チャットアプリのアーキテクチャ図:WebSocket、ファンアウト、メッセージ保存

チャットアプリのアーキテクチャ図を解説。WebSocket ゲートウェイ、メッセージの順序、冪等な送信、ファンアウト、プレゼンス、保存。

· 創業者・開発者

公開日:

チャットアプリのアーキテクチャ図:WebSocket、ファンアウト、メッセージ保存

チャットアプリは、システム設計の問題の中で最も多くの状態を隠し持っています。すべてのユーザーが特定のサーバーに常時接続を持ち、すべてのメッセージが一度だけ、順番どおりに届かなければならず、同じメッセージが 1 人に届くこともあれば 1 万人に届くこともあります。「クライアント、サーバー、データベース」と描いただけの図では、どれにも答えられません。

この記事では、チャットのバックエンドを判断ごとに順に見ていきます。クライアントがどう接続するか、メッセージが会話の中でどう位置を得るか、見るべき全員にどう届くか、どう保存されるか。最後に、出発点として使えるアーキテクチャ図を示します。

要件

  • 1 対 1 とグループの会話。テキストとメディアを扱う。
  • 会話のすべてのメンバーに、メッセージが同じ順序で届く。
  • クライアントが送信を再試行しても重複しない。
  • オンラインのユーザーには 1 秒以内に届く。オフラインのユーザーにはプッシュ通知が届き、再接続時に追いつける。
  • 配信済み・既読の表示と、オンライン表示。

接続:なぜ WebSocket で、なぜゲートウェイか

新しいメッセージをポーリングすると、リクエストが無駄になり、遅延も増えます。WebSocket の接続は開いたままなので、サーバーはメッセージができた瞬間にプッシュできます。各デバイスは チャットゲートウェイ への接続を 1 本持ちます。

ゲートウェイはシステムの中でステートフルな部分です。ゲートウェイが知っているのは、自分に接続しているユーザーだけです。ゲートウェイは薄く保ちます。接続を一度だけ認証し、メッセージを受け取ってステートレスなサービスに渡し、送信するメッセージを正しいソケットに流すだけです。新しいバージョンのゲートウェイをデプロイすると接続が切れ、クライアントは再接続します。そのため、クライアントはバックオフとジッターを付けて再接続し、最後に見たメッセージから再開できなければなりません。

前段にはレイヤー 4 のロードバランサーか、WebSocket へのアップグレードを理解するロードバランサーを置きます。長時間の接続で負荷が偏ることも想定しておきます。新しい接続は、最も負荷の低いゲートウェイを優先するようにします。

メッセージの送信:冪等性と順序

クライアントは送信前に client_msg_id(UUID)を生成します。確認応答が届く前に接続が切れたら、クライアントは同じ ID で再送し、メッセージサービスは重複を捨てます。これがないと、不安定なモバイル回線で二重のメッセージが生まれます。

順序はグローバルではなく、会話ごとに決めます。メッセージサービスは、各メッセージにその会話の次のシーケンス番号を割り当てて保存します。クライアントは自分の時計ではなく、必ずこの番号で並べ替えます。番号の割り当て方には、実用的な方法が 2 つあります。

  • 会話ごとのカウンター。メッセージを保存するのと同じ書き込みでインクリメントする。
  • 時刻で並べられる ID。たとえばサーバーが生成する Snowflake 方式の ID。「順序」が「サーバーが受け取った順序」を意味することを受け入れられる場合に使う。

シーケンス番号のおかげで、追いつく処理も安く済みます。再接続したクライアントが「会話 42 は 1871 まで持っている」と伝えれば、それ以降のすべてを受け取れます。

ファンアウト:オンライン、オフライン、大きなグループ

メッセージが保存されると、サービスはイベントを発行します。そこから先は ファンアウトワーカー が引き継ぎます。

  1. 会話のメンバーを調べる。
  2. Redis のようなプレゼンスとセッションのレジストリで、どのメンバーがオンラインで、どのゲートウェイが接続を持っているかを調べる。
  3. それらのゲートウェイにメッセージを送り、ゲートウェイがソケットに流す。
  4. オフラインのメンバーには、APNs と FCM でプッシュ通知を送る。

メッセージを一度だけ書き込み、読み取りを広げるようにすると、ストレージはメンバー数ではなくメッセージ数に比例するだけで済みます。大きなグループには注意が必要です。10 万人のメンバーがいるチャンネルで、メッセージごとに 10 万件のプッシュ通知を出すべきではありません。通知を希望したメンバーだけに送るか、まとめて要約します。

プレゼンス

プレゼンスは TTL 付きのハートビートです。ゲートウェイは「ユーザー 7 はゲートウェイ 3 に接続中」という情報を 30 秒ほどごとに更新し、キーが期限切れになったらそのユーザーはオフラインです。プレゼンスの変化を、すべての連絡先にリアルタイムで配信してはいけません。ユーザーがその遅れにほとんど気づかないわりに、チャットで最もコストのかかる機能の 1 つです。会話を開いたときにプレゼンスを取得し、定期的に更新します。期限切れになるキーはキャッシュのエントリーと同じように動きます。TTL と、それが一斉に切れたときに何が起きるかは キャッシュ戦略の記事 で扱っています。

既読表示とエンドツーエンド暗号化

配信済みや既読の表示もメッセージです。デバイスがメッセージ 1871 を受け取ると「1871 まで配信済み」という小さなイベントを送り、ユーザーが会話を開くと「1871 まで既読」を送ります。メッセージごとに 1 件ずつ保存するのではなく、メンバーと会話ごとに最大の番号を保存すれば、活発なグループでもコストは一定のままです。

エンドツーエンド暗号化は、サーバーが見られる内容を変えます。Signal のようなプロトコルでは、サーバーは暗号文を保存して転送するだけなので、検索、モデレーション、リンクのプレビューはクライアント側に移ります。ストレージとファンアウトの両方が変わるので、早い段階で決めておきます。

保存

メッセージは一度書き込まれ、主に最近のものが読まれ、ほぼ常に会話単位で読まれます。そこから、会話でパーティションを分け、シーケンス番号で並べたストアが導かれます。たとえば Discord は、活発なチャンネルが 1 つの巨大なパーティションにならないよう、チャンネルと固定の時間バケットでメッセージを分割しています。何兆ものメッセージをどう保存しているか を説明した記事では、このモデルを Cassandra から ScyllaDB に移した経緯が書かれています。

メディアはメッセージストアに入れません。クライアントは画像や動画を署名付き URL でオブジェクトストレージに直接アップロードし、メッセージは参照だけを持ちます。メッセージサービスがバイト列を扱うことはありません。

アーキテクチャ図

ユーザーはロードバランサーを経由し、WebSocket でチャットゲートウェイに接続します。ゲートウェイはメッセージをメッセージサービスに渡し、サービスはそれを保存してイベントを発行します。ファンアウトワーカーは Redis でプレゼンスを確認し、ゲートウェイ経由で配信し、オフラインのメンバーにはプッシュを送ります。メディアはバケットへ。

11 個のコンポーネント · 11 個の接続

Archboard で開く
ダイアグラムの内容
  • 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 ゲートウェイとステートレスなメッセージサービスで構成したチャットのバックエンド。メッセージには会話ごとのシーケンス番号が付き、一度だけ保存され、オンラインのメンバーにはゲートウェイ経由で、オフラインのメンバーにはプッシュで届きます。

図の読み方は次のとおりです。

  • 左: ユーザーはロードバランサーを経由し、WebSocket(wss)でチャットゲートウェイに接続します。メディアのアップロードはバケットに直接行きます。
  • 中央: ゲートウェイは各送信をメッセージサービスに渡します。サービスは client_msg_id で重複を捨て、次のシーケンス番号を割り当て、メッセージをストアに追記します。
  • 右: サービスはイベントを発行します。ファンアウトワーカーはプレゼンスとセッションを確認し、正しいゲートウェイ経由で配信し、オフラインのメンバーにはプッシュ通知を送ります。
  • ゲートウェイからプレゼンスへのハートビート は、メッセージごとではなくタイマーで動くので点線です。
  • Markdown ブロック はメッセージのエンベロープを示し、全員がフィールドについて合意できるようにしています。

ラベルに書くこと

  • どのコンポーネントが状態を持つか。 ゲートウェイは接続を持ち、メッセージサービスは何も持ちません。それによって、それぞれのデプロイとスケールの仕方が決まります。
  • 順序のルール。 「会話ごとのシーケンス、サーバーが割り当てる」は図に書きます。
  • 重複排除のキー。 送信経路に client_msg_id の名前を書きます。
  • オンラインとオフラインの経路。 両方を描きます。バグの多くは後者に潜んでいます。

イベントとコンシューマー側については イベント駆動アーキテクチャの記事 で詳しく扱っています。送信と確認応答のやり取りを一歩ずつ記録するには、シーケンス図の例 がよい手本になります。

Archboard で作る

  1. Google または GitHub でサインインし、図を作成します。
  2. ユーザー用に C4 モデルパックから 人物 を、クラウドパックから クラウドロードバランサー を追加します。
  3. C4 モデルパックから コンテナ ノードを 3 つ追加します。チャットゲートウェイ、メッセージサービス、ファンアウトワーカーです。それぞれにステートフルかどうかを書いておきます。
  4. メッセージストア用に C4 モデルパックの データベース を、プレゼンスとイベント用にデータパックの Redis と Kafka を、メディア用にクラウドパックの オブジェクトストレージ ノードを、APNs と FCM 用に 外部システム を追加します。
  5. それらをつなぎ、ハートビートとメディアのアップロードは破線か点線のエッジにして、メッセージのエンベロープを書いた基本パックの Markdown ブロックを追加します。

この例で使うダイアグラムノードは 11 個で、Free プランの範囲に収まります。

よくある質問

チャットアプリに WebSocket は必要ですか? リアルタイムに配信するなら必要です。あるいは、送信には HTTP を使い、受信には server-sent events のような似たプッシュの仕組みを使います。小さなアプリならロングポーリングでも動きますが、リクエストが増え、遅延も大きくなります。

メッセージの順序を保つには? メッセージを保存するときに、サーバーが会話ごとのシーケンス番号を割り当て、クライアントはその番号で並べ替えます。クライアントの時計はずれるので、順序付けには使えません。

メッセージの重複を防ぐには? クライアントが送信前にメッセージ ID を生成し、再試行でも同じ ID を使います。サーバーはその ID を保存し、同じ ID の 2 通目は無視します。

チャットのメッセージはどのデータベースに保存すべきですか? 会話単位で速く読めて、シーケンスで並べられるものです。たとえば、会話と時間バケットでパーティションを分けたワイドカラムストアです。小さめのアプリなら、会話とシーケンスにインデックスを張った Postgres で十分に動きます。

オフラインのユーザーにはどうやってメッセージが届きますか? ほかのメッセージと同じように保存されます。ファンアウトの段階でプッシュ通知が送られ、アプリが再接続すると、持っている最後のシーケンス番号より後のものをすべて要求します。

無料で始める — 1 シート、クレジットカード不要。

関連記事

← ブログに戻る

1分でキャンバスを開く

Google または GitHub でサインイン。1シートは無料で、チームが加わったらアップグレード。