跳至主要內容
經典架構拆解 — 第 6/6 篇

經典架構拆解 · 06 — Discord 百萬人頻道與 NoSQL 選型

內文字級

經典架構拆解 · 06 — Discord 百萬人頻道與 NoSQL 選型
更新:2026-10-12

熱門 Discord 伺服器裡,成員可能同時聊天、搜尋舊訊息、加入語音。使用量成長後,訊息儲存、即時連線和語音服務會先後遇到不同瓶頸。Discord 的工程文章提供了觀察這些取捨的材料。

這是 經典工程架構拆解 系列的最後一篇,上一篇看了 Airbnb 的搜尋與金流。

大型頻道架構:Clients 經 Gateway shards 訂閱頻道,Event bus 將事件 fanout 給持有訂閱連線的 Gateway;Message store 以 channel_id 加 time bucket 分片,時間桶取代單一 Hot partition;Rust Read State 另存 last-read cursor;Cassandra 到 ScyllaDB 遷移依序雙寫、驗證與切換讀取,穩定前保留回滾路徑;語音流量則走最近的 Edge relay

訊息只保存一份 canonical copy,Gateway fanout 只處理自己持有的訂閱連線;主鍵必須配合「按頻道依時間讀取」的查詢,而 time bucket 會限制熱門分區大小。資料庫遷移先雙寫與驗證,再逐步切讀;回滾是穩定前保留的分支,不是必經步驟。語音的延遲問題則由最近的邊緣節點處理。


為什麼值得拆解

Discord 的工程部落格在資料庫、Rust、語音這三塊都寫過夠深的長文,而且難得的是,你看得出來他們每個技術決策背後都連著一個量得出來的問題——p99 延遲多少、每天寫入幾筆——不是看到別人用什麼就跟著用。對準備 PCA 的人來說這很重要:考試指南 Section 1.1 就把「設計決策的取捨」(Design decision trade-offs)列為考量項目。

Discord 身上最值得學的,是三個「什麼時候」:什麼時候該選 NoSQL、什麼時候該換掉現在這套 NoSQL、什麼時候該用系統程式語言把熱點重寫一遍。這三題都是架構設計時會反覆遇到的取捨。


商業規模與壓力

根據 Discord 官方與公開演講:

  • 月活躍用戶(MAU)超過 2 億,同時在線超過 千萬量級(Discord 官方部落格 Discord for Business Newsletter)1。
  • 截至 2022 年 Discord 每天寫入訊息超過 數十億則(Discord Engineering Blog “How Discord Stores Trillions of Messages”)2。
  • 單一 server(channel)曾在 Midjourney 全盛期擁有 超過 1,900 萬成員(Midjourney 公開聲明與多家媒體報導)3。

大型頻道的難處在於,一則新訊息可能要同時送到大量在線成員手上,還得保存下來供日後搜尋。寫入速度、即時扇出、儲存成本和歷史查詢會互相牽制;後面談到的技術選型,都是在處理這幾個需求之間的取捨。

查看流程與節點文字

箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。

sequenceDiagram
  participant U as Sender
  participant G as Gateway shard
  participant M as Message service/store
  participant E as Event bus
  participant S as Subscriber Gateway shards
  U->>G: send message
  G->>M: 驗證並寫入 canonical copy
  M-->>G: message_id+sequence
  G-->>U: ack
  M->>E: publish channel event
  E->>S: 只送到有訂閱者的 shards
  S-->>S: fanout 給各自維護的連線
  Note over M,S: 不為每位成員複製一份訊息資料
核心儲存按 channel 保存單一 canonical copy;Gateway fanout 只負責傳輸到自己持有的訂閱連線,避免形成每人一份 inbox 的儲存爆炸。

架構演進簡史

階段年份關鍵變化
MongoDB2015初期單庫,遇到大量寫入後 lock contention 嚴重
遷移到 Cassandra2017改用 Cassandra,以日期 bucket 當 partition key 2
Rust 重寫 Read States2020用 Rust 改寫「最後已讀」服務,移除 Go GC 抖動 4
Cassandra → ScyllaDB2022訊息儲存全面遷到 ScyllaDB,吞吐提升、延遲降低 2
持續 Rust 化2023 以後多個核心服務(包含訊息查詢)改寫 Rust

表格超出欄寬時,可左右捲動;鍵盤可聚焦表格後用左右方向鍵查看。

把上面的取捨畫成一張圖,整個訊息與語音的骨架長這樣:

用戶端

Gateway

訂閱 · 扇出

訊息服務

訊息儲存 ScyllaDB

channel+時間 分片

Read States(Rust)

語音邊緣節點

全球

扇出

已讀

語音

Discord 百萬人頻道:Gateway 訂閱後扇出,訊息存進依 channel+時間分片的 ScyllaDB,Read States 用 Rust,語音走全球邊緣節點。

大量 WebSocket 連線經全域入口後依 guild_id 路由到不同 Gateway 分片,事件只送回有訂閱者的分片

這是便於理解的即時連線分片模型:穩定路由鍵把連線與會話分散到多個 Gateway shard,事件只回到有訂閱者的分片。用戶端若保有 sequence 與 session id,短暫斷線後便有機會續傳而非全量同步。


核心技術決策

決策為何替代方案
訊息 partition key = (channel_id, 時間 bucket)同 channel 訊息連續儲存,查歷史快 2用 message_id(會跨 shard 查)
Cassandra → ScyllaDB同一張表、同一種 CQL,但 ScyllaDB 用 C++ 寫且 shard-per-core,延遲降 40%+ 2自己調 Cassandra JVM(邊際效益低)
Read State 用 Rust原本用 Go,其 GC(每約 2 分鐘強制掃描)造成 p99 延遲尖峰,Rust 以所有權模型移除整個 GC 議題 4維持 Go 但調 GC(治標不治本)、C++(開發成本高、記憶體安全風險)
語音走全球邊緣節點語音延遲敏感,近端 relay 比中央處理好全部集中(跨洋延遲致命)
訊息永久保留社群文化需要翻舊訊息LRU 清除(會損害產品體驗)

表格超出欄寬時,可左右捲動;鍵盤可聚焦表格後用左右方向鍵查看。

ScyllaDB 遷移的關鍵: Discord 不是因為 Cassandra「不行」才換的。是 Cassandra 的 JVM GC 造成的 p99 延遲尖峰,在他們這種規模下變成了主要瓶頸。而 ScyllaDB 的 API 幾乎一模一樣,遷移成本控制得住2。這種「保留介面、換掉底層實作」的思路,很值得記起來——它讓你可以分階段搬,而不是停機重來一次。

Partition key 看起來只是欄位順序,卻會影響讀寫落在哪些節點。以 IoT 時序資料為例,如果 key 只從時間戳開始,最新寫入可能集中在相近範圍,形成熱點;改用裝置 ID 等能分散寫入的前綴,還要確認常用查詢能否有效讀取。Discord 把 channel 與時間範圍納入 key,也是為了兼顧「讀某個 channel 的近期訊息」和分散負載。設計寬列式資料庫時,先列出常用查詢與寫入分布,再決定 key 的形狀。

查看流程與節點文字

箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。

flowchart TB
  Q["最高頻查詢<br/>讀某 channel<br/>最近 N 則訊息"] --> K["Row key 前綴<br/>channel_id+time_bucket"]
  K --> W["新訊息寫入目前 bucket"]
  K --> R["先掃最新 bucket"]
  R --> E{"已取得足夠訊息?"}
  E -->|"否"| O["往前掃較舊 bucket"]
  O --> E
  E -->|"是"| S["依時間順序回傳"]
  B["Bucket 限制熱門<br/>partition 的大小"] -.-> W
channel_id 保留同頻道資料局部性,time bucket 限制熱門分區大小;讀取由新往舊掃描,直到取得需要的訊息數量。
聊天訊息用 channel_id 加時間 bucket 切成每日分區,讀取最近訊息時先查最新 bucket,不足才往較舊分區查詢

只用 channel_id 會讓熱門頻道永遠塞進同一個膨脹分區;再加時間 bucket,就能保留頻道內的時間順序,同時限制每個分區的大小。查詢最新訊息時由新往舊串接即可。

查看流程與節點文字

箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。

flowchart TB
  A["既有 Cassandra 讀寫"] --> B["建立相容 Schema<br/>並回填歷史資料"]
  B --> C["Dual-write 新舊資料庫"]
  C --> D["持續比對 row count、checksum、<br/>latency 與 error rate"]
  D --> E{"資料與 SLO 達標?"}
  E -->|"否"| F["修正並維持舊讀取路徑"]
  F --> C
  E -->|"是"| G["小比例切換讀流量"]
  G --> H{"新系統穩定?"}
  H -->|"否"| I["讀取回滾 Cassandra"]
  I --> C
  H -->|"是"| J["逐步擴大並最終停用舊系統"]
先回填、雙寫與驗證,再漸進切讀;舊系統在新路徑穩定前保留為回滾選項,避免一次性大爆炸遷移。

如果用 GCP 重新蓋

Discord 元件GCP 對應
訊息儲存Bigtable(大規模 key-value、sparse wide-column,接近 Cassandra 模型)或自建 ScyllaDB on GKE
Read State / 已讀游標Firestore 或 Memorystore for Redis
WebSocket gatewayGKE 跑 Rust 服務
語音 relay 邊緣Cloud Run 多 region 部署 + Cloud Load Balancing(Anycast)或 GKE 加 NLB
訊息搜尋Agent Search(前身為 Vertex AI Search)或 Elasticsearch on GKE,透過 Dataflow CDC 同步
媒體(附件、圖)Cloud Storage + Cloud CDN
分析BigQuery + Pub/Sub 串流事件
即時反詐欺Gemini Enterprise Agent Platform(機器人、spam 偵測即時推論)

表格超出欄寬時,可左右捲動;鍵盤可聚焦表格後用左右方向鍵查看。

關鍵取捨: Google Cloud 沒有與 ScyllaDB 完全相同的託管服務。Bigtable 適合大量、以 row key 為主的讀寫,但不能直接把 Cassandra 的資料模型和查詢搬過去;單列交易限制與容量規劃也要先確認。如果需求包含跨列交易或跨區域的一致性,還得比較 Spanner 等選項。這時候成本與資料模型是否合適,比「哪個產品最像 ScyllaDB」更重要。

查看流程與節點文字

箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。

flowchart TB
  A["大量聊天/時序寫入"] --> B{"需要跨 row 交易<br/>或全球強一致?"}
  B -->|"否"| C{"查詢是否以 key/<br/>range 為主,<br/>且要線性擴展?"}
  C -->|"是"| D["Bigtable<br/>設計正確 Row Key"]
  C -->|"否,文件與即時同步為主"| E["Firestore"]
  B -->|"是"| F{"需要 SQL 與全球交易?"}
  F -->|"是"| G["Spanner"]
  F -->|"否,單區關聯式"| H["Cloud SQL"]
  D --> I["用負載測試驗證 hotspot、latency 與容量"]
  E --> I
  G --> I
  H --> I
高吞吐不代表一律選 Bigtable;題目若加入跨 row 交易或全球強一致,就要重新評估 Spanner 等關聯式選項及其成本。

考場上會怎麼考

Discord 的設計可以拿來練習三類問題:NoSQL 選型、全球低延遲部署,以及不停機的資料遷移。下面用幾個情境檢查該怎麼做取捨。

📝 考場提點:先看資料模型,再選服務

如果系統有大量寫入,資料按 key 或時間查詢,先確認讀寫模式、資料模型與一致性需求,再比較服務:

  • 大量寬列或時間序列資料,以 key/range 查詢為主:評估 Bigtable,並測試 row key 是否造成熱點。
  • 文件結構、行動應用即時同步:評估 Firestore,同時估算讀寫量與查詢成本。
  • 需要 SQL 與多筆資料交易:比較 Cloud SQL、Spanner 的擴展需求、可用性和費用。

不要只憑「高吞吐」或「全球」選服務。若交易會跨多列、跨區域,先確認產品能否提供需要的一致性;若只是大量單列讀寫,就要測試吞吐、延遲與成本。資料規模和存取方式,比單一關鍵字更能決定選型。

📝 考場提點:全球低延遲 vs. 線上遷移

全球部署要先拆開看:哪些內容能放在邊緣快取、哪些請求必須回到後端,以及使用者能接受多少延遲。Global Load Balancing 搭配多區域後端,是需要跨區域服務使用者時可評估的架構;但是否能達到特定延遲目標,仍要實測網路路徑與後端處理時間。

線上遷移則要規劃新舊系統如何並行、如何核對資料,以及何時切換讀取流量。Dual-write 是其中一種做法,但會帶來雙邊寫入失敗與順序不一致的處理成本;選用前要設計驗證和回滾方式,不能只把「同時寫兩邊」當成零風險切換。


常見誤解

  • 「Discord 換 ScyllaDB 是因為 Cassandra 不夠快。」 比較準確的說法是:在他們這種規模下,是 Cassandra 的 JVM GC 延遲尖峰變成了主要瓶頸,不是單純「速度不夠」。工程部落格原文強調的是 p99 延遲的改善2。
  • 「Rust 一定比現有語言快,所以該全部改寫。」 Discord 只在延遲超敏感的熱點服務(Read States、Gateway)才改成 Rust(Read States 是從 Go 改寫過來的),其他地方還是維持 Python / Elixir。做法是「哪裡是瓶頸就動哪裡」,不是整套重寫。
  • 「百萬人頻道,就是同時推百萬個 WebSocket。」 實際上走的是訂閱-扇出模型:Gateway server 訂閱 channel 事件,再把事件推給自己這邊連線的使用者。邊緣 server 不會直接拉百萬條連線互連。

來源與延伸閱讀


這是 經典工程架構拆解 系列的最後一篇。六家公司看下來,你大概會發現一件事:這些撐住超大規模的架構,講穿了都在同樣幾招上反覆下功夫——把資料切好(分片)、用事件把系統解耦、哪裡是瓶頸就只動哪裡、能保留介面就別整套重寫。沒有什麼神奇銀彈,就是把這幾個原則用在對的地方。

這幾招也對得上 PCA 考試指南列的設計取捨項目。想把它們套到自己的題目上,可以到 PCA 案例研究庫 對照 Google 官方 case study,或乾脆動手走一遍 架構師設計工作坊。

🎯 換你練習

想動手設計類似系統?到 架構師設計工作坊 用這套思考步驟走一遍。

Footnotes

  1. Introducing the Discord for Business Newsletter, Vol. 1 — 官方部落格揭露 200M+ MAU 數據來源。 ↩

  2. How Discord Stores Trillions of Messages — Discord Engineering Blog — 從 Cassandra 遷到 ScyllaDB 的完整工程記錄。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  3. Midjourney Community Size — Midjourney 官方 Discord 與媒體報導 — Midjourney Discord server 規模公開資訊。 ↩

  4. Why Discord is switching from Go to Rust — Discord Engineering Blog — Read States 服務為何從 Go 改用 Rust(解決 GC 抖動)。 ↩ ↩2

經典架構拆解 — 第 6 篇/共 6 篇 查看系列全覽 →

讀完後,留一個學習紀錄

試著用自己的話說出一個重點,以及你會如何運用。實作文章請先核對結果與清理資源,再標記為讀完。

文章進度以你確認讀完的標記為準,可隨時取消。舊版造訪紀錄保留,但不計入讀完;這份自評不代表已掌握內容。

留言討論

文章目錄與系列

徽章解鎖!