チャットアプリは、システム設計の問題の中で最も多くの状態を隠し持っています。すべてのユーザーが特定のサーバーに常時接続を持ち、すべてのメッセージが一度だけ、順番どおりに届かなければならず、同じメッセージが 1 人に届くこともあれば 1 万人に届くこともあります。「クライアント、サーバー、データベース」と描いただけの図では、どれにも答えられません。
この記事では、チャットのバックエンドを判断ごとに順に見ていきます。クライアントがどう接続するか、メッセージが会話の中でどう位置を得るか、見るべき全員にどう届くか、どう保存されるか。最後に、出発点として使えるアーキテクチャ図を示します。
要件
- 1 対 1 とグループの会話。テキストとメディアを扱う。
- 会話のすべてのメンバーに、メッセージが同じ順序で届く。
- クライアントが送信を再試行しても重複しない。
- オンラインのユーザーには 1 秒以内に届く。オフラインのユーザーにはプッシュ通知が届き、再接続時に追いつける。
- 配信済み・既読の表示と、オンライン表示。
接続:なぜ WebSocket で、なぜゲートウェイか
新しいメッセージをポーリングすると、リクエストが無駄になり、遅延も増えます。WebSocket の接続は開いたままなので、サーバーはメッセージができた瞬間にプッシュできます。各デバイスは チャットゲートウェイ への接続を 1 本持ちます。
ゲートウェイはシステムの中でステートフルな部分です。ゲートウェイが知っているのは、自分に接続しているユーザーだけです。ゲートウェイは薄く保ちます。接続を一度だけ認証し、メッセージを受け取ってステートレスなサービスに渡し、送信するメッセージを正しいソケットに流すだけです。新しいバージョンのゲートウェイをデプロイすると接続が切れ、クライアントは再接続します。そのため、クライアントはバックオフとジッターを付けて再接続し、最後に見たメッセージから再開できなければなりません。
前段にはレイヤー 4 のロードバランサーか、WebSocket へのアップグレードを理解するロードバランサーを置きます。長時間の接続で負荷が偏ることも想定しておきます。新しい接続は、最も負荷の低いゲートウェイを優先するようにします。
メッセージの送信:冪等性と順序
クライアントは送信前に client_msg_id(UUID)を生成します。確認応答が届く前に接続が切れたら、クライアントは同じ ID で再送し、メッセージサービスは重複を捨てます。これがないと、不安定なモバイル回線で二重のメッセージが生まれます。
順序はグローバルではなく、会話ごとに決めます。メッセージサービスは、各メッセージにその会話の次のシーケンス番号を割り当てて保存します。クライアントは自分の時計ではなく、必ずこの番号で並べ替えます。番号の割り当て方には、実用的な方法が 2 つあります。
- 会話ごとのカウンター。メッセージを保存するのと同じ書き込みでインクリメントする。
- 時刻で並べられる ID。たとえばサーバーが生成する Snowflake 方式の ID。「順序」が「サーバーが受け取った順序」を意味することを受け入れられる場合に使う。
シーケンス番号のおかげで、追いつく処理も安く済みます。再接続したクライアントが「会話 42 は 1871 まで持っている」と伝えれば、それ以降のすべてを受け取れます。
ファンアウト:オンライン、オフライン、大きなグループ
メッセージが保存されると、サービスはイベントを発行します。そこから先は ファンアウトワーカー が引き継ぎます。
- 会話のメンバーを調べる。
- Redis のようなプレゼンスとセッションのレジストリで、どのメンバーがオンラインで、どのゲートウェイが接続を持っているかを調べる。
- それらのゲートウェイにメッセージを送り、ゲートウェイがソケットに流す。
- オフラインのメンバーには、APNs と FCM でプッシュ通知を送る。
メッセージを一度だけ書き込み、読み取りを広げるようにすると、ストレージはメンバー数ではなくメッセージ数に比例するだけで済みます。大きなグループには注意が必要です。10 万人のメンバーがいるチャンネルで、メッセージごとに 10 万件のプッシュ通知を出すべきではありません。通知を希望したメンバーだけに送るか、まとめて要約します。
プレゼンス
プレゼンスは TTL 付きのハートビートです。ゲートウェイは「ユーザー 7 はゲートウェイ 3 に接続中」という情報を 30 秒ほどごとに更新し、キーが期限切れになったらそのユーザーはオフラインです。プレゼンスの変化を、すべての連絡先にリアルタイムで配信してはいけません。ユーザーがその遅れにほとんど気づかないわりに、チャットで最もコストのかかる機能の 1 つです。会話を開いたときにプレゼンスを取得し、定期的に更新します。期限切れになるキーはキャッシュのエントリーと同じように動きます。TTL と、それが一斉に切れたときに何が起きるかは キャッシュ戦略の記事 で扱っています。
既読表示とエンドツーエンド暗号化
配信済みや既読の表示もメッセージです。デバイスがメッセージ 1871 を受け取ると「1871 まで配信済み」という小さなイベントを送り、ユーザーが会話を開くと「1871 まで既読」を送ります。メッセージごとに 1 件ずつ保存するのではなく、メンバーと会話ごとに最大の番号を保存すれば、活発なグループでもコストは一定のままです。
エンドツーエンド暗号化は、サーバーが見られる内容を変えます。Signal のようなプロトコルでは、サーバーは暗号文を保存して転送するだけなので、検索、モデレーション、リンクのプレビューはクライアント側に移ります。ストレージとファンアウトの両方が変わるので、早い段階で決めておきます。
保存
メッセージは一度書き込まれ、主に最近のものが読まれ、ほぼ常に会話単位で読まれます。そこから、会話でパーティションを分け、シーケンス番号で並べたストアが導かれます。たとえば Discord は、活発なチャンネルが 1 つの巨大なパーティションにならないよう、チャンネルと固定の時間バケットでメッセージを分割しています。何兆ものメッセージをどう保存しているか を説明した記事では、このモデルを Cassandra から ScyllaDB に移した経緯が書かれています。
メディアはメッセージストアに入れません。クライアントは画像や動画を署名付き URL でオブジェクトストレージに直接アップロードし、メッセージは参照だけを持ちます。メッセージサービスがバイト列を扱うことはありません。
アーキテクチャ図
ダイアグラムの内容
- Users: mobile and web
- Load balancer
- Chat gateway
- Message service
- Message store
- Message events
- Fan-out workers
- Presence and sessions
- APNs and FCM
- Media bucket
- wss
- send
- append, next seq
- event
- who is online, which gateway
- deliver
- offline
- heartbeat
- pre-signed upload
図の読み方は次のとおりです。
- 左: ユーザーはロードバランサーを経由し、WebSocket(wss)でチャットゲートウェイに接続します。メディアのアップロードはバケットに直接行きます。
- 中央: ゲートウェイは各送信をメッセージサービスに渡します。サービスは
client_msg_idで重複を捨て、次のシーケンス番号を割り当て、メッセージをストアに追記します。 - 右: サービスはイベントを発行します。ファンアウトワーカーはプレゼンスとセッションを確認し、正しいゲートウェイ経由で配信し、オフラインのメンバーにはプッシュ通知を送ります。
- ゲートウェイからプレゼンスへのハートビート は、メッセージごとではなくタイマーで動くので点線です。
- Markdown ブロック はメッセージのエンベロープを示し、全員がフィールドについて合意できるようにしています。
ラベルに書くこと
- どのコンポーネントが状態を持つか。 ゲートウェイは接続を持ち、メッセージサービスは何も持ちません。それによって、それぞれのデプロイとスケールの仕方が決まります。
- 順序のルール。 「会話ごとのシーケンス、サーバーが割り当てる」は図に書きます。
- 重複排除のキー。 送信経路に
client_msg_idの名前を書きます。 - オンラインとオフラインの経路。 両方を描きます。バグの多くは後者に潜んでいます。
イベントとコンシューマー側については イベント駆動アーキテクチャの記事 で詳しく扱っています。送信と確認応答のやり取りを一歩ずつ記録するには、シーケンス図の例 がよい手本になります。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- ユーザー用に C4 モデルパックから 人物 を、クラウドパックから クラウドロードバランサー を追加します。
- C4 モデルパックから コンテナ ノードを 3 つ追加します。チャットゲートウェイ、メッセージサービス、ファンアウトワーカーです。それぞれにステートフルかどうかを書いておきます。
- メッセージストア用に C4 モデルパックの データベース を、プレゼンスとイベント用にデータパックの Redis と Kafka を、メディア用にクラウドパックの オブジェクトストレージ ノードを、APNs と FCM 用に 外部システム を追加します。
- それらをつなぎ、ハートビートとメディアのアップロードは破線か点線のエッジにして、メッセージのエンベロープを書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 11 個で、Free プランの範囲に収まります。
よくある質問
チャットアプリに WebSocket は必要ですか? リアルタイムに配信するなら必要です。あるいは、送信には HTTP を使い、受信には server-sent events のような似たプッシュの仕組みを使います。小さなアプリならロングポーリングでも動きますが、リクエストが増え、遅延も大きくなります。
メッセージの順序を保つには? メッセージを保存するときに、サーバーが会話ごとのシーケンス番号を割り当て、クライアントはその番号で並べ替えます。クライアントの時計はずれるので、順序付けには使えません。
メッセージの重複を防ぐには? クライアントが送信前にメッセージ ID を生成し、再試行でも同じ ID を使います。サーバーはその ID を保存し、同じ ID の 2 通目は無視します。
チャットのメッセージはどのデータベースに保存すべきですか? 会話単位で速く読めて、シーケンスで並べられるものです。たとえば、会話と時間バケットでパーティションを分けたワイドカラムストアです。小さめのアプリなら、会話とシーケンスにインデックスを張った Postgres で十分に動きます。
オフラインのユーザーにはどうやってメッセージが届きますか? ほかのメッセージと同じように保存されます。ファンアウトの段階でプッシュ通知が送られ、アプリが再接続すると、持っている最後のシーケンス番号より後のものをすべて要求します。