
こんにちは、MA部配信基盤ブロックの田島です。ZOZOTOWNではユーザへのコミュニケーション手段の1つとしてアプリへのPush通知を活用しており、配信にはFirebase Cloud Messaging(以降、FCM)を利用しています。
FCMではPush通知の送信先となるデバイスごとに「FCMトークン」と呼ばれる一意の識別子が発行され、このトークンを宛先としてFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。
FCMでは無効なトークンに対してUNREGISTEREDエラーを返します。Firebaseの公式ドキュメントでは、このエラーが返されたトークンを無効として扱うことが推奨されています。しかし、我々の調査により、一度UNREGISTEREDエラーを受けたトークンがその後復活し、再び有効になるケースの存在を確認しました。復活したトークンで配信すると正常にPush通知が届きクリックイベントも取得できることから、確実に有効なトークンであることを確認しています。
本記事では、このトークン復活の実態調査と、FCMのvalidate_only APIを活用したエラートークン管理の精緻化について紹介します。
目次
- 目次
- 背景と課題
- エラートークン復活の調査
- 方針の検討
- 決定した方針
- FCM validate_only フラグを利用したトークンの検証
- エラートークンの収集と検証バッチの実装
- 既存の全トークンの再検証と本番リリース
- まとめ
- 最後に
背景と課題
FCMトークンとは
最初にも紹介しましたが、FCMトークンとは、FCMがPush通知の送信先を識別するために、アプリがインストールされた各デバイスに対して発行する一意の識別子です。アプリの初回起動時にFCM SDKがこのトークンを生成し、このトークンを指定してFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。
配信フローとしては、サーバからFCMにメッセージリクエストが送られます。FCMはプラットフォーム固有の転送層(Androidの場合はATL、iOSの場合はAPNs)を経由して対象デバイスにメッセージを届けます。

FCMトークンは永続的でなく、以下のような理由で無効化や更新が発生します。
- トークンがリフレッシュされた場合
- トークンの保持期間を超過した場合
- アプリがアンインストールされた場合
無効になったトークンを使ってFCMにリクエストを行うと、UNREGISTEREDエラーが返されます。
既存のエラートークン管理の問題
Firebaseの公式ドキュメントでは、UNREGISTEREDエラーが返されたトークンを無効として扱うことがベストプラクティスとして紹介されています。
こちらに則り、UNREGISTEREDエラーが返されたトークンを無効として記録し、以降の配信対象から除外していました。しかし、UNREGISTEREDエラーを受けたトークンがその後再び有効になるケースの存在を確認しました。この場合、本来配信すべきユーザにPush通知が届かなくなってしまいます。
まずはユーザへの配信が確実にできることを優先し、エラートークンの登録処理を一時的に停止した上で、復活の頻度や傾向を正確に把握するための調査を実施しました。
エラートークン復活の調査
調査内容
エラートークンの管理方針を決めるにあたり、以下の点を調査しました。
SUCCESS→UNREGISTERED→SUCCESSが発生する頻度UNREGISTEREDが何回連続した後SUCCESSへ復帰するケースがあるかUNREGISTEREDがどれくらいの期間続いた後SUCCESSへ復帰するケースがあるかSUCCESSに復帰後、どれくらいの回数成功が続くか
調査方法
約2.5か月分(2025年8月以降)の配信ログを対象に、同一トークンにおけるステータス遷移を分析しました。
分析に使用したpush_logsテーブルは、Push通知の配信結果を1リクエストごとに記録したログテーブルです。主なカラムは以下の通りです。
| カラム名 | 型 | 説明 |
|---|---|---|
token |
STRING | 配信先のFCMトークン |
delivered_at |
TIMESTAMP | 配信日時 |
status |
STRING | 配信結果(SUCCESS, FAILED) |
status_detail |
STRING | 失敗時の詳細(UNREGISTEREDなど) |
fcm_message_id |
STRING | FCMが発行したメッセージID |
以下のクエリで、SUCCESSとSUCCESSの間にUNREGISTEREDが挟まるケースを抽出しています。
WITH base AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id FROM `project.dataset.push_logs` WHERE TIMESTAMP_TRUNC(delivered_at, DAY) >= TIMESTAMP("2025-08-01") AND TIMESTAMP_TRUNC(delivered_at, DAY) <= TIMESTAMP("2025-10-15") AND token IS NOT NULL AND status IN ('SUCCESS', 'FAILED') ), -- トークンごとに時系列でインデックスを付与 ordered AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id, ROW_NUMBER() OVER (PARTITION BY token ORDER BY delivered_at, fcm_message_id) AS rn FROM base ), -- 累積のUNREGISTERED失敗数などを付与 ord AS ( SELECT o.*, SUM(CASE WHEN o.status = 'FAILED' AND o.status_detail = 'UNREGISTERED' THEN 1 ELSE 0 END) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_unreg_failed, MIN(IF(o.status != 'SUCCESS', o.rn, NULL)) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS next_non_success_rn, COUNT(*) OVER (PARTITION BY o.token) AS total_rows FROM ordered o ), -- SUCCESS行から直前のSUCCESSとの関係を取得 success_pairs AS ( SELECT s.token, s.rn AS success_rn, s.delivered_at AS success_at, LAG(s.rn) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_rn, LAG(s.delivered_at) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_at FROM ord s WHERE s.status = 'SUCCESS' ), -- 直前SUCCESS〜今回SUCCESSの間にあるUNREGISTERED失敗件数を算出 final AS ( SELECT sp.token, sp.prev_success_at, sp.success_at AS recover_success_at, (oc.cum_unreg_failed - COALESCE(op.cum_unreg_failed, 0)) AS unreg_failed_between_successes, (COALESCE(oc.next_non_success_rn, oc.total_rows + 1) - oc.rn) AS consecutive_success_count_after FROM success_pairs sp JOIN ord oc ON oc.token = sp.token AND oc.rn = sp.success_rn LEFT JOIN ord op ON op.token = sp.token AND op.rn = sp.prev_success_rn WHERE sp.prev_success_rn IS NOT NULL ) SELECT * FROM final WHERE unreg_failed_between_successes > 0 ORDER BY recover_success_at;
調査結果
| 項目 | 結果 |
|---|---|
SUCCESS → UNREGISTERED → SUCCESSの発生頻度 |
2.5か月で約230件 |
UNREGISTEREDの最大の連続回数 |
約80回 |
UNREGISTEREDが続く最大期間 |
約14日 |
SUCCESS復帰後の成功回数 |
ケースにより異なる |
この結果から、UNREGISTEREDの返されたトークンが復活するケースは確かに存在することがわかりました。また、UNREGISTEREDの連続する回数・期間も把握できました。
トークン復活に関する補足
FCMの公式ドキュメントでは、UNREGISTEREDエラーが返されたトークンについて「it will never again be valid(二度と有効にはならない)」と明記されています。そのため、即座に削除することが推奨されています。

ただし、FCMのエラーコードに関するドキュメントでは「This usually means that the token used is no longer valid and a new one must be used.(通常、使用されたトークンはもはや有効ではなく、新しいトークンを使用する必要があることを意味します)」という表現になっており、「usually」という留保がついています。

実際に復活したトークンを使って配信すると正常にPush通知が届き、クリックイベントも取得できることを確認しています。トークンがリフレッシュされて新しいものが発行されたわけでもなく、同一のトークンがそのまま再び有効になっていました。公式ドキュメントの記述と実際の挙動に乖離がある状況です。
なお、この挙動は2026年3月時点でも確認されています。将来的にFCM側で修正される可能性もあるため、最新の挙動については各自で検証されることをお勧めします。
方針の検討
調査により、トークンの復活は2.5か月で約230件と少数ながら確実に発生しており、最長で約14日間UNREGISTEREDが続いた後に復活するケースも確認されました。
この結果を踏まえると、エラートークンの管理には以下の2点を両立させる必要があります。
- 無効なトークンへの無駄な配信を早期に止めること
- 復活する可能性のあるトークンを誤って永久に除外しないこと
これらを考慮し、以下の2つの方針を検討しました。
方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い
1つめの方針は、1か月ずっとUNREGISTEREDとなっているトークンをエラー扱いにする方式です。調査結果から14日以上UNREGISTEREDが続くケースはなかったため、1か月の閾値で安全にエラートークンを判定できます。これにより、本当に無効となったトークンのみをエラートークンとして保持できます。
方針2: 即時エラー登録 + validate_onlyで定期解除
従来通りUNREGISTEREDになったトークンをエラー扱いとしつつ、定期的にvalidate_onlyでトークンの有効性を再検証し、復活したトークンをエラーリストから除外する方式です。validate_onlyについては後ほど説明します。これにより、無効と判定したトークンを即時無効にしつつ、復活したトークンに対しても配信を継続できます。
決定した方針
両方針を比較した結果、以下の理由から方針2を採用しました。
- 方針1だと、一度
UNREGISTEREDとなったトークンが復活しない場合、1か月の間無効なトークンに配信し続けてしまう - 初回の
validate_only検証を既存の全エラートークンに実施することで、これまでに蓄積したエラートークンを有効活用できる - 既存のエラートークン登録フローを大きく変更する必要がない
FCM validate_only フラグを利用したトークンの検証
validate_only フラグ
FCMのmessages.send API(FCMにPush通知送信を依頼するAPI)にはvalidate_onlyフラグがあります。これをtrueに設定すると、実際にメッセージを配信せずにトークンの有効性のみを検証できます。
動作検証
validate_only(Firebase Admin SDKではdry_runパラメータに対応)が本当に配信しないことを事前に検証しました。dry_run=Trueの場合、レスポンスのmessage_idがfake_message_idとなり、実際のメッセージ配信は行われません。これにより、安全にトークンの有効性を確認できることが実証されました。
dry_runの詳細な検証については、以下の記事にまとめています。
エラートークンの収集と検証バッチの実装
ここからは、エラートークンの収集と検証の方法について紹介します。
テーブル設計
本施策では主に2つのテーブルを使用します。
エラートークンテーブル(error_fcm_tokens)
UNREGISTEREDエラーが返されたトークンを記録するテーブルです。FCMトークンそのものをキーとして管理することで、トークンの有効性を直接的に判定できるようにしています。
| カラム名 | 型 | 説明 |
|---|---|---|
fcm_token |
STRING | エラーとなったFCMトークン |
first_errored_at |
TIMESTAMP | 初めてUNREGISTEREDエラーが発生した日時 |
registered_at |
TIMESTAMP | エラートークンとして登録した日時 |
再有効化テーブル(reactivated_fcm_tokens)
一度エラーとなったが、validate_onlyによる再検証で有効と判定されたトークンの履歴を記録するテーブルです。
| カラム名 | 型 | 説明 |
|---|---|---|
fcm_token |
STRING | 再有効化されたFCMトークン |
validated_at |
TIMESTAMP | validate_onlyで検証した日時 |
reactivated_at |
TIMESTAMP | エラートークンテーブルから削除し再有効化した日時 |
エラートークンの収集・再検証ワークフロー
エラートークンの収集と再検証を日次で行うワークフロー(refresh_error_fcm_tokens)を作成しました。バッチ処理にはワークフローエンジンのDigdagを使用しています。Digdagのワークフロー定義は以下の通りです。Digdagでは+で始まるブロックがタスクを表し、上から順に実行されます。
timezone: Asia/Tokyo schedule: daily>: 00:00:00 # 毎日0時に実行 # ワークフロー全体で使う変数の定義 _export: # 検証結果を格納する一時テーブル名(実行日ごとに一意になるようにする) validated_fcm_tokens_temp_table_id: "project.temp.validated_fcm_tokens_temp_${moment(session_time).format('YYYYMMDD')}" # 並列処理のシャード数 total_shards: 50 # 1. 配信ログからUNREGISTEREDエラーのトークンを収集し、エラートークンテーブルに登録 +collect_fcm_error_tokens: py>: app.collect_fcm_error_tokens # 2. 検証結果を格納する一時テーブルを作成 +create_temp_table: py>: app.refresh_error_fcm_tokens.create_validation_temp_table # 3. エラートークンを50シャードに分割し、並列でFCM APIに検証リクエストを送信 # loop>: 0〜49のインデックス(${i})で繰り返し、_parallel: trueで全シャードを同時実行 +validate_fcm_tokens_parallel: _parallel: true loop>: ${total_shards} _do: +validate_shard: py>: app.refresh_error_fcm_tokens.validate_fcm_tokens_shard shard_index: ${i} total_shards: ${total_shards} # 4. 一時テーブルの検証結果をもとに、有効なトークンをエラートークンテーブルから削除し、 # 再有効化テーブル(reactivated_fcm_tokens)に記録 +update_error_and_reactivated_fcm_tokens: py>: app.refresh_error_fcm_tokens.update_error_and_reactivated_fcm_tokens
以下でそれぞれについて具体的に説明します。
1. エラートークンの収集
はじめに配信ログテーブルからUNREGISTEREDエラーのトークンを以下のSQLで収集し、エラートークンテーブルに追加します。このワークフローは日次で実行されますが、対象期間を直近3日間としています。これは、ワークフローが2日連続で失敗した場合でも3日目の実行で未収集分をカバーできるようにするためです。
-- エラートークンの収集クエリ SELECT token AS fcm_token, MIN(delivered_at) AS first_errored_at, CURRENT_TIMESTAMP AS registered_at FROM `project.ma_batch.push_logs` AS push_logs LEFT OUTER JOIN `project.push.error_fcm_tokens` AS target ON push_logs.token = target.fcm_token WHERE status = "FAILED" AND status_detail = "UNREGISTERED" -- 日次実行だが、2日連続WF失敗時でも3日目に回復できるよう3日分のバッファを確保 AND DATE(delivered_at) >= DATE_ADD(CURRENT_DATE('Asia/Tokyo'), INTERVAL -3 DAY) AND target.fcm_token IS NULL GROUP BY token
2. 検証用一時テーブルの作成
トークンの有効性の検証結果を格納するための一時テーブルを作成します。各シャードがFCM APIの検証結果をこのテーブルに書き込み、最後にまとめてエラートークンテーブルを更新します。
DROP TABLE IF EXISTS `{validated_fcm_tokens_temp_table_id}`; CREATE TABLE `{validated_fcm_tokens_temp_table_id}` ( fcm_token STRING NOT NULL, -- 検証対象のFCMトークン validated_at TIMESTAMP NOT NULL, -- 検証日時 valid BOOLEAN NOT NULL, -- 有効かどうか error_code STRING, -- 無効だった場合のエラーコード );
3. エラートークンの再検証(並列処理)
エラートークンテーブルに登録済みのトークンに対し、FCMのvalidate_only APIでトークンの有効性を再検証します。この処理は50シャードに分割して並列実行されます。
検証対象トークンの取得
各シャードが担当するトークンを取得するSQLは以下の通りです。FARM_FINGERPRINTでトークンをハッシュ化し、シャード数で剰余を取ることで均等に分割しています。また、first_errored_atが直近30日以内のトークンのみを対象とし、復活の見込みが低い古いトークンへの無駄な検証を避けています。この期間は、調査でわかったUNREGISTEREDが続く最大期間の約14日に余裕をもたせて設定しています。
SELECT error_tokens.fcm_token, error_tokens.first_errored_at FROM `project.push.error_fcm_tokens` AS error_tokens LEFT JOIN `{validated_fcm_tokens_temp_table_id}` AS temp_tokens ON error_tokens.fcm_token = temp_tokens.fcm_token WHERE -- 直近30日以内に登録されたエラートークンのみを対象 error_tokens.first_errored_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND -- FARM_FINGERPRINTでトークンをハッシュ化し、シャードに均等分割 MOD(ABS(FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND -- リトライ時に既に処理済みのトークンを除外 temp_tokens.fcm_token IS NULL
シャード単位の検証処理
各シャードでは上記のSQLで取得したエラートークンに対し、FCMのdry_run(validate_onlyに対応)でトークンの有効性を検証しています。検証対象のトークン数は数百万件に及ぶため、メモリ効率を考慮して5,000件ごとのバッチに分割して処理しています。検証結果は一時テーブルに書き込まれます。
def validate_fcm_tokens_shard(self, shard_index, total_shards, ...) -> None: # リトライ時に途中から再開できるよう、5,000件ずつ処理する BATCH_SIZE = 5000 # BigQueryからこのシャードが担当するエラートークンを取得 # (例: shard_index=0, total_shards=50 なら、全体の1/50を担当) result = self._bq_client.execute_bigquery_result( query_path="get_error_fcm_tokens_shard.sql", params={"shard_index": shard_index, "total_shards": total_shards}, ) fcm_client = FCMClient(fcm_gcp_project) # 5,000件ずつFCM APIで検証し、結果を一時テーブルに書き込む for batch_tokens in self._create_batches(result, BATCH_SIZE): valid_tokens, invalid_tokens = fcm_client.validate_tokens_batch(batch_tokens) self._insert_validation_results(valid_tokens, invalid_tokens)
FCM APIによるトークン検証
FCMトークンの実際の検証では、Firebase Admin SDKのmessaging.send_eachをdry_run=Trueで呼び出しています。実際にメッセージを配信せずにトークンの有効性のみを検証できます。send_eachは1リクエストあたり最大500件のため、500件単位で分割してリクエストを送信しています。
class FCMClient: BATCH_SIZE = 500 # send_eachの1リクエストあたりの最大件数 def validate_tokens_batch(self, tokens: List[str]) -> Tuple[List[str], List[Tuple[str, str]]]: valid_tokens = [] # 有効と判定されたトークンのリスト invalid_tokens = [] # 無効と判定されたトークンと、そのエラーコードのリスト # 500件ずつに分割してFCM APIにリクエスト for i in range(0, len(tokens), self.BATCH_SIZE): batch = tokens[i:i + self.BATCH_SIZE] # 各トークンに対してダミーのメッセージオブジェクトを生成 messages = [ messaging.Message(token=token, data={'validation': 'true'}) for token in batch ] # dry_run=True により実際の配信は行わず、トークンの有効性のみ検証 batch_response = messaging.send_each(messages, dry_run=True) # レスポンスからトークンごとの有効/無効を判定 for idx, response in enumerate(batch_response.responses): token = batch[idx] if response.success: valid_tokens.append(token) else: error_code = response.exception.code if response.exception else "Unknown" invalid_tokens.append((token, error_code)) return valid_tokens, invalid_tokens
4. エラートークンテーブルの更新
全シャードの検証が完了した後、一時テーブルの結果をもとにエラートークンテーブルと再有効化テーブルをトランザクション内で一括更新します。有効と判定されたトークンを再有効化テーブルにMERGEし、エラートークンテーブルから削除しています。
BEGIN TRANSACTION; -- 一時テーブルから有効と判定されたトークンを重複排除して抽出 CREATE TEMP TABLE deduped_tokens AS SELECT DISTINCT fcm_token, MAX(validated_at) AS validated_at, CURRENT_TIMESTAMP() AS reactivated_at FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE GROUP BY fcm_token; -- 有効なトークンを再有効化テーブルに記録 MERGE `project.push.reactivated_fcm_tokens` AS target USING deduped_tokens AS source ON (target.fcm_token = source.fcm_token AND target.validated_at = source.validated_at) WHEN NOT MATCHED THEN INSERT (fcm_token, validated_at, reactivated_at) VALUES (source.fcm_token, source.validated_at, source.reactivated_at); -- 有効なトークンをエラートークンテーブルから削除 DELETE FROM `project.push.error_fcm_tokens` WHERE fcm_token IN ( SELECT DISTINCT fcm_token FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE ); COMMIT TRANSACTION;
パフォーマンス
上記の処理がどれくらいの時間で完了するのか、パフォーマンス計測をした結果は以下の通りです。
| 対象件数 | 並列数 | 処理時間 |
|---|---|---|
| 10万件 | 1並列 | 約25分 |
| 約800万件(全量) | 50並列 | 約50分 |
また、FCM APIのQuotaについても確認し、日中に実行しても問題ない余裕があることを確認しました。
既存の全トークンの再検証と本番リリース
初回実行:全期間のエラートークンを検証
初回実行では、過去に蓄積された全エラートークン(約754万件)を対象に検証しました。通常運用では直近30日以内のエラートークンのみを検証対象としていますが、初回は既存の全トークンの検証が必要でした。そのため、検証対象を取得するSQLの30日の条件を一時的にコメントアウトしてワークフローを実行しました。
WHERE -- 初回実行時は全期間のエラートークンを対象にするため一時的にコメントアウト -- error_tokens.first_errored_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND MOD(ABS(FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND temp_tokens.fcm_token IS NULL
- 実行前のエラートークン数:約7,500,000件
- エラートークン収集直後:約+70件(新規エラートークン)
- 再検証後:約ー170件(復活トークン)
約170件のトークンがvalidate_onlyで有効と判定され、エラートークンから解除されました。
通常運用の開始
初回実行後、1か月以内に登録されたエラートークンを対象とする通常運用を開始しました。
- 実行前のエラートークン数:約7,500,000件
- エラートークン収集直後:約+6,500件(新規エラートークン)
- 再検証後:約ー10件(復活トークン)
日次で約10件のトークンが再有効化されていることが確認できました。
まとめ
本記事では、FCMエラートークンの管理を精緻化した取り組みについて紹介しました。
従来はUNREGISTEREDエラーの返されたトークンを即時かつ永続的にエラー扱いとしていました。しかし調査の結果、一度無効になったトークンが復活するケースの存在を確認しました。そこでFCMのvalidate_only APIを活用した定期的な再検証の仕組みを導入し、復活したトークンを自動的にエラーリストから解除するようにしました。
この改善により、以下の効果が得られました。
- 無効トークンへの無駄な配信リクエストの削減によるコスト最適化
- セグメントのボリューム把握の精度向上
- トークン復活時の配信漏れ防止
FCMトークンの管理は、Push配信の品質とコストに直結する重要な要素です。同様の課題をお持ちの方の参考になれば幸いです。
最後に
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。