Messenger 即時通訊 (Design Messenger)

一句話定位 + TL;DR(含 35 分鐘心智時鐘)

設計一個 1 億使用者的 即時通訊(chat)系統:支援多人群聊(≤100 人)、收發訊息、離線時也能補收期間訊息。在線延遲要 < 500ms、訊息 不可遺失(durability)、同一 chat 內 所有人看到一致的訊息順序(consistency)
招牌取捨:分片單位選 chat_id(pub/sub 模型)而非 user_id——放棄「以使用者為中心」的直覺,換取兩個決定性好處:(1) 同一 chat 永遠路由到 單一 pub/sub owner → 它成為訊息順序的 唯一真實來源(source of truth),順序一致才可行;(2) fan-out 從「對 N 個接收者各一次 RPC」降為「對 M 個 subscribing gateway 各一次」。其餘決勝點:WebSocket 雙向長連線(取代 polling)、monotonic increasing message_id(伺服器端產生,解決亂序)、stateless gateway + consistent hashing離線 store-and-forward + ack/last_read_message_id
配速(35 min 預算):~5 min 釐清需求 → ~4 min 容量估算 → ~3 min 協定選型(polling vs WebSocket)→ ~3 min API + ~4 min 資料模型 → ~6 min 高階架構(在線/離線兩條 flow)→ ~8 min deep dive(億級在線:user_id vs chat_id 分片 / 訊息亂序)→ ~2 min 收尾。開場先一句話複述題目、宣告配速,並把「億級擴展」「亂序」明確標記為待 deep dive——宣告配速本身就是高分動作。

1. 釐清需求與範圍 (Clarify & Scope ~5 min)

開場後先框邊界,別急著畫 WebSocket 連線圖。即時通訊題最容易在「協定」「分片」「順序」三點翻車,先問清楚這三點要不要做:

釐清問題 為何要問 / 影響到哪個決策 本題假設(依來源)
是 1-1 還是群聊?群聊人數上限? 決定 fan-out 規模與分片策略 群聊,上限 100 人
離線使用者要補收期間訊息嗎? 決定要不要 store-and-forward + 已讀指標 (離線期間訊息不可漏)
延遲要求?哪條路徑最敏感? 決定協定選型(polling 不夠) 在線傳遞 < 500ms
訊息可以遺失嗎? 決定是否先持久化再回 ack 不可遺失(durability)
同一 chat 要保證訊息順序一致嗎? 直接決定分片單位(chat vs user) 要,所有人看到一致順序
規模多大? 驅動「億級在線」deep dive 1 億使用者、高吞吐
附件 / 視訊通話 / 線上狀態要做嗎? 框掉 scope,避免發散 不在範圍(attachments / video / presence 排除)
defer 話術

億級使用者同時在線怎麼定位接收者所在的 server 我先標記,等 deep dive 展開——現在先假設所有人連到同一台 chat server、用一個 local hash map 維護 user_id → WebSocket connection,這樣能先把端到端訊息流程串起來。」這招把最難的分片問題推遲,先保住敘事連貫。

2. 功能性與非功能性需求 (FR / NFR 與設計北極星)

設計北極星(NFR 直接導出後續所有選型的判準):

NFR 導出的設計北極星 後續對映選型
低延遲 + server 主動推送 不能用 polling,必須長連線雙向通道 WebSocket
訊息順序一致 同一 chat 需要單一順序權威 chat_id 分片 + 伺服器端 monotonic message_id
Durability 先持久化再回 ack;ack 失敗能重送 DB 寫入後才算成功、離線 store-and-forward
1 億在線 / 高吞吐 連線與資料都要能水平分散;gateway 要 stateless consistent hashing、pub/sub 解耦、NoSQL 高寫吞吐
一句話北極星

「這是一個 長連線、順序一致、不可丟訊息 的系統」——記住這三個詞,協定選 WebSocket、分片選 chat、ID 選 monotonic,全都從它推回去。

3. 容量估算 (Back-of-Envelope ~4 min)

估算的目的是 驅動連線分片與寫入選型(估算 → 選型的 hand-off)。回連 02-Distributed-Systems/05-Numbers-to-Know

並發連線(最關鍵的維度——連線即狀態)

1 億使用者,假設高峰 ~10% 同時在線 → ~1,000 萬並發 WebSocket 連線
WhatsApp 經驗:單一 host 可承載 ~100–200 萬連線(來源給出此基準)
→ 連線數 ÷ 每台容量 = 10,000,000 ÷ 1,500,000 ≈ 7 台(僅承載連線)
→ 實務含備援 / 不均,需「數百台」量級(來源用語:可能數百台)

寫流量(訊息吞吐)

假設每位在線使用者每天送 ~40 則訊息(推導值,來源未給)
1 億 × 40 = 40 億則/day ÷ 86,400 s ≈ 46,000 訊息/sec(寫)
群聊放大:每則訊息要 fan-out 給聊天內成員 → 推送量 = 寫 × 平均群人數

儲存

40 億則/day × ~300 bytes/則 ≈ 1.2 TB/day(推導值)
→ 訊息是高寫吞吐 + 持續增長 → NoSQL(DynamoDB)+ 分區,而非單機關聯式

下表並發連線基準(100–200 萬/台)來自來源;訊息量、儲存量為 推導值(由「使用者數 × 每人次數」「筆數 × 每筆大小」算出)。

維度 數字 對後續決策的意義
並發連線 ~1,000 萬(推導值,假設 10% 在線) 單台扛不下 → 多台 + consistent hashing 分散連線
單台連線容量 100–200 萬/台(來源基準) 算出需 數百台 chat/pub-sub server
寫 QPS ~46,000 訊息/sec(推導值) 高寫吞吐 → NoSQL(DynamoDB),非單機 RDB
群聊 fan-out 每則 × 群人數(≤100) fan-out 放大是真正壓力 → pub/sub 把 N 降為 M(見 §8)
儲存增長 ~1.2 TB/day(推導值) 訊息表持續增長 → 分區 + 以 chat_id 聚合查詢
估算的最大收穫

連線本身就是狀態,且單台只能扛 100–200 萬 → 這直接逼出「多台 + 如何定位接收者所在 host」這個全題最難的問題,也是 deep dive §8.1 的起點。寫吞吐 ~46k/sec 則逼出 NoSQL 高寫吞吐 的選型。

4. 用到的積木 / Building Blocks Used

架構即目錄:先給導覽圖,再展開。下表每個決策都對映一個既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源(single source of truth)。

設計決策 積木 在本題的作用(≤1 行)
容量估算驅動選型 02-Distributed-Systems/05-Numbers-to-Know 算出 ~1,000 萬連線 / 數百台 host,逼出分片與 NoSQL 選型
polling vs 長連線的協定取捨 01-Networking/05-Realtime-Protocols 比較 simple/long polling 與 WebSocket,選雙向長連線
WebSocket 升級走 TCP / L7 01-Networking/02-TCP-vs-UDP WebSocket 在既有 TCP 上做 L7 protocol upgrade
handshake 用 HTTP Upgrade 01-Networking/03-HTTP-and-HTTPS client 先用 HTTP 發 handshake,再升級為 WebSocket
server 主動推送的即時模式 10-Design-Patterns/05-Real-time-Updates 訊息到達即 push 給在線接收者,非 client 輪詢
連線 / chat 分片到多節點 02-Distributed-Systems/04-Consistent-Hashing 用一致性雜湊把連線(或 chat_id)平均分到數百台、增刪節點影響最小
chat 為核心的 pub/sub fan-out 09-Messaging-Coord/02-Queue pub/sub owner 對 subscribing gateways 做 fan-out(M 而非 N)
定位接收者所在 host 09-Messaging-Coord/05-Zookeeper 集中式強一致 service discovery 維護 shard→server 對應
不可遺失(持久化後再 ack) 12-Ops-and-Reliability/03-Reliable-Delivery 先寫 Message table 才算成功,client ack 更新 last_read
高寫吞吐訊息儲存 06-Database-Tech/03-DynamoDB NoSQL 扛 ~46k 寫/sec,chat_id 當 partition key
建群多筆寫入原子化 05-Database-Advanced/01-Transactions chat + 每位 member 的 membership 包在同一 transaction
gateway stateless 水平擴展 02-Distributed-Systems/03-Scalability socket 與 chat ownership 解耦 → gateway 無狀態、任意加機器
訊息順序唯一真實來源 05-Database-Advanced/02-Sharding 以 chat_id 分片,使單一 owner 能強制 monotonic 順序

5. API 設計 (API Design ~3 min)

因為用 WebSocket(雙向),API 不必遵循 REST 慣例——訊息是雙向的「message」而非 request/response 資源(理據見 01-Networking/05-Realtime-Protocols)。為簡化用 JSON 表示。

訊息(method) 請求 payload 回應 用途
createChat { participants: [userId...], name } { chatId } 建立群聊,回唯一 chatId
sendMessage { chatId, message } "SUCCESS" | "FAILURE" 送訊息到某 chat
modifyChatParticipants { chatId, userId, operation: "ADD"|"REMOVE" } "SUCCESS" | "FAILURE" 加 / 移除群成員
newMessage(server→client) { chatId, messageId, senderId, message } (client 回 ack server 主動推送新訊息給在線接收者
ack(client→server) { chatId, messageId } 確認已收,server 更新 last_read
細節話術

sendMessageSUCCESS 的語意是 訊息已持久化到 Message table,不是『所有人都已收到』。持久化 = durability 的保證點;後續推送靠 newMessage,收到靠 client 回 ack。把這三段切清楚,durability 與 delivery 才不會混為一談。」

6. 資料模型與儲存選型 (Data Model & Storage Choice ~4 min)

三張核心表,全部圍繞「以 chat 為單位高效查詢」設計(故獨立成段)。

Chat
--------------------------------------------------
chat_id   STRING  PK              ← 唯一聊天識別碼
name      STRING  群聊顯示名稱
created_at TIMESTAMP
--------------------------------------------------

Membership                         ← 查「某 chat 有哪些人」的核心表
--------------------------------------------------
chat_id   STRING  Partition Key    ← 同一 chat 的成員聚在同一分區
user_id   STRING  Sort Key         ← (chat_id, user_id) 唯一
last_read_message_id  BIGINT       ← 已讀指標,離線補收 / 已讀回執靠它
--------------------------------------------------
   → 以 chat_id 為 partition key 可一次撈出整個 chat 的全部參與者

Message
--------------------------------------------------
chat_id    STRING  Partition Key   ← 同一 chat 訊息聚在一起、依序查詢
message_id BIGINT  Sort Key        ← monotonic increasing(時間序 + 唯一)
sender_id  STRING
body       STRING
created_at TIMESTAMP
--------------------------------------------------
   → 離線補收 = 查 message_id > last_read_message_id 的所有訊息

儲存選型:

選項 適配本題嗎 理由
NoSQL(DynamoDB) ✅ 主選 高寫吞吐(~46k 訊息/sec);chat_id 當 partition key、message_id/user_id 當 sort key,天生支援「撈整個 chat」的查詢
關聯式 DB △ 也行 建群的多筆寫需要 transaction(RDB 直覺),但訊息表的寫吞吐與水平擴展 NoSQL 更直接
訊息佇列當「儲存」 ❌ 不當儲存 Queue 用於 fan-out 投遞(見 §8),不是訊息持久化的真實來源,補收靠 Message table

→ 積木:NoSQL 高寫吞吐見 06-Database-Tech/03-DynamoDB;建群多筆原子寫見 05-Database-Advanced/01-Transactions;以 chat_id 分片見 05-Database-Advanced/02-Sharding

邊界

last_read_message_id 同時服務 離線補收已讀回執 兩個用途。建群時 Chat + 每位參與者的 Membership 必須包在同一 transaction——否則可能建了 chat 卻漏掉成員,導致訊息查不到收件人。

7. 高階架構 (High-Level Design ~6 min)

把積木串成端到端。先看「建群」與「在線/離線兩條傳遞 flow」(簡化假設:所有人連同一台 server、用 local hash map user_id → WebSocket connection;億級分片留 §8)。

(A) 建群 flow(createChat)

Client ──WebSocket── Chat Server
   │  createChat { participants, name }
   ▼
Chat Server (同一 DB transaction 內)
   │ 1. INSERT Chat(chat_id, name)
   │ 2. 為每位 participant INSERT Membership(chat_id, user_id, last_read=0)
   ▼
回傳 { chatId }

(B) 在線傳遞 flow(recipient online)

Sender ──sendMessage{chatId, message}──► Chat Server
                                            │ 1. 寫入 Message table
                                            │    (message_id = monotonic increasing)
                                            │ 2. 查 Membership(chat_id) 取得所有參與者
                                            │ 3. 回 SUCCESS/FAILURE 給 sender
                                            │ 4. 逐一查每位參與者的 WebSocket connection
                                            ▼  並 push newMessage
                              在線接收者 ◄── newMessage{messageId, ...}
                                            │ 5. Client 回 ack{messageId}
                                            ▼
                              Chat Server 更新 Membership.last_read_message_id

(C) 離線傳遞 flow(recipient offline — store & forward)

接收者離線時:訊息已落在 Message table(durability 已達成)
        │
   接收者上線、重連 WebSocket
        ▼
   1. 查 Membership 取該 user 的 last_read_message_id
   2. 查 Message: message_id > last_read_message_id 的所有訊息
   3. push 給接收者
   4. Client 回 ack → 更新 last_read_message_id

(D) Scaled 架構(端到端 — 億級的 pub/sub by chat,詳見 §8)

   Sender Client ─┐
                  │ WebSocket
                  ▼
            ┌──────────────┐    Subscribe(chat_id)   ┌────────────────────────┐
            │  Gateway (stateless) ──────────────────►│  Pub/Sub Server (owner) │
            │  維護本地 WS 連線    │                   │  by shard = hash(chat_id)%S │
            └───────┬──────┘                          │  subscribers[chat_id] =  │
                    │ sender→gateway→pub/sub          │     [subscribing_gateways]│
                    ▼                                  │  指派 monotonic message_id│
        ┌────────────────────────┐                    └───────────┬────────────┘
        │ Service Discovery       │◄── heartbeat /                 │ fan-out 給 M 個
        │ (Zookeeper/Consul, Raft)│    shard ownership             ▼  subscribing gateways
        │ shard_id → pub/sub host │                    ┌────────────────────────┐
        └────────────────────────┘                    │ 其他 Gateways → Receiver │
                                                       └────────────────────────┘
        ┌─────────────────────────────────────────────────────────────────────┐
        │  DynamoDB:  Chat / Membership(chat_id PK) / Message(chat_id PK, msg_id SK)│
        └─────────────────────────────────────────────────────────────────────┘

關鍵 hand-off:gateway(stateless,持 WS 連線)→ pub/sub owner(by chat_id,順序權威 + fan-out)→ 其他 subscribing gateways → 接收者;service discovery 維護 shard_id → pub/sub host,gateway 本地快取。

8. 深入探討 (Deep Dives ~8 min)

不重複原則(link-don't-restate)

通用機制(consistent hashing 原理、WebSocket 機制、transaction、replication)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。任一機制超過 ~3 行就砍掉改連結。本題無 home note 的新材料(WebSocket handshake、monotonic ID 三種做法、gateway/pub-sub 解耦、client 端重排序)才 inline 教學並登記到 new_concepts_introduced

8.1 協定選型:為何不能 polling,要 WebSocket

問題:即時通訊要 server 主動推送,但標準 HTTP 是 request-response、回完即關閉,不適合 server push。
為何是瓶頸:讓每個 client 每幾毫秒 poll 一次,基礎設施會被海量空回應壓垮。

協定 機制 缺點 適用
Simple Polling 固定間隔送 request,無資料回空 大量空回應、高延遲、資源浪費 低頻檢查通知
Long Polling server 保持請求直到有新資料才回,回後 client 再發 高頻時收完還要重建連線、延遲仍在 中低頻即時(簡單通知)
WebSocket HTTP Upgrade → 持久 TCP 雙向通道 連線管理 / 錯誤恢復複雜 高頻即時通訊(本題)
新材料:WebSocket handshake(本案例首次 introduce → 已登記 new_concepts_introduced)

WebSocket 是 基於 HTTP 的 upgrade protocol,讓既有 TCP 連線在 L7 切換協定。流程:(1) client 用 HTTP 發 handshake → (2) 連線升級為 WebSocket → (3) 雙方皆可隨時送「訊息」(opaque binary blobs,可載 JSON/Protobuf)→ (4) 連線持續到一方明確關閉。

選擇與理由:選 WebSocket。高頻雙向、低延遲、傳輸效率高(< 500ms 需求 polling 達不到)。代價是 連線即狀態 + 連線管理複雜——這正是 §8.2 的起點。
→ 積木:01-Networking/05-Realtime-Protocols10-Design-Patterns/05-Real-time-Updates01-Networking/03-HTTP-and-HTTPS

8.2 億級在線:user_id 分片 vs chat_id 分片(招牌取捨)

問題:需數百台 server(單台僅 100–200 萬連線)。發送者與接收者連到不同 host 時,如何定位接收者所在 host 以即時推送?

方案 A:以 user_id 為核心的 consistent hashing
每台 server 負責一段 user_id key space,連線平均分散;用 集中式強一致 service discovery(Zookeeper/Consul,Raft/Zab 共識) 維護 user_id → node,各節點本地快取。1-1 聊天理論可行,但規模一大就難維護:

  1. 每則訊息要對 N 個接收者各發一次 RPC;大群聊每則可能數百~上千 RPC。
  2. scale up/down 時除了 socket,還要重新分配 user_id 的 ownership,極難。
  3. 最根本:以 user 分片,同一 chat 散在多個 owner(A 的訊息在 server 1、B 的在 server 2),幾乎無法強制單一訊息順序

方案 B:以 chat_id 為核心的 pub/sub(解耦 socket 與 chat ownership)

shard_id = hash(chat_id) % S          (S = shard 數)
service registry:  shard_id → pub/sub server
pub/sub server 記憶體:  chat_id → [subscribing_gateways]
流程:  sender → gateway → pub/sub → subscribed gateways → receiver
維度 A. user_id 分片 B. chat_id pub/sub
fan-out 規模 N 個接收者 各一次 RPC M 個 subscribing gateway 各一次(M≤N)
scale up/down 要重分配 user_id ownership(難) gateway stateless,任意連任一台,易水平擴展
訊息順序 ❌ 同 chat 散多 owner,無法強制單一順序 每個 chat 唯一 owner → 順序權威可行

選擇與理由:選 B(chat_id pub/sub)。它一次解決 fan-out 放大、gateway 無狀態擴展、以及最關鍵的訊息順序權威——這就是本題 core_tradeoff
→ 積木:02-Distributed-Systems/04-Consistent-Hashing09-Messaging-Coord/05-Zookeeper09-Messaging-Coord/02-Queue02-Distributed-Systems/03-Scalability

8.3 訊息亂序:為何用伺服器端 monotonic message_id

問題:同一 chat 兩人同時送訊息,如何確保所有人看到 一致順序
為何是瓶頸:client 端時鐘有 clock skew,且 timestamp 不保證唯一(同毫秒多人送)——靠 client 時間排序必然亂。

解法:讓「接收並持久化訊息的 server」成為順序的 唯一真實來源。§8.2 的 pub/sub 正好提供此特性——同一 chat 全部路由到 同一 pub/sub owner,由它在處理時 指派 monotonic increasing 的 message_id 並持久化到 DB。

新材料:monotonic ID 的三種產生法(已登記 new_concepts_introduced)

同時要「時間順序性 + 唯一性」,常見三種:(1) 每個 partition 用 auto-increment counter;(2) Timestamp-based UUID(UUID v1);(3) Twitter Snowflake ID。三者皆 monotonic increasing,兼具時序與唯一。不能用 client 端 timestamp(clock skew + 不唯一)。

client 端收尾:若收到 late-arriving message(message_id 較小),client 在本地 重新排序 對話,確保呈現順序正確。
→ 積木:05-Database-Advanced/02-Sharding(chat 分片使單一 owner 可行)、06-Database-Tech/03-DynamoDB(message_id 當 sort key 天然有序)

8.4 不可遺失:持久化點與 ack 閉環

問題:訊息 durability——送出後不可丟。
做法sendMessageSUCCESS 語意是 已寫入 Message table(持久化點),而非「已送達」。送達靠 push newMessage;接收者收到回 ack → server 更新 last_read_message_id離線者 的訊息早已在 Message table,上線後查 message_id > last_read_message_id 補收即可——store-and-forward 的真實來源是 DB,不是記憶體佇列
→ 積木:12-Ops-and-Reliability/03-Reliable-Delivery

9. 擴展與瓶頸 (Scaling & Bottlenecks ~5 min)

主動找出系統會先在哪裂開並補強。

瓶頸 / 風險 補強手段 積木
單台 server 連線數上限(100–200 萬) 多台 + consistent hashing 分散連線;gateway stateless 加機器 02-Distributed-Systems/04-Consistent-Hashing02-Distributed-Systems/03-Scalability
大群聊 fan-out(每則對 N 接收者) chat_id pub/sub:fan-out 對象從 N 接收者降為 M subscribing gateway 09-Messaging-Coord/02-Queue
訊息順序在多 owner 下崩壞 chat_id 分片 保證單一 owner + monotonic message_id 05-Database-Advanced/02-Sharding
寫吞吐壓垮單機 RDB NoSQL(DynamoDB) 高寫吞吐 + chat_id 分區 06-Database-Tech/03-DynamoDB
pub/sub owner 節點掛掉 heartbeat + service discovery 重分配 shard ownership,gateway 重訂閱新 owner 09-Messaging-Coord/05-Zookeeper
訊息遺失 / ack 漏掉 持久化後才回 SUCCESS;離線靠 last_read_message_id 補收(store-and-forward) 12-Ops-and-Reliability/03-Reliable-Delivery
drive 的關鍵句

「系統會 先在連線層裂開(單台 100–200 萬連線、1,000 萬並發 → 必須數百台)→ 接著是 fan-out 與順序(user 分片時兩者都崩)→ 所以核心一招是 改以 chat_id 分片做 pub/sub:它同時解掉 fan-out 放大、gateway 無狀態擴展、與訊息順序權威三件事。」一招解三題,比逐項補強更有說服力。

10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~2 min)

決策點 選擇 放棄了什麼 為什麼值得
通訊協定 WebSocket polling 的實作簡單 雙向、低延遲、server 可主動 push(< 500ms 硬需求)
分片單位 chat_id(pub/sub) user_id 分片的直覺 單一 owner → 順序權威 + fan-out N→M(招牌取捨
訊息 ID 伺服器端 monotonic(snowflake 等) client timestamp 的省事 避開 clock skew / 不唯一,保證時序 + 唯一
gateway 設計 stateless(與 chat ownership 解耦) sticky session 便利 任意連任一 gateway、易水平擴展
訊息儲存 NoSQL(DynamoDB) RDB 的 join / 強 schema 高寫吞吐 + chat_id 分區查整個 chat
durability 持久化後才回 SUCCESS + ack 閉環 「送出即成功」的低延遲 訊息不可遺失 + 離線可補收
收尾話術

「整題的單一錨點是 以 chat_id 分片:我刻意不以 user 為中心,因為 (1) 同一 chat 需要 單一順序權威,(2) fan-out 要從 N 個接收者降到 M 個 gateway,(3) gateway 才能 stateless 水平擴展——一招解三題。其餘是即時通訊的標準打法:WebSocket 取代 polling、monotonic message_id 解亂序、持久化後再 ack 保 durability、離線靠 last_read_message_id store-and-forward。連線層會最先裂開(單台 100–200 萬),所以前面擺 consistent hashing 與數百台 host。」引用既有決策用連結,不重新推導。

後續追問演練 (Interviewer Follow-ups — "What if…")

為什麼這個系統選 WebSocket 而不是 long polling?

為什麼分片用 chat_id 而不是 user_id?

為什麼 message_id 不能用 client 端產生的 timestamp?

流量放大 100x(並發連線到數億),架構哪裡先撐不住?怎麼補?

某個超大群(接近 100 人)成為熱點,單一 chat 全壓在一個 pub/sub owner 上怎麼辦?

pub/sub owner 節點掛掉,該 chat 的訊息推送中斷,怎麼恢復?

離線使用者上線後,怎麼確保「不漏收也不重複」期間訊息?

建群(createChat)為什麼要包在 transaction 裡?不包會怎樣?

為什麼訊息儲存選 NoSQL(DynamoDB)而非關聯式 DB?

主動回想:白板重建 (Whiteboard from Scratch)

不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。

常見陷阱 (Exam Traps for This Problem)