Messenger 即時通訊 (Design Messenger)
設計一個 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 排除) |
「億級使用者同時在線怎麼定位接收者所在的 server 我先標記,等 deep dive 展開——現在先假設所有人連到同一台 chat server、用一個 local hash map 維護 user_id → WebSocket connection,這樣能先把端到端訊息流程串起來。」這招把最難的分片問題推遲,先保住敘事連貫。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
- 功能性需求 (FR)
- 使用者可 建立群聊(多位參與者,上限 100 人)。
- 使用者可 收發訊息。
- 使用者 離線期間 別人送的訊息,上線後 仍能收到。
- 不在範圍 (Out of Scope):傳附件、視訊通話、線上狀態(presence)。
- 非功能性需求 (NFR)
- 低延遲:在線使用者 < 500ms 收到訊息。
- 耐久性(Durability):送出的訊息 不可遺失。
- 一致性(Consistency):同一 chat 內所有人看到 完全一致的訊息順序。
- 規模 / 高吞吐:支援 1 億使用者。
設計北極星(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 |
「sendMessage 回 SUCCESS 的語意是 訊息已持久化到 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)
通用機制(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 是 基於 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-Protocols、10-Design-Patterns/05-Real-time-Updates、01-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 聊天理論可行,但規模一大就難維護:
- 每則訊息要對 N 個接收者各發一次 RPC;大群聊每則可能數百~上千 RPC。
- scale up/down 時除了 socket,還要重新分配 user_id 的 ownership,極難。
- 最根本:以 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
- gateway 啟動時從 service discovery 載入 shard mapping 並本地快取、定期監聽更新。
- 使用者連上 gateway 並加入某 chat → gateway 向該 chat 的 owning pub/sub server 發
Subscribe(chat_id)→ owner 把該 gateway 加入subscribers[chat_id]。 - pub/sub server 用 heartbeat 向 service discovery 回報存活與 shard ownership。
| 維度 | 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-Hashing、09-Messaging-Coord/05-Zookeeper、09-Messaging-Coord/02-Queue、02-Distributed-Systems/03-Scalability
shard_id → pub/sub host 的對應若不一致,不同 gateway 可能把同一 chat 的訂閱送到 不同 owner,瞬間破壞「單一 owner」前提 → 順序權威崩潰。故用 Raft/Zab 共識的強一致儲存(Zookeeper/Consul),寧可查詢稍慢也要保證對應唯一。見 09-Messaging-Coord/05-Zookeeper。
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。
同時要「時間順序性 + 唯一性」,常見三種:(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——送出後不可丟。
做法:sendMessage 的 SUCCESS 語意是 已寫入 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-Hashing、02-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 |
「系統會 先在連線層裂開(單台 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…")
即時通訊是 高頻雙向 場景,server 要主動 push。Long polling 在高頻時「收完資料還要重建連線」延遲仍在,且使用者再少聊天 client 也得定期重連、效率差。WebSocket 是基於 HTTP Upgrade 的持久 TCP 雙向通道,一旦建立雙方隨時可送訊息,最適合 < 500ms 的即時需求。見 01-Networking/05-Realtime-Protocols。
三個理由:(1) user 分片時同一 chat 散在多 owner,無法強制單一訊息順序;(2) fan-out 要對 N 個接收者各發 RPC,大群聊爆炸;(3) scale 時要重分配 user ownership 極難。chat_id pub/sub 讓 每個 chat 唯一 owner(順序權威)、fan-out 降為 M 個 gateway、gateway stateless。見 05-Database-Advanced/02-Sharding 與 09-Messaging-Coord/02-Queue。
兩個原因:client 之間有 clock skew;timestamp 不保證唯一(同毫秒多 client)。改由 接收並持久化訊息的 pub/sub owner 指派 monotonic increasing message_id(auto-increment counter / UUID v1 / Snowflake),兼具時序與唯一。client 收到 late message 時本地重排序。
連線層先裂。依序:(1) 單台僅 100–200 萬連線 → 用 consistent hashing 把連線分到更多 gateway,gateway stateless 直接加機器;(2) shard 數 S 提高、pub/sub server 擴容,service discovery 重分配 ownership;(3) 大群聊 fan-out 仍是壓力 → pub/sub 把對象壓到 M 個 gateway。見 02-Distributed-Systems/04-Consistent-Hashing、02-Distributed-Systems/03-Scalability。
這是 chat 分片下的 hot owner。權衡:群聊有 100 人上限,單一 owner 對 ≤100 成員的 M 個 gateway fan-out 仍可控;若該 shard 整體負載過高,可 調整 shard 數 / 重新平衡 shard ownership(consistent hashing 增刪節點影響最小)。不能把同一 chat 拆到兩個 owner——那會破壞順序權威。見 02-Distributed-Systems/04-Consistent-Hashing。
pub/sub server 用 heartbeat 向 service discovery 回報存活;節點失聯後 service discovery(Raft/Zab 強一致)把該 shard 的 ownership 重新指派 給其他節點,gateway 監聽到更新後 重新 Subscribe(chat_id) 到新 owner。訊息本身已持久化在 Message table,不會遺失,新 owner 接手繼續指派 monotonic id。見 09-Messaging-Coord/05-Zookeeper 與 12-Ops-and-Reliability/03-Reliable-Delivery。
漏收靠 store-and-forward:訊息早已在 Message table,上線後查 message_id > last_read_message_id 補收。避免重複靠 ack 閉環:client 收到才回 ack、server 才更新 last_read;未 ack 的會再被推送(at-least-once),client 端用 message_id 去重 + 重排序。見 12-Ops-and-Reliability/03-Reliable-Delivery。
建群要寫 1 筆 Chat + N 筆 Membership。若不在 同一 transaction,可能 Chat 建好但部分 Membership 寫失敗 → 某些成員查不到、收不到訊息,或群成員清單不一致。包成原子操作保證「要嘛全成、要嘛全敗」。見 05-Database-Advanced/01-Transactions。
訊息是 高寫吞吐(~46k/sec)+ 持續高速增長 的工作負載,且查詢模式單純(「撈某 chat 的訊息 / 成員」)。NoSQL 以 chat_id 當 partition key、message_id/user_id 當 sort key,天生支援這種「同 partition 內依序查」的存取,水平擴展也更直接。RDB 的 join / 強 schema 在此用不上、反成擴展負擔。見 06-Database-Tech/03-DynamoDB。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:看到「聊天」就上 long polling,或乾脆 simple polling。
Why:高頻場景 polling 造成大量空回應與重建連線延遲,撐不住 < 500ms 與 1 億使用者吞吐。
正確做法:用 WebSocket(HTTP Upgrade 持久雙向 TCP 通道)。見 01-Networking/05-Realtime-Protocols。
What:直覺把每個 user 雜湊到一台 server,訊息逐一 RPC 給每位接收者。
Why:(1) 大群聊每則數百~上千 RPC;(2) scale 時重分配 user ownership 極難;(3) 同 chat 散多 owner → 無法強制訊息順序。
正確做法:以 chat_id 分片做 pub/sub,單一 owner 兼順序權威,fan-out 降為 M 個 gateway。見 05-Database-Advanced/02-Sharding、09-Messaging-Coord/02-Queue。
What:訊息帶 client 產生的 timestamp,server / 其他 client 照它排序。
Why:client 間 clock skew + timestamp 不唯一(同毫秒多人)→ 順序不一致。
正確做法:由 持久化訊息的 pub/sub owner 指派 monotonic increasing message_id(Snowflake / UUID v1 / auto-increment)。
What:push 出去就回 SUCCESS,不先持久化、不等 ack。
Why:接收者離線或網路斷時訊息會 永久遺失,違反 durability。
正確做法:先寫 Message table 才回 SUCCESS;client 回 ack 才更新 last_read_message_id;離線靠 message_id > last_read store-and-forward 補收。見 12-Ops-and-Reliability/03-Reliable-Delivery。
What:shard_id → host 的對應用最終一致儲存。
Why:對應不一致時,不同 gateway 會把同一 chat 訂閱到 不同 owner → 順序權威崩潰。
正確做法:用 強一致(Raft/Zab)service discovery(Zookeeper/Consul),保證對應唯一。見 09-Messaging-Coord/05-Zookeeper。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 02-Distributed-Systems/03-Scalability
- 02-Distributed-Systems/04-Consistent-Hashing
- 01-Networking/05-Realtime-Protocols
- 01-Networking/02-TCP-vs-UDP
- 01-Networking/03-HTTP-and-HTTPS
- 10-Design-Patterns/05-Real-time-Updates
- 09-Messaging-Coord/02-Queue
- 09-Messaging-Coord/05-Zookeeper
- 05-Database-Advanced/01-Transactions
- 05-Database-Advanced/02-Sharding
- 06-Database-Tech/03-DynamoDB
- 12-Ops-and-Reliability/03-Reliable-Delivery