通知システムの設計
概要
この記事は、「システム設計の面接試験」(Alex Xu 著)を読み学習した内容を個人学習用にまとめ直したものです。
本記事では、プッシュ通知・SMS・Eメールといった複数の手段でユーザーに情報を届ける通知システムの設計について解説します。
「メッセージを1通送る」だけなら、外部サービスのAPIを1回呼ぶだけで済みます。しかし1日に数百万件、数千万件を配信するとなると、送信手段ごとに異なる外部サービスへの依存、送信に失敗したときの再試行、同じ通知が二重に届かないようにする仕組みなど、設計上の論点が次々に現れます。本記事ではそれらを順に整理していきます。
設計の要件
本記事では、Alex Xu の設計演習にならい、次の3種類の通知を配信できるシステムを設計する。
- プッシュ通知(push notification): モバイル端末やPCのOSに対して送る通知
- SMSメッセージ: 電話番号宛に送るショートメッセージ
- Eメール: メールアドレス宛に送るメール
そのうえで、以下の制約・前提のもとで設計する。
- 対応端末: iOS端末・Android端末・ラップトップ/デスクトップPCに対応する
- 通知のトリガー: クライアントアプリケーションからの送信要求と、サーバーサイドのスケジューリングによる送信の、どちらからも通知を発行できる
- オプトアウト: ユーザーは自分で通知をオフにできる。オフにしたユーザーには通知を送らない
- 配信規模: 1日あたり、モバイルプッシュ通知 1,000万件、SMSメッセージ 100万件、Eメール 500万件
最後に、リアルタイム性については次の前提を置く。
- ソフトリアルタイム(soft real-time): 通知はできるだけ早く届けるが、システムの負荷が高い場合は多少の遅延を許容する
なお、1日あたり合計1,600万件という配信規模は、1秒あたりに直せば大きな数字ではなく、このシステムの難しさは件数をさばくこと自体にはない。
通知の種類ごとの仕組み
設計に入る前に、プッシュ通知・SMS・Eメールがそれぞれどのような経路でユーザーの手元に届くのかを押さえておく。別々の仕組みに見える4つの経路を順に見たうえで、最後に共通点を整理する。
iOSのプッシュ通知
iOS端末へのプッシュ通知は、Appleが提供する APNS(Apple Push Notification Service)を経由して届けられる。通知の内容と宛先を組み立ててAPNSへ送信する側を通知プロバイダ(provider)と呼び、自分たちが構築するサーバーがこれにあたる。
通知プロバイダがAPNSへ送信する際に必要となるデータは、次の2つである。
- デバイストークン(device token): 通知の宛先を表す、端末ごとに一意な識別子。「どの端末に届けるか」はこのトークンだけで決まる
- ペイロード(payload): 通知の中身を表すJSONデータ。表示するメッセージなどを含む
ペイロードは、たとえば次のような形をしている。
{
"aps": {
"alert": {
"title": "ゲームリクエスト",
"body": "対戦を申し込まれました",
"action-loc-key": "対戦する"
},
"badge": 5
}
}
aps はApple側が定義するキーで、通知の表示方法を指示する部分である。alert に表示する文言を、badge にアプリアイコンに重ねて表示する数字を指定している。
Androidのプッシュ通知
Android端末へのプッシュ通知は、Googleが提供する FCM(Firebase Cloud Messaging)を経由して届けられる。APNSの位置にFCMが入るだけで、仕組みはiOSと同じである。
送信に必要なデータも対応しており、宛先を表す識別子は 登録トークン(registration token)と呼ばれる。ペイロードもJSONである。
{
"message": {
"token": "<登録トークン>",
"notification": {
"title": "ゲームリクエスト",
"body": "対戦を申し込まれました"
}
}
}
キーの名前や階層こそ違うが、「宛先の識別子」と「表示する中身」を渡すという構造はAPNSと変わらない。iOSとAndroidの違いは、どのサービスにどんな形式で渡すかだけである。
SMSメッセージ
SMSの送信には、Twilio や Vonage(旧Nexmo)といった商用のSMSサービスを利用するのが一般的である。自前で携帯電話網に接続するのは現実的でないためである。
プッシュ通知との違いは、宛先がトークンではなく電話番号である点である。トークンはアプリをインストールした端末からしか得られないが、電話番号はアプリの有無に関係なく使える。本人確認コードの送信のように、アプリのインストールを前提にできない場面で選ばれるのはこのためである。
Eメール
Eメールは自前のメールサーバーからでも送信できるが、SendGrid や Mailchimp といった商用のメール配信サービスを使うことが多い。要件にあるラップトップ・デスクトップPCへ通知を届ける手段としても、Eメールが中心になる。
自前で送らず商用サービスを使う理由は、配信到達率(delivery rate)にある。メールは送信しても受信側に迷惑メールと判定されれば、ユーザーの目に触れないまま終わる。商用サービスは送信元の評価を維持する仕組みを備えており、この判定を回避しやすい。加えて、開封率やクリック率といった分析データを取得できる点も利点である。
4つの経路の共通点
ここまで見た4つの経路を整理する。
| 通知の種類 | 経由するサービス | 宛先を表すもの |
|---|---|---|
| iOSプッシュ通知 | APNS | デバイストークン |
| Androidプッシュ通知 | FCM | 登録トークン |
| SMSメッセージ | Twilio・Vonage など | 電話番号 |
| Eメール | SendGrid・Mailchimp など | メールアドレス |
いずれも第三者のサービスを経由する点は同じで、違うのは渡すサービスと宛先を表すものだけである。そして、この2つの列がそれぞれ設計上の課題を生む。
経由するサービスが第三者であるということは、自分たちのサーバーが行えるのは通知を渡すところまでで、実際に届くかどうかは管理の外にあるということである。だからこの先の設計は、「どうやって送るか」よりも「渡した先で失敗したときにどう立て直すか」が中心になる。
宛先を表すものは、トークンにせよ電話番号にせよ、自分たちのサーバーがあらかじめ保持していなければ通知を送れない。次のセクションでは、これらの連絡先情報をどのように集めて保存するかを見ていく。
連絡先情報の収集と保存
収集のタイミング
連絡先情報は、ユーザーがアプリをインストールしたときやサインアップしたときに集める。電話番号やメールアドレスはユーザー自身が入力するが、デバイストークンだけは事情が異なる。トークンを発行するのはAPNSやFCMであり、アプリがそれを受け取ってからサーバーへ送る、という手順を踏む。
データモデル
保存先は、ユーザーの情報を持つ user テーブルと、端末の情報を持つ device テーブルの2つに分かれる。
なぜ端末を別のテーブルに分けるのか
1人のユーザーが、スマートフォンとタブレットのように複数の端末を使うことがあるためである。プッシュ通知の宛先は端末ごとに発行されるトークンで決まるので、user テーブルに1つのトークンを持たせる形では、複数端末に対応できない。
ER図の ||--o{ はこの1対多を表している。この関係により、「あるユーザーに通知を送る」という指示は、そのユーザーに紐づくすべての端末に送るという処理に展開できる。
デバイストークンは永続的な値ではありません。アプリの再インストールや端末の変更によって変わります。古いトークンに送り続けても通知は届かないため、トークンが変わったら上書きし、送信時に無効と判明したものは削除する仕組みが必要になります。
通知の送信フロー
ここからは、送信の要求が入ってから第三者のサービスに渡るまでの構成を組み立てていく。まず通知がどこから発生するのかを整理し、最小構成を組んだうえで、その問題点をもとに改善する。
通知の発生源
要件より、通知は次の2つの経路から発生する。
- クライアントアプリケーションからの送信要求: ユーザーの操作が起点になるもの。メッセージを送ったら相手に届く、といった通知がこれにあたる
- サーバーサイドのスケジューリング: 時刻や条件が起点になるもの。リマインダーや未読のまとめ通知など、ユーザーの操作を伴わずに発生する
さらに実際のシステムでは、発生源はこの2つに限られない。課金サービスやメッセージサービスなど、社内の複数のサービスがそれぞれ通知を送りたい状況になる。
ここで、なぜ通知を専用のシステムに集約するのかがはっきりする。各サービスが個別にAPNSやFCMを呼ぶ形にすると、デバイストークンの管理、失敗したときの再試行、通知をオフにしたユーザーの除外といった処理を、サービスの数だけ重複して実装することになるためである。
したがって、発生源が通知システムに渡すのは「誰に」「何を」送るかだけでよい。どの経路を使うか、その人のどの端末に送るかを解決するのは、通知システム側の仕事になる。
最小構成
通知システムがやることは、送信要求を受け取る、宛先を引く、通知の種類に応じて第三者のサービスへ渡すの3つである。これを1台の通知サーバーで行うと、次の構成になる。
最小構成の問題点
動きはするが、実運用には耐えない。問題は3つある。
1. 単一障害点(single point of failure)
通知サーバーが1台しかないため、これが落ちるとすべての通知が止まる。さらに、そのとき処理の途中だった送信要求は復元する手立てがなく、そのまま失われる。
2. スケーリングの難しさ
送信要求の受け付け、宛先の取得、第三者サービスの呼び出しを、1台がまとめて担っている。役割が分かれていないため、負荷の高い部分だけを増やすことができない。プッシュ通知の送信量だけが伸びた場合でも、サーバーごと増やすしかない。
3. パフォーマンスのボトルネック
第三者サービスの呼び出しは、ネットワーク越しの応答を待つ時間が長い。しかも、いつ返ってくるかは自分たちで制御できない。この構成では応答を待っている間もサーバーの処理が占有されるため、SMSサービスが遅いだけで、無関係なプッシュ通知まで滞ることになる。
これらの根っこは共通していて、送信要求を受け取ってから第三者サービスに渡し終えるまでを、1つのプロセスで同期的に処理していることにある。
改善した構成
3つの問題を踏まえて組み直すと、次の構成になる。最小構成から変えたのは次の3点である。
- 通知サーバーを複数台にした
- 宛先の取得にキャッシュを挟んだ
- 通知サーバーと第三者サービスの間にメッセージキューとワーカーを挟んだ
それぞれの役割は次の通りである。
| コンポーネント | 役割 |
|---|---|
| 通知サーバー | 発生源からの送信要求を受け付ける。電話番号やメールアドレスの形式を検証し、宛先を引いて、通知の種類に応じたキューへ積む |
| キャッシュ | ユーザー情報と端末情報を保持する。宛先の取得はまずここを見て、なければデータベースから読み込む。宛先の取得は通知1件ごとに発生する一方、その内容は頻繁に変わらないため、キャッシュと相性がよい |
| メッセージキュー | 通知の種類ごとに用意し、送信待ちの通知を保持する |
| ワーカー | 担当するキューから通知を取り出し、第三者のサービスへ渡す。応答待ちはここに閉じるため、通知サーバーが待たされることはない |
なお、プッシュ通知のキューがiOSとAndroidで分かれているのは、渡す先がAPNSとFCMに分かれるためである。どちらのキューへ積むかは、宛先の端末がどちらのプラットフォームかによって決まる。
3つの問題がどう解消されるか
最小構成で挙げた問題と、改善した構成での対応は次の通りである。
| 問題 | 対応 |
|---|---|
| 単一障害点 | 通知サーバーを複数台にした |
| スケーリングの難しさ | キューが受付側と送信側を切り離すため、通知サーバーとワーカーを別々に増やせる |
| パフォーマンスのボトルネック | 通知サーバーはキューへ積んだ時点で応答を返し、第三者サービスの応答待ちはワーカーの中に閉じる |
キューをチャネルごとに分けているのは、1本を共有すると詰まったSMSの後ろにプッシュ通知が並んでしまうためである。分けておけば、ある第三者サービスの不調はそのキューの中に閉じ込められ、APNSに障害が起きている間もSMSとEメールは流れ続ける。
ただし、キューを挟む以上、届くまでに待ち時間は入る。「必ず1秒以内に届ける」という要件なら採れない構成であり、これが成り立つのは要件にソフトリアルタイムを置いたからである。逆にこの前提があるおかげで、送信要求が一時的に集中してもキューがいったん受け止め、ワーカーは処理できる速さで流していける。
詳細設計
構成の骨組みができたので、ここからは実運用に必要な要素を足していく。次の3点を順に見ていく。
信頼性
通知を失わない
ワーカーが第三者サービスへ渡す段階では、応答が返ってこない、エラーが返る、ワーカー自体が落ちるといった失敗が避けられない。そこで、送信する通知を通知ログ(notification log)としてデータベースに記録し、失敗を検知したらその記録をもとに再試行する。何度試しても失敗する通知は、開発者に知らせて調査できるようにする。
重複して届かないようにする
分散システムでは、「ちょうど1回だけ届ける」ことを保証できない。ほとんどの場合は1回で届くが、ネットワークの切断やワーカーの再起動によって、同じ通知が重複して送られることが起こりうる。
そこで重複排除(dedupe)の仕組みを置く。通知イベントを受け取ったときにその識別子を確認し、すでに処理した記録があれば捨てる、という単純な判定である。あわせて、送信が失敗したときの処理を一つひとつ丁寧に扱うことで、重複が生じる余地を減らしていく。
追加コンポーネント
ここまでの構成に、実運用で必要になる要素を足す。
| コンポーネント | 役割 |
|---|---|
| 通知テンプレート | 通知の雛形を用意し、変わる部分だけを差し替える。通知ごとに本文を組み立てる手間と、表記のばらつきを防ぐ |
| 通知設定 | ユーザーがチャネルごとに通知のオン・オフを選べるようにする。送信前にここを参照し、オフなら送らない |
| レート制限 | 同じユーザーに送る通知の回数に上限を設ける。送りすぎると、通知そのものをオフにされてしまう |
| 再試行 | 送信に失敗した通知を専用のキューに入れ、あらためて送り直す。それでも失敗が続く場合は開発者に知らせる |
| セキュリティ | 送信用のAPIを appKey と appSecret で認証し、検証済みのクライアントだけが通知を送れるようにする |
| キューの監視 | キューに溜まっている通知の量を監視する。滞留が増え続けているなら、ワーカーの台数が足りていない兆候になる |
| イベント追跡 | 配信数・開封率・クリック率などを記録し、分析基盤へ渡す |
このうち通知設定が、要件に挙げたオプトアウトに対応する。設定は専用のテーブルで管理する。
ユーザーとチャネルの組み合わせごとに1行を持つため、「プッシュ通知は受け取るがEメールは不要」といったチャネル単位の設定を表せる。
更新した設計
追加した要素を含めると、全体は次のようになる。
改善した構成から変わった点は次の通りである。
- 送信要求は、認証とレート制限を通ってから受け付けられる
- 通知サーバーは、宛先に加えて通知設定とテンプレートも引く。通知がオフなら、ここで送信を打ち切る
- ワーカーは送信のたびに通知ログへ記録する
- 送信に失敗した通知は再試行キューへ回り、あらためてワーカーが送り直す
- キューの滞留量は監視され、増え続けていればワーカーを増やす
イベント追跡の経路
分析サービスに集まる指標は、1か所から得られるわけではない。「送った」で終わらず、「届いたか」「開かれたか」「クリックされたか」まで追うには、それぞれ別の場所からイベントを集める必要がある。
送信したことを知っているのはワーカーだが、実際に端末へ届いたかどうかを知っているのは第三者のサービスであり、ユーザーが通知を開いたりリンクを押したりしたことを検知できるのは端末側だけである。開封率やクリック率を見るには、通知システムの外側からもイベントを集めることになる。
まとめ
通知システムの設計で掲げた「要件」と、それに応えた「設計」を整理する。
| 要件 | 設計上の対応 |
|---|---|
| プッシュ通知・SMS・Eメールで通知 | 通知の種類ごとにキューとワーカーを分け、対応する第三者サービスへ渡す |
| iOS・Android・PCに対応 | プッシュ通知はAPNSとFCM、PCへの通知はEメールが中心となる |
| クライアントとサーバーサイドの両方からトリガー | 発生源を問わず通知サーバーが受け口となり、宛先の解決から先を引き受ける |
| ユーザーが通知をオフにできる | 通知設定テーブルをチャネル単位で持ち、送信前に参照して打ち切る |
| 負荷が高い場合は多少遅れてもよい | メッセージキューを挟んで非同期化し、集中した送信要求をいったん受け止める |
| 1日1,600万件の配信 | 通知サーバーとワーカーを別々に増やせる構成、宛先の取得へのキャッシュ |
そのうえで、設計の中心となる論点は次の2つである。
- 届ける相手は端末ではなく第三者サービス: 自分たちのサーバーが行えるのは、APNS・FCM・SMSサービス・メール配信サービスへ渡すところまでである。実際に届くかどうかが管理の外にある以上、設計の重心は「どう送るか」ではなく「渡した先で失敗したときにどう立て直すか」に置かれる。通知ログへの記録と再試行キューは、そのための仕組みである
- キューを分けることが波及を防ぐ: 応答待ちの長い第三者サービスの呼び出しを、キューを挟んで受付側から切り離す。さらにキューをチャネルごとに分けることで、ある第三者サービスの不調がそのキューの中に閉じ込められる。APNSに障害が起きている間も、SMSとEメールは流れ続ける
参考文献
この記事は以下の情報を参考にして執筆しました。