經典架構拆解 · 04 — Slack 即時訊息與已讀同步
Slack 表面上就是個聊天室,底層卻是一套同時要扛長連線、訊息扇出(fanout)、已讀同步、全文搜尋的分散式系統。對架構師來說,它是看「即時 + 最終一致」混合設計怎麼做的好教材。
這是 經典工程架構拆解 系列的第 4 篇,上一篇討論了 Stripe 的冪等性(idempotency)設計。一個是金流要錢算對,一個是訊息要即時送達,看起來八竿子打不著,但你會發現它們在「同一份資料怎麼安全地給很多人看到」這件事上,煩惱的其實是同一類問題。
為什麼值得拆解
Slack 把看起來很簡單的「聊天」做成了企業級協作平台。它的架構正好點出三個關鍵問題:怎麼維持數百萬條長連線而不讓伺服器爆掉?一個 channel 有上萬人時,訊息要怎麼送?搜尋和即時訊息這兩種差很多的工作負載,要怎麼共存?
這三個問題拆開來,剛好就是 PCA 在 messaging 那一塊最愛問的東西——服務選型(Pub/Sub、Cloud Tasks、WebSocket)、event-driven 架構、一致性 vs 可用性的取捨。差別只在考試把它包成一份 case study,Slack 是真的拿幾百萬使用者在跑。
商業規模與壓力
根據 Slack 官方歷年公開資訊與第三方統計:
- 每日活躍使用者(DAU)官方最後一次公布是 約 1,200 萬以上(2020 年;Salesforce 併購後財報就不再單獨揭露 Slack DAU),近年第三方估算的數字更高,但屬估算而非官方財報;付費組織則超過 20 萬家1。
- 高峰期維持超過 數百萬條同時在線的 WebSocket 連線(根據 Slack Engineering Blog 2017 年的 “How Slack Built Shared Channels” 與後續演講)2。
- 單一 workspace 最多可達上萬使用者,單一 channel 訂閱者規模從數人到數萬人都有(Slack Help Center 官方文件)3。
壓力點:訊息平均延遲得壓在 幾百毫秒內,不然使用者馬上就「感覺到卡」。企業客戶還要求 SLA 99.99%。
這個「幾百毫秒」的要求很容易被低估。我自己經手過一個內部的通知推播功能,規模比 Slack 小好幾個量級,但只要訊息從觸發到送達超過大概一秒,使用者就會開始疑神疑鬼地重整頁面、重送一次,然後客服就收到「為什麼我發兩次」的回報。即時系統最反直覺的地方就在這:它不是「越快越好」的加分題,而是「慢一點就直接被當壞掉」的及格題。
架構演進簡史
| 階段 | 年份(約) | 關鍵變化 |
|---|---|---|
| 單體 PHP + MySQL | 2013–2015 | 每個 workspace 一組 MySQL shard,訊息從 DB 撈再推 |
| 加入 Job Queue | 2015 | 用自家 Job Queue(後來換成 Kafka)做非同步 fanout |
| Flannel Edge Cache | 2017 | 邊緣節點快取每個使用者的 channel 清單,降低登入延遲 2 |
| 全面 Vitess 化 | 2020 | MySQL 改跑 Vitess,解決單 shard 容量瓶頸(Slack Engineering Blog 2020)4 |
| Search 分離為獨立服務 | 2016–至今 | 用 Solr / 自家 indexer 做全文搜尋,與即時訊息分流 |
有意思的是,這條線上沒有「打掉重練」這一格。Slack 一路是在能跑的系統上一塊一塊換——先把 fanout 抽到 queue、再把登入慢的部分用 edge cache 擋掉、最後才動到最底層的資料庫分片。這種「邊飛邊修引擎」的順序不是巧合,而是任何還在成長的線上服務的常態:你沒有停機半年重寫的本錢,只能挑當下最痛的那一塊先解。
把這些選擇收斂成一張圖,訊息與已讀的流向大概是這樣:
Slack 即時訊息:用戶端走 WebSocket 長連線,Channel 服務把訊息寫一次再扇出推播,已讀只存 last_read_ts 游標。
核心技術決策
| 決策 | 為何 | 替代方案 |
|---|---|---|
| WebSocket 長連線 | 推訊息延遲低於 polling,省頻寬 | HTTP long-polling(早期用過)、Server-Sent Events |
| 每 workspace 一組 shard | 資料隔離清楚、好做 compliance | 全局分散式資料庫(成本與一致性代價太高) |
| Edge cache(Flannel) | 登入一次要傳的 channel/user metadata 太大,邊緣快取後只同步 diff 2 | 每次從核心服務撈(延遲高) |
| Job Queue 做 fanout | 大 channel 要把一則訊息複製給上萬人,同步做會卡住寫入 | DB trigger(無法橫向擴充) |
| 讀取游標(read cursor)只存「最後已讀的 ts」 | 不用為每則訊息存「誰讀過」,節省儲存 | per-message read receipt(像 WhatsApp,代價大太多) |
最後那一條——已讀游標的設計——是整張表裡最值得停下來看的,因為它示範了一個很省力的取巧。Slack 的「已讀」不是一條一條記,而是只存一個 last_read_ts(最後已讀時間戳)。客戶端切換 channel 時,WebSocket 上送一個事件,後端更新游標,再推到使用者其他裝置同步。
差別有多大?如果為每則訊息記「誰讀過」,資料量是 O(訊息 × 使用者);只存一個游標,立刻降到 O(channel × 使用者)。一個上萬人、每天幾萬則訊息的 channel,這兩種做法的儲存量差好幾個數量級。
這背後其實是一個值得內化的提問:使用者真的需要那麼精確的資訊嗎?「你在這個 channel 讀到哪了」99% 的情況下夠用,沒人會去追「第 4,217 則訊息你到底點開沒」。先想清楚使用者真正要的那條線,再決定資料怎麼存,往往能省掉一整層複雜度——這個直覺在考場跟實務都一樣值錢。
如果用 GCP 重新蓋
| Slack 元件 | GCP 對應 |
|---|---|
| WebSocket gateway | GKE 跑自家 gateway(Cloud Run 近期支援 WebSocket,但長連線仍偏好 GKE 管控更細) |
| 訊息儲存 | Cloud Spanner 或 Bigtable(超大量選 Bigtable;需跨 channel 交易選 Spanner) |
| 已讀游標 | Firestore 或 Memorystore for Redis(低延遲讀寫,每使用者 per-channel 一筆) |
| 訊息 fanout | Pub/Sub 做 topic per workspace 或 per channel;超大 channel 用 fan-out worker 拆 |
| 搜尋索引 | Dataflow 做 CDC(change data capture)管線 → Agent Search(前身為 Vertex AI Search)或自建 Elasticsearch on GKE |
| Edge cache | Cloud CDN + Memorystore 做 per-user metadata cache |
| 檔案附件 | Cloud Storage + 簽章 URL |
| 稽核與合規 | Cloud Logging + BigQuery 做分析、Cloud DLP 掃敏感資料 |
這張表最容易被考的一格,是 WebSocket gateway 那一條。很多人一看到「無狀態、自動擴縮」就反射選 Cloud Run,但長連線是有狀態的——連線一旦建立,這個使用者就「黏」在某台後端上,直到斷線為止。這跟 Cloud Run 那種「一個請求進來、處理完就放掉」的模型天生有張力。GKE 之所以在這裡更穩,是因為你能自己掌握連線怎麼分配、節點要淘汰時怎麼優雅地把連線遷走。
順帶提醒一個容易記反的點:上面把入口寫成 gateway,真正在 GCP 前面替你終止 WebSocket、做全球分流的那一層,是 Global External Application LB——它在 L7 原生處理 HTTP Upgrade 握手,能順著路徑做路由。Proxy Network LB 不是「不能跑 WebSocket」,而是它只能把 WebSocket-over-SSL 當成 L4 的 TCP/SSL 流量隧道過去,不做 HTTP 層的 Upgrade 與路徑路由。要在 L7 把 WebSocket 接起來、又要分流,答案還是 Application LB。這個對應關係下面考場提點會再講一次,因為它是考試的高頻反射題。
📝 考場提點
「長連線 / WebSocket / 即時雙向推播 + 要在 L7 做路徑路由」這組關鍵字一出現,入口層的建議答案幾乎鎖死 Global External Application LB——它在 L7 原生處理 HTTP Upgrade 握手,能把 WebSocket 接起來又順著路徑分流。要留意的對照組是 Proxy Network LB:它其實能承載 WebSocket,但是把 WebSocket-over-SSL 當 L4 的 TCP/SSL 流量隧道過去,不做 HTTP 層的 Upgrade 與路徑路由——所以題目只要強調「要看 HTTP、要按路徑/Host 分流」,標準答案就是 Application LB。另一個常見陷阱選項是 Cloud CDN(快取靜態內容,跟長連線無關)。別把 Proxy Network LB 背成「不能跑 WebSocket」,會背錯。後端要不要 Cloud Run 還是 GKE 是另一道題:題目強調「長連線、連線狀態要自己管、要優雅遷移」就偏 GKE;只是「短請求、無狀態、要極速擴縮到零」才輪到 Cloud Run。把這兩道題分開看,不要混成一題答。
📝 考場提點
messaging 的服務選型是 PCA 的常駐考點,記一條分水嶺就好:「一個事件、很多人要收到」走 Pub/Sub;「每個工作要保證跑一次、retry 要可控、可以指定誰來處理」走 Cloud Tasks。 Slack 的 fanout(一則訊息扇給上萬人)是前者的教科書案例。題目裡的訊號詞也很固定——出現「廣播」「多訂閱者」「解耦」就往 Pub/Sub 想;出現「task」「排程」「保證執行」「指定 worker」就往 Cloud Tasks 想。另一個常被綁在一起問的是一致性:已讀游標這種「晚幾秒同步也沒差」的資料適合 Firestore 的最終一致;訊息順序、金額這種「錯一次就出事」的資料才需要 Spanner 的強一致。看到「資料駐留」「指定區域」再補一刀 regional 資源 + VPC Service Controls,這三點常常包在同一題裡考。
常見誤解
- 「Slack 一定是把每則訊息寫進每個收件者的 inbox。」 實際上核心儲存是「channel → messages」的寫一次,推播時才扇出,不是 fan-out on write 的 per-user inbox 模型(Slack 公開演講多次提及此設計)2。這個誤解在考場也會以另一種面貌出現:看到「通知」「收件匣」就反射要為每人存一份,結果把儲存設計成天價。
- 「WebSocket 一定比 HTTP 快。」 只有在訊息頻繁、又是雙向的情境才划算。登入、上傳檔案這類一次性請求,Slack 還是走 HTTP。長連線本身是有成本的——你得一直維護那條連線,不是免費的快。
- 「長連線多 = 伺服器要開很多。」 單台 server 撐十萬條 idle WebSocket 其實不難,真正吃資源的是訊息廣播跟序列化。換句話說,容量規劃要看的是「每秒要扇出幾則訊息」,不是「掛了幾條連線」——這個直覺搞反,容量就會抓錯方向。
來源與延伸閱讀
- Flannel: An Application-Level Edge Cache — Slack Engineering — 2017 年邊緣快取設計原文。
下一篇:經典架構拆解 · 05 — Airbnb 搜尋與金流,來看一個完全不一樣的負載類型(搜尋排名 + 多幣別帳本)怎麼設計。
想看完整五大案例?到 PCA 案例資料庫 對照 Google 官方 case study。
🎯 換你練習
想動手設計類似系統?到 架構師設計工作坊 用這套思考步驟走一遍。
Footnotes
-
Slack reaches 10 million daily active users — Slack(官方最後一次公布 DAU 約在 2019–2020 年的 1,000–1,200 萬量級;Salesforce 併購後財報不再單獨揭露 Slack DAU)。付費組織與近年估算可參考 Slack Statistics — Business of Apps(第三方彙整,非官方財報數字)。 ↩
-
How Slack Built Shared Channels — Slack Engineering — 官方部落格解釋跨 workspace 的 channel 設計與 fanout。 ↩ ↩2 ↩3 ↩4
-
Slack Help Center — Workspace Limits — 官方文件列出的 workspace 與 channel 規模上限。 ↩
-
Scaling Datastores at Slack with Vitess — Slack Engineering — Slack 從 MySQL 轉 Vitess 的工程記錄。 ↩