案例:從一台機器到百萬用戶
使用者一路變多,每次都是某個瓶頸先爆:資料庫、單台伺服器、讀取量、寫入量。看每個元件是在哪個時間點、為了解決哪個瓶頸登場的。
流程
這是一百萬人時的架構。選一個流程,看一次讀取和一次寫入怎麼經過它;每一步也會亮起估算程式裡、讓那個元件變成必要的那一行。下面拉動使用者人數,看這些元件是依什麼順序出現的。
亮起來的是這一步執行的程式碼
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
1,000 人時已經有的元件(亮起來的),其他的還沒需要
現在資料庫還和程式跑在同一台應用伺服器上。
- 1資料庫搬到自己的機器程式和資料庫擠在同一台:每秒 9.3/20 個請求2.2K 人起
- 2負載平衡+多台應用伺服器一台應用伺服器:每秒 9/300 個請求32.4K 人起
- 3快取資料庫:每秒 28/3,000 次查詢108K 人起
- 4CDN原站送出靜態檔案:1/125 MB/s135K 人起
- 5訊息佇列+背景 worker在請求裡直接做的慢工作:每秒 0.1/20 件216K 人起
- 6唯讀副本快取之後的資料庫:每秒 28/3,000 次查詢568K 人起
- 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 O | n = 10,000,000 | n = 100,000,000 | n = 1,000,000,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 應用伺服器 | O(n) | 309 | 3,087 | 30,865 | ×100 (×100) |
| 資料庫分片 | O(n) | 12 | 112 | 1,112 | ×93 (×100) |
| 快取記憶體(GB) | O(n) | 20 | 200 | 2,000 | ×100 (×100) |
同一個估算函式跑出來的。所有元件到齊之後,再往上就只是每一種加更多台,數量和使用者成正比。
真實世界裡的它
- Instagram 被 Facebook 收購時只有十幾個工程師、卻有三千萬使用者:主要就是靠快取、唯讀副本和分片這幾步撐起來的。
- 「Latency numbers every programmer should know」這類表格就是粗估的原料:記憶體、SSD、跨資料中心各要多久。
取捨與陷阱
- 太早拆:一台機器撐得住的時候就上分片和微服務,只是多了維運成本和除錯難度。
- 看平均值估容量會低估:流量有尖峰(這裡假設尖峰是平均的 4 倍),容量要照尖峰算。
- 應用伺服器要無狀態才能隨意增減;session 存在本機記憶體,負載平衡一換機器就登出了。