跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:從一台機器到百萬用戶

案例:從一台機器到百萬用戶

使用者一路變多,每次都是某個瓶頸先爆:資料庫、單台伺服器、讀取量、寫入量。看每個元件是在哪個時間點、為了解決哪個瓶頸登場的。

流程
靜態檔案慢工作讀取沒命中寫入複製依鍵切分使用者CDNCDN負載平衡負載平衡應用伺服器訊息佇列訊息佇列背景 worker快取快取主資料庫資料庫複製唯讀副本分割與分片更多分片

這是一百萬人時的架構。選一個流程,看一次讀取和一次寫入怎麼經過它;每一步也會亮起估算程式裡、讓那個元件變成必要的那一行。下面拉動使用者人數,看這些元件是依什麼順序出現的。

亮起來的是這一步執行的程式碼
const REQUESTS_PER_USER_PER_DAY = 200, PEAK_FACTOR = 4;
const QUERIES_PER_REQUEST = 3, READ_SHARE = 0.9;
const SINGLE_BOX_QPS = 20, APP_SERVER_QPS = 300, DB_QPS = 3000;
const CACHE_HIT_RATE = 0.9, STATIC_KB_PER_REQUEST = 100, ORIGIN_KB_PER_SEC = 125000;
const SLOW_JOB_SHARE = 0.01, INLINE_SLOW_JOBS_PER_SEC = 20;
const REPLICA_READ_QPS = 3000, PRIMARY_WRITE_QPS = 2500;
function estimate(users: number) {
const peak = (users * REQUESTS_PER_USER_PER_DAY * PEAK_FACTOR) / 86400;
const queries = peak * QUERIES_PER_REQUEST;
const reads = queries * READ_SHARE;
const writes = queries - reads;
const needs: string[] = [];
if (peak > SINGLE_BOX_QPS) needs.push("database");
if (peak > APP_SERVER_QPS) needs.push("load-balancer");
if (queries > DB_QPS) needs.push("cache");
const dbReads = needs.includes("cache") ? reads * (1 - CACHE_HIT_RATE) : reads;
if (peak * STATIC_KB_PER_REQUEST > ORIGIN_KB_PER_SEC) needs.push("cdn");
if (peak * SLOW_JOB_SHARE > INLINE_SLOW_JOBS_PER_SEC) needs.push("queue");
if (dbReads + writes > DB_QPS) needs.push("replicas");
if (writes > PRIMARY_WRITE_QPS) needs.push("sharding");
return {
needs,
appServers: Math.max(1, Math.ceil(peak / APP_SERVER_QPS)),
replicas: needs.includes("replicas") ? Math.ceil(dbReads / REPLICA_READ_QPS) : 0,
shards: Math.max(1, Math.ceil(writes / PRIMARY_WRITE_QPS)),
};
}
1,000
1101001K10K100K1M1234567
1,000 人時已經有的元件(亮起來的),其他的還沒需要
靜態檔案慢工作讀取沒命中寫入複製依鍵切分使用者CDNCDN負載平衡負載平衡應用伺服器訊息佇列訊息佇列背景 worker快取快取主資料庫資料庫複製唯讀副本分割與分片更多分片

現在資料庫還和程式跑在同一台應用伺服器上。

  1. 1資料庫搬到自己的機器程式和資料庫擠在同一台:每秒 9.3/20 個請求2.2K 人起
  2. 2負載平衡+多台應用伺服器一台應用伺服器:每秒 9/300 個請求32.4K 人起
  3. 3快取資料庫:每秒 28/3,000 次查詢108K 人起
  4. 4CDN原站送出靜態檔案:1/125 MB/s135K 人起
  5. 5訊息佇列+背景 worker在請求裡直接做的慢工作:每秒 0.1/20 件216K 人起
  6. 6唯讀副本快取之後的資料庫:每秒 28/3,000 次查詢568K 人起
  7. 7分片寫入單一主資料庫:每秒 3/2,500 次900K 人起
尖峰每秒請求
9.26
應用伺服器
1
唯讀副本
0
分片
1

1,000 位使用者,尖峰每秒 9.26 個請求:一台機器同時跑程式和資料庫就夠了。第一個撐不住的會是這台機器,大約在 2,161 人時。

假設:每人每天 200 個請求,最忙的那小時是平均的 4 倍,每個請求 3 次資料庫查詢、其中 90% 是讀取。上限:程式和資料庫同一台時每秒 20 個請求、一台應用伺服器每秒 300 個、一台資料庫每秒 3,000 次查詢、主資料庫每秒 2,500 次寫入、原站頻寬 1 Gbps(每個請求 100 KB 靜態檔案)、請求裡直接做的慢工作每秒 20 件。改任何一個假設,每個階段都會跟著移動。

亮起來的是這一步執行的程式碼
const REQUESTS_PER_USER_PER_DAY = 200, PEAK_FACTOR = 4;
const QUERIES_PER_REQUEST = 3, READ_SHARE = 0.9;
const SINGLE_BOX_QPS = 20, APP_SERVER_QPS = 300, DB_QPS = 3000;
const CACHE_HIT_RATE = 0.9, STATIC_KB_PER_REQUEST = 100, ORIGIN_KB_PER_SEC = 125000;
const SLOW_JOB_SHARE = 0.01, INLINE_SLOW_JOBS_PER_SEC = 20;
const REPLICA_READ_QPS = 3000, PRIMARY_WRITE_QPS = 2500;
function estimate(users: number) {
const peak = (users * REQUESTS_PER_USER_PER_DAY * PEAK_FACTOR) / 86400;
const queries = peak * QUERIES_PER_REQUEST;
const reads = queries * READ_SHARE;
const writes = queries - reads;
const needs: string[] = [];
if (peak > SINGLE_BOX_QPS) needs.push("database");
if (peak > APP_SERVER_QPS) needs.push("load-balancer");
if (queries > DB_QPS) needs.push("cache");
const dbReads = needs.includes("cache") ? reads * (1 - CACHE_HIT_RATE) : reads;
if (peak * STATIC_KB_PER_REQUEST > ORIGIN_KB_PER_SEC) needs.push("cdn");
if (peak * SLOW_JOB_SHARE > INLINE_SLOW_JOBS_PER_SEC) needs.push("queue");
if (dbReads + writes > DB_QPS) needs.push("replicas");
if (writes > PRIMARY_WRITE_QPS) needs.push("sharding");
return {
needs,
appServers: Math.max(1, Math.ceil(peak / APP_SERVER_QPS)),
replicas: needs.includes("replicas") ? Math.ceil(dbReads / REPLICA_READ_QPS) : 0,
shards: Math.max(1, Math.ceil(writes / PRIMARY_WRITE_QPS)),
};
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 白板容量估算,依假設的使用量與元件容量決定階段;不是固定人數就必須升級,也沒有自動擴縮的實測資料。

什麼時候用

  • 系統設計面試的開場:先用粗估算出尖峰流量和資料量,再決定需要哪些元件,而不是一開始就把所有元件畫上去。
  • 規劃產品成長時:知道下一個瓶頸在哪、大概在多少使用者時出現,就能提早準備,而不是等它爆掉。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
應用伺服器數
n 是使用者數;無狀態,所以可以一直加
O(n)O(n)
資料庫分片數
寫入隨使用者線性成長,一台主資料庫的寫入上限是固定的
O(n)O(n)
快取記憶體O(n)O(n)
一個請求經過的元件數
水平擴充的目的:人變多,單一請求的路徑不變長
O(1)O(1)

空間:O(n),資料隨使用者線性成長

Big O 實測:n 變大時步數怎麼長

數的是:需要的機器數或 GB(n 是每日使用者,超出滑桿範圍的外插)

Big On = 10,000,000n = 100,000,000n = 1,000,000,000成長倍數:實測(理論)
應用伺服器O(n)3093,08730,865×100 (×100)
資料庫分片O(n)121121,112×93 (×100)
快取記憶體(GB)O(n)202002,000×100 (×100)

同一個估算函式跑出來的。所有元件到齊之後,再往上就只是每一種加更多台,數量和使用者成正比。

真實世界裡的它

  • Instagram 被 Facebook 收購時只有十幾個工程師、卻有三千萬使用者:主要就是靠快取、唯讀副本和分片這幾步撐起來的。
  • 「Latency numbers every programmer should know」這類表格就是粗估的原料:記憶體、SSD、跨資料中心各要多久。

取捨與陷阱

  • 太早拆:一台機器撐得住的時候就上分片和微服務,只是多了維運成本和除錯難度。
  • 看平均值估容量會低估:流量有尖峰(這裡假設尖峰是平均的 4 倍),容量要照尖峰算。
  • 應用伺服器要無狀態才能隨意增減;session 存在本機記憶體,負載平衡一換機器就登出了。