通知は 1 行のコードから始まります。注文が発送されたらメールを送る、というものです。1 年後、同じ会社は十数個のサービスからプッシュ、メール、SMS を送るようになり、ユーザーは同じ通知を 3 回受け取り、パスワードリセットのメールが届いたかどうか誰にもわからず、マーケティングのメッセージが深夜 3 時に人を起こしています。
通知システムは、何かが起きたと判断するコードと、メッセージを届けるプロバイダーの間に 1 つのサービスを置くことで、これを解決します。この記事ではそのサービスを設計します。API、送信前に走るチェック、再試行付きのチャネル別配信、そして追跡です。終盤の図で、全体の流れを示します。
要件
- プロダクトサービスは、ユーザーがどのチャネルを有効にしているかを知らなくても、1 回の呼び出しで通知を送れる。
- プッシュ(iOS と Android)、メール、SMS。言語ごとのテンプレートを持つ。
- ユーザーはチャネルとトピックを管理でき、おやすみ時間が守られる。
- 同じイベントから同じメッセージが 2 回生まれることはない。
- すべてのメッセージに、送信済み、配信済み、バウンス、失敗といったステータスがある。
- 遅いプロバイダーが 1 つあっても、ほかのチャネルは遅れない。
API:メッセージではなくイベントを送る
プロダクトサービスがメールを組み立てるべきではありません。事実と冪等キーを含むイベントを送ります。
POST /notifications
{
"idempotencyKey": "order-8812-shipped",
"userId": "u_7",
"type": "order_shipped",
"data": {"orderId": "8812", "carrier": "UPS", "eta": "2026-10-30"}
}
それ以外はすべて通知サービスの担当です。この種類の通知がどのチャネルを使うか、どのテンプレートか、どの言語か、そしてユーザーがそもそも受け取りたいかどうか。チームが文面を変えたり、ある種類の通知に SMS を追加したりしても、注文サービスは何も変わりません。
送信する前に
処理がチャネルに届く前に、API でこの順に 4 つのチェックを実行します。
- 重複を排除する。 冪等キーを TTL 付きで保存し、繰り返しを捨てます。上流のサービスでの再試行は普通のことで、それが重複としてユーザーに届いてはいけません。
- 設定を確認する。 ユーザーのチャネル、トピックごとの配信停止、おやすみ時間を調べます。パスワードリセットのようなセキュリティ通知は、たいていマーケティングの配信停止を無視して届けます。マーケティングが配信停止を無視することはありません。このルールは文書にしておきます。
- 量に上限を設ける。 ユーザー、チャネル、1 時間ごとのメッセージ数を制限します。500 件のイベントを発火させるバグが、500 件のプッシュ通知になってはいけません。
- 描画する。 通知の種類、チャネル、ユーザーの言語に合ったテンプレートを選び、イベントのデータで埋めます。
おやすみ時間にはユーザーのタイムゾーンが必要です。急ぎでない限り、メッセージは捨てずに保留し、時間帯の終わりに送るよう予約します。
チャネルごとに 1 つのキューとワーカープール
チェックのあと、API はチャネルごとに 1 件のジョブを、そのチャネルのキューに書き込みます。キューを分けることが、この設計で最も重要な判断です。SMS プロバイダーが遅くなっても、SMS のジョブは自分のキューにたまるだけで、プッシュとメールは流れ続けます。
チャネルのワーカーはプロバイダーを呼び出し、それぞれのクセに対応します。
- プッシュ。 iOS 端末は Apple Push Notification service を、Android は FCM HTTP v1 API を通ります。デバイストークンは古くなります。APNs は有効でなくなったトークンに 410 を返し、FCM は unregistered エラーを返します。そうしたトークンは削除します。さもないと、誰にも届かないメッセージのために遅延を払い続けることになります。
- メール。 プロバイダー経由で送り、バウンスと苦情を報告として受け取ります。一斉メールを送るなら、RFC 8058 で定義されたワンクリック配信停止のヘッダーを付けます。Gmail の 送信者のガイドライン は、1 日に 5,000 通を超えて送る送信者に対し、マーケティングと購読型のメッセージでワンクリック配信停止を求めています。
- SMS。 メッセージあたり最も高価で、最も規制の多いチャネルです。認証コードと緊急の通知に限って使い、送信先の国ごとにルールを確認します(この記事は法的助言ではありません)。
ワーカーは一時的な失敗を指数バックオフとジッターで再試行し、決まった回数を超えたジョブはデッドレターキューに移します。無効なアドレスのような恒久的な失敗は再試行しません。
配信の追跡
プロバイダーは、何が起きたかを Webhook や応答コードで知らせてきます。受け付け、配信、バウンス、開封などです。ステータスの変化はすべて、通知 ID をキーにした配信ログに書き込みます。そうすれば、サポートは「ユーザーはリセットのメールを受け取ったか」に数秒で答えられ、チャネルとプロバイダーごとの配信率も測れます。
ステータスの更新は順不同で、ときには 2 回届きます。ステータスの列を上書きするのではなく、タイムスタンプ付きのイベントとして保存し、そこから現在の状態を導き出します。
アプリ内通知
多くの製品には、アプリ内の受信箱もあります。件数付きのベルのアイコンです。これは、プロバイダーを呼ぶ代わりにデータベースに書き込む 4 つ目のチャネルとして扱います。ユーザーと通知ごとに既読フラグ付きの行を保存し、ユーザーと時刻でインデックスを張り、アプリが最新のページと未読件数を取得できるようにします。既読はまとめて付けるようにして、受信箱を開いたユーザーが 50 回の書き込みを起こさないようにします。
ざっくりした見積もり
1 日のアクティブユーザーが 100 万人で、1 人あたり 1 日 5 件の通知なら、システムは 1 日に 500 万件、平均で毎秒約 58 件を送ります。重要なのはピークです。キャンペーンや障害のアラートは、数分で 100 万件のメッセージを送ることがあります。キューはバーストに合わせてサイズを決め、ワーカーには各プロバイダーが許す速度でキューを消化させます。そして、自分のワーカーの処理能力が尽きる前に、プロバイダーのほうが流量を絞ってくると考えておきます。
アーキテクチャ図
ダイアグラムの内容
- Product services
- Notification API
- Preferences
- Push queue
- Email queue
- SMS queue
- Push workers
- Email workers
- SMS workers
- APNs and FCM
- Email provider
- SMS provider
- Delivery log
- event
- opt-outs, quiet hours
- status
- webhooks
図の読み方は次のとおりです。
- 左: プロダクトサービスは、冪等キー付きのイベントを通知 API に送ります。API は設定ストアを読み、Markdown ブロックに書いたチェックを実行します。
- 中央: 各チャネルが専用のキューとワーカープールを持つので、遅いプロバイダーが詰まらせるのは自分のチャネルだけです。
- 右: ワーカーは APNs と FCM、メールプロバイダー、SMS プロバイダーを呼び出します。ステータスの更新はあとから非同期に届くので、配信ログへは点線のエッジで戻ります。
図に載せるべきこと
- チェックとその順序。 重複排除、設定、上限、描画が、このシステムの実際のロジックです。
- チャネルごとのレーン。 図の上ですべてのチャネルが 1 つのキューを共有しているなら、本番でもおそらく共有しています。
- 再試行とデッドレターの挙動。 各ワーカーに、バックオフと試行回数の上限を書いておきます。
- ステータスが戻る経路。 これがないと、届かなかったメッセージを誰もデバッグできません。
キューとコンシューマー側について詳しくは、イベント駆動アーキテクチャの記事 を参照してください。このような共有サービスがほかのサービスの間のどこに位置するかは マイクロサービスのアーキテクチャ図 で示しています。また、連絡先や配信ログを保存する前に GDPR のデータマッピングの記事 を読んでおく価値があります。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- C4 モデルパックから、プロダクトサービス用と通知 API 用に コンテナ を 1 つずつ追加し、API の下に設定用の データベース を置きます。
- データパックから、チャネルごとに 1 つずつ キュー ノードを 3 つ追加し、各チャネルのワーカー用に コンテナ を追加します。
- プロバイダー用に C4 モデルパックから 外部システム ノードを 3 つ、配信ログ用に データベース を追加します。
- ステータスのエッジは点線にして、送信前のチェックを書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 14 個で、Free プランの範囲に収まります。
よくある質問
各サービスがそれぞれ通知を送るのではだめですか? そうすると、設定、おやすみ時間、重複排除、配信追跡をすべてのサービスで作ることになり、少しずつ食い違っていきます。1 つの通知サービスなら、どこでも同じように適用できます。
通知の重複を防ぐには? すべてのリクエストに冪等キーを必須にし、しばらく保存しておきます。同じキーが来たら受理したうえで捨てます。
プッシュ、メール、SMS で 1 つのキューを共有すべきですか? いいえ。キューとワーカープールを分けておけば、遅いプロバイダーや障害の起きたプロバイダーがほかのチャネルを遅らせることはありません。
おやすみ時間はどう扱えばよいですか? ユーザーごとのタイムゾーンとおやすみの時間帯を保存し、急ぎでないメッセージは送ったり捨てたりせず、時間帯の終わりに送るよう予約します。
通知が配信されたことはどうやってわかりますか? プロバイダーの応答と Webhook を、通知 ID をキーにした配信ログに記録します。「配信済み」の意味はチャネルごとに違うので、プロバイダー自身のステータス名を記録します。