📌 30 秒摘要(Layer 3)

系統設計面試系列的第一卷。前半奠定通用能力——如何從 0 擴展到百萬用戶、四步設計框架、粗估容量(這些與 系統設計面試 卷二共用,見既有工具);後半是一系列經典設計案例(限流器、一致性雜湊、鍵值儲存、唯一 ID、短網址、爬蟲、通知、動態訊息、聊天、搜尋自動補全…),每個案例都沉澱出可重用的設計積木。

🗺 心智圖(Canvas)

系統設計面試卷一

系統設計面試 卷一

Alex Xu|通用能力 + 經典設計積木

🚀 從 0 到百萬用戶 本書前半的擴展路徑

單機 → 分離 DB → 負載均衡 → 快取/CDN → 讀寫分離 → 分片 每一步都對應一個新出現的瓶頸

🧭 通用框架 (與卷二共用)

🎯 什麼情境該想到我

當你面對「設計一個 X 系統」這種又大又模糊的題目(面試或實務),不知從何下手時。

⚙️ 怎麼用(四步,別急著畫框)

  1. 釐清需求與範圍:功能需求(要做什麼)、非功能需求(規模、延遲、一致性)、圈定範圍。不確定就問。
  2. 容量估算:粗估 QPS、儲存量、頻寬(見 工具-容量估算),決定選型量級。
  3. 高層設計:畫出主要方塊與資料流(客戶端 → 負載均衡 → 服務 → 快取 → DB → 佇列…)。
  4. 深入與取捨:挑 1–2 個關鍵元件深入(如何分區/複製/快取),主動討論瓶頸與替代方案的取捨。

面試/評審看重的是推理與取捨,不是背標準答案。

★ 為什麼「先問清楚」比什麼都重要

系統設計沒有標準答案,只有「在某組約束下的合理選擇」。
約束沒問清楚 → 你的整個設計都建立在假設上,而那些假設多半是錯的。

必問的六個問題:

① 功能範圍:核心功能有哪些?★ 什麼「不做」?
② 規模:  DAU?QPS(平均/尖峰)?資料量?成長率?
③ 讀寫比:★ 讀多寫少(社群)vs 寫多讀少(遙測)→ 架構完全不同
④ 延遲:  p99 要求多少?可以非同步嗎?
⑤ 一致性:★ 可以最終一致嗎?(見 [[工具-線性一致性與共識]])
⑥ 其他:  合規?多地區?離線支援?成本上限?

★ 第 ③ 與 ⑤ 常常被跳過,但它們對架構的影響最大。

★ 容量估算:要算到什麼程度

目的不是精確,是「決定量級」——決定你需要一台機器還是一千台。

【範例】社群動態,DAU 1000 萬

寫入:每人每天發 0.1 則 → 100 萬則/天 → ★ 平均 ~12 QPS
      尖峰 ×5                        → ★ ~60 QPS        ← 很低!
讀取:每人每天看 50 次  → 5 億次/天  → ★ 平均 ~5,800 QPS
      尖峰 ×5                        → ★ ~29,000 QPS
★ 讀寫比 ≈ 500:1  →  結論:讀取是瓶頸,要重快取與讀取副本

儲存:每則 1KB × 100 萬/天 × 365 × 5 年 ≈ 1.8 TB  ← ★ 單機放得下!
      加上圖片(每則 20% 有圖,每張 200KB)→ ★ 73 TB/5年  ← 這才是大頭
      → 結論:文字進資料庫,圖片進物件儲存 + CDN

★ 要記的基準數字(量級即可):

1 天≈ 86,400 秒 ≈ 秒
1 百萬/天≈ 12 QPS
尖峰倍數通常 2–10× 平均
單台現代伺服器可處理 數千~數萬 QPS(依工作量)
單一 PostgreSQL可撐 數 TB、數萬 QPS(見 工具-資料分區 的警告)

★ 估算最大的價值是「發現不需要複雜方案」:
上面的例子算完會發現寫入只有 60 QPS,單機資料庫綽綽有餘——
整個「要不要分片」的討論消失了。

★ 四步之外:★ 一定要主動講取捨

這是系統設計評審真正在看的東西。 每個決定都用這個句型:

「我選 X,因為在『(約束)』下,Y 的代價是『(具體代價)』。
如果(約束改變),我會改用 Y。」

✅ 「我用 Redis 快取熱門動態,因為讀寫比是 500:1。
     代價是快取失效的複雜度與一致性延遲(使用者可能看到 5 秒前的讚數)。
     如果業務要求即時一致,我會改成 write-through 並接受更高的寫入延遲。」

❌ 「我用 Redis 做快取。」   ← 沒有推理,沒有代價,沒有邊界

★ 逐軸檢查(收尾時用)

設計完之後,用 工具-可靠可擴展可維護 的三軸掃一遍:

可靠性   → 每個元件掛了會怎樣?單點在哪?怎麼回滾?
可擴展性 → 10 倍流量時哪裡先爆?瓶頸是 CPU / 記憶體 / I/O / 網路?
可維護性 → 出事怎麼查?要多少人維運?

★ 再加一個常被忘記的:成本。這個設計一個月要多少錢?有沒有便宜十倍的做法?

★ 常見的失分點

失分正解
★ 直接開始畫框先問需求與規模
★ 每個元件都用最複雜的方案從最簡單能跑的開始,量出瓶頸再加
只講 happy path主動講失敗模式
背誦「標準架構」依約束推導
★ 不承認不確定「這個數字我會用壓測驗證」比編一個精確的假數字好
忽略成本與維運它們常常是真正的約束

🧪 我實際套用的紀錄

  • 2026-07-15:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 這是「討論」的框架,不是「實作」的順序。真實專案裡你會邊做邊修,而不是一次設計到位。
  • ★ 不要照搬「標準架構圖」。LB → API → Cache → DB → Queue 這張圖適用於很多場景,
    但把它當答案而不是起點,就是在背誦。
  • 估算不要算太細。目的是判斷量級。算到小數點只是浪費時間,而且假精確會誤導。
  • ★ 先問「能不能不做」。最好的系統設計常常是「這個需求可以用既有的東西解決」。
  • 不要一開始就上分散式。單機能解的就單機解——分散式的複雜度成本遠超過多數人的預估
    (見 工具-資料分區、工具-分散式事務的取捨)。
  • ★ 給演進路徑。「v0 用託管服務驗證需求,v1 再自建」比一次畫出完整架構更顯成熟。

🔗 相關工具

🎯 什麼情境該想到我

當你要快速判斷「這系統大概多大、需要什麼量級的資源/架構」時(設計前的粗估)。

⚙️ 怎麼用

  1. 從使用者/流量推 QPS:日活 × 每人操作數 ÷ 86400 ≈ 平均 QPS;尖峰再 ×2~10。
  2. 估儲存:每筆資料大小 × 筆數 × 保存年限;別忘副本與索引開銷。
  3. 估頻寬:QPS × 每次回應大小。
  4. 記幾個關鍵數字(延遲級距):記憶體讀 ~100ns、SSD ~100µs、磁碟/網路跨機房 ~ms;讀記憶體比讀磁碟快上萬倍。
  5. 只求數量級:目的是判斷「單機夠不夠、要不要分區/快取」,不是精算。

★ 書中的範例:估算推特的 QPS 與儲存量

《系統設計面試》第 2 章的練習(書中特別聲明數字僅供練習,不是推特的真實數據):

假設:3 億月活躍使用者、50% 每天使用、平均每人每天發 2 則推文、10% 的推文含媒體、資料保存 5 年。

QPS:

  • 日活躍使用者 = 3 億 × 50% = 1.5 億
  • 推文 QPS = 1.5 億 × 2 ÷ 24 小時 ÷ 3600 秒 ≈ 3,500
  • 尖峰 QPS = 2 × QPS ≈ 7,000

媒體儲存(媒體平均 1 MB):

  • 每天 = 1.5 億 × 2 × 10% × 1 MB = 30 TB/天
  • 5 年 = 30 TB × 365 × 5 ≈ 55 PB

書給的技巧:四捨五入近似(99987 ÷ 9.1 → 100,000 ÷ 10)、寫下假設、標上單位(「5」是 5 KB 還是 5 MB?)。書強調估算重在過程,不在結果。

★ 心算捷徑

捷徑數值用法
一天的秒數86,400 ≈ 10⁵每天 X 次 → QPS ≈ X ÷ 10⁵。1 億次/天 ≈ 1,000 QPS
一個月的秒數≈ 2.5 × 10⁶
一年的秒數≈ 3 × 10⁷
2¹⁰≈ 10³(1 KB)2³⁰ ≈ 10⁹(1 GB),2⁴⁰ ≈ 10¹²(1 TB)

★ 可用性的「幾個 9」:自己算得出來

一年 = 365 × 24 = 8,760 小時;一個月 ≈ 730 小時。

可用性每年停機每月停機
99%87.6 小時(約 3.65 天)約 7.3 小時
99.9%約 8.76 小時約 43.8 分鐘
99.99%約 52.6 分鐘約 4.4 分鐘
99.999%約 5.26 分鐘約 26 秒

推論:99.99% 代表每月只有 4 分鐘的預算——任何需要人工介入的故障都會超標(光是收到告警、登入、看儀表板就不只 4 分鐘)。所以四個 9 以上必須靠自動故障轉移。

串聯的服務可用性相乘:A(99.9%)呼叫 B(99.9%)→ 整體最多 99.8%。十個 99.9% 的依賴串起來 ≈ 99.0%。

★ 延遲數字的相對量級

書引用 Jeff Dean 2010 年公布的數字,並提醒有些已經過時,但相對快慢仍有參考價值。書整理出的結論:

  • 記憶體很快,磁碟很慢。
  • 盡量避免磁碟尋道。
  • 簡單的壓縮演算法很快。
  • 可能的話,資料在網路傳輸前先壓縮。
  • 資料中心通常在不同地區,跨地區傳資料需要時間。

★ 估算完之後的用途:換算成架構決策

估算結果架構推論
寫入 QPS ≈ 7,000 尖峰單一 PostgreSQL 主機可能撐得住,但要實測;再大一個量級就要考慮分片
30 TB/天的媒體不可能放資料庫 → 物件儲存 + CDN
55 PB/5 年,再乘上副本數(例如 3 份)儲存成本是主要的費用項目 → 考慮冷熱分層
讀取量是寫入的 100 倍快取優先;熱門內容 20% 的資料通常承擔大部分讀取,快取大小可以用這個比例估

最常被忘記的三個乘數:副本數(通常 ×3)、尖峰係數(×2 到 ×10)、成長率(明年的量)。

🧪 我實際套用的紀錄

  • 2026-07-15:(待填)

⚠️ 注意

  • 估算是為了「選對量級的架構」;別在面試/設計初期糾結精確數字。

🔗 相關工具

🧩 設計積木 反覆出現、可重用

🎯 什麼情境該想到我

當你要把資料/請求分散到多台節點(快取、分片 DB),又希望「增減節點時不要大搬風」時。

⚙️ 怎麼用

  1. 問題:用「hash(key) % N」分配,一旦 N 改變(加/減機器),幾乎所有 key 都要重新對應 → 快取全失效、大量資料搬移。
  2. 一致性雜湊:把節點與 key 都雜湊到一個環(0 ~ 2³²)上;key 順時針找到第一個節點。
  3. 增減節點時,只影響環上相鄰的一小段 key,其餘不動 → 資料搬移量最小化。
  4. 虛擬節點(vnodes):每個實體節點在環上放多個虛擬點,讓分佈更均勻、避免熱點。

★ 先算一下「為什麼不能用 % N」

1000 萬個 key,10 台機器 → hash(key) % 10
加到 11 台            → hash(key) % 11

★ 有多少 key 的歸屬沒變? 約 1/11 ≈ 9%
→ ★ 91% 的 key 要搬家 / 快取全部失效

一致性雜湊:

★ 只有「新節點接手的那一段」會變 → 約 1/N = 9% 的 key 搬家

→ 從 91% 降到 9%,這就是它唯一但巨大的價值。

★ 虛擬節點:不是優化,是必需品

❌ 沒有虛擬節點:3 個節點隨機落在環上
   ┌──────────────A──B────────────────────C──┐
   ★ A→B 之間很短,B→C 之間超長
   → ★ B 只拿到 5% 的資料,C 拿到 60%   ← 嚴重不均

✅ 每個實體節點放 100~200 個虛擬節點
   A B C A C B A B C B A C ...(散佈整個環)
   → ★ 大數法則讓每個實體節點接近 1/N

虛擬節點還解決第二個問題:★ 節點掛掉時的負載轉移

沒有虛擬節點:A 掛了 → ★ A 的「全部」負載壓到「下一個」節點 B → B 也被壓垮 → 雪崩
有虛擬節點:  A 掛了 → A 的 200 個虛擬節點各自由不同的實體節點接手 → ★ 負載平均分攤

★ 這一點比「分佈均勻」更重要——它是防止連鎖故障的關鍵。

虛擬節點數的取捨:太少 → 不均;太多 → 環上的節點多、查找與 metadata 大。實務常見 100–256。

★ 實作(環用有序結構 + 二分搜尋)

type Ring struct {
    hashes []uint32          // ★ 排序後的虛擬節點 hash
    nodes  map[uint32]string // hash → 實體節點
    vnodes int
}
 
func (r *Ring) Add(node string) {
    for i := 0; i < r.vnodes; i++ {
        h := crc32.ChecksumIEEE([]byte(node + "#" + strconv.Itoa(i)))
        r.hashes = append(r.hashes, h)
        r.nodes[h] = node
    }
    slices.Sort(r.hashes)                    // ★ 見 [[快速排序]]
}
 
func (r *Ring) Get(key string) string {
    h := crc32.ChecksumIEEE([]byte(key))
    // ★ 二分搜尋找第一個 >= h 的虛擬節點(見 [[二分搜尋]])
    i, _ := slices.BinarySearch(r.hashes, h)
    if i == len(r.hashes) { i = 0 }          // ★ 繞回環的開頭
    return r.nodes[r.hashes[i]]
}

★ 查找是 (V = 虛擬節點總數),不是 ——但常數極小。

★ 兩個更好的替代方案(值得知道)

方案特點
★ Rendezvous hashing(HRW)對每個節點算 hash(key, node),取分數最高的。
✅ 不需要環、不需要虛擬節點、分佈天然均勻、實作極簡
❌ 查找是 (節點少時完全不是問題)
★ 節點數 < 幾百時,它通常是更好的選擇
★ Jump consistent hash(Google)極快、零記憶體、分佈完美
❌ 只支援「在尾端加減節點」,不能任意移除中間的節點
★ 適合分片數固定遞增的場景
固定數量的邏輯分區★ 大型系統的實際做法(見 工具-資料分區):
一開始切 1024 片,只改「片→節點」的對應表。最可控。

★ 實務上的選擇順序:

  1. 固定邏輯分區 + 對應表(可控、可觀測、可手動調整)← 大型系統
  2. Rendezvous hashing(簡單、均勻)← 中小型
  3. 一致性雜湊 + 虛擬節點(經典、生態成熟)

一致性雜湊的名氣大於它今天的適用範圍——知道它,但不要預設就用它。

真實案例

系統用法
memcached 客戶端★ 經典用例(ketama)
Cassandra / DynamoDB一致性雜湊 + 虛擬節點
Envoy / nginx 的 upstream hash讓同一個使用者打到同一台(會話黏著)
CDN 的快取節點選擇Rendezvous 或一致性雜湊

🧪 我實際套用的紀錄

  • 2026-07-15:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 一致性雜湊解決的是「搬遷量」,不是「均勻度」。均勻度靠虛擬節點——沒有虛擬節點的一致性雜湊幾乎不能用。
  • ★ 它處理不了「熱 key」。一個爆紅的 key 永遠落在同一個節點上,虛擬節點也救不了。
    → 熱 key 要靠複製到多個節點或本地快取(見 工具-資料分區 的名人問題)。
  • ★ 加節點時要「預熱」。新節點接手的那段 key 一開始全是 cache miss → 打到後端造成尖峰。
    漸進式接手或預載入。
  • 節點暫時失聯時不要立刻重新分配。網路抖動觸發搬遷 → 更多負載 → 更多失聯 → 雪崩。
    要有延遲與人工閘門(見 工具-故障偵測)。
  • 雜湊函式要快且分佈好。用 xxHash / MurmurHash,不要用加密雜湊(SHA-256 太慢)。
  • ★ 快取層用它就好,有狀態資料要更小心。資料的搬遷牽涉一致性與可用性,
    比「快取 miss 一次」嚴重得多——那時該考慮固定分區 + 明確的再平衡流程。
  • 不同語言的客戶端要用同一套雜湊實作,否則會路由到不同節點(memcached 多語言環境的經典坑)。

🔗 相關工具

🎯 什麼情境該想到我

當你要限制「某使用者/IP/API 在一段時間內最多幾次請求」,保護服務不被流量或濫用打垮時。

⚙️ 怎麼用(選演算法)

  • Token Bucket(權杖桶):以固定速率補充權杖,每次請求耗一個;桶有容量 → 允許突發、最常用。
  • Leaky Bucket(漏桶):請求排隊以固定速率流出 → 平滑輸出、不允許突發。
  • 固定視窗計數:每個時間窗計數,簡單但視窗邊界會爆量(兩窗交界瞬間 2 倍)。
  • 滑動視窗:用滑動時間窗平滑計數,較準但成本略高。

實作要點:分散式下把計數放共享儲存(如 Redis),並回傳 429 Too Many Requests + Retry-After。

★ 四種演算法的數學:一個窗口內最多放行幾個

設限制是「每分鐘 N 個」:

演算法任意 60 秒內最多放行為什麼
固定視窗計數2N在 0:59 送 N 個(第一個窗口)、1:00 再送 N 個(第二個窗口)→ 兩秒內 2N 個
滑動視窗日誌N(精確)記下每個請求的時間戳,每次數最近 60 秒內有幾個;代價是記憶體 O(N) 每個使用者
滑動視窗計數≈ N(近似)見下方公式
權杖桶(容量 b、每秒補 r 個)b + r × 60桶滿時可以一口氣用掉 b 個,之後以速率 r 補充
漏桶(佇列長 q、每秒流出 r 個)輸出最多 r × 60輸出速率固定;突發流量進佇列排隊,佇列滿了才丟棄

滑動視窗計數的公式(《系統設計面試》第 4 章):

目前窗口的請求數 + 上一個窗口的請求數 × 滑動窗口與上一個窗口重疊的比例

書中的例子:限制每分鐘 7 個,上一分鐘有 5 個,這一分鐘有 3 個,新請求在這一分鐘的 30% 位置到達 → 重疊 70% → ,無條件捨去成 6 → 還沒到 7,放行。

它假設上一個窗口的請求平均分布,所以是近似值。書引用 Cloudflare 的實驗:在 4 億個請求中,只有 0.003% 被錯誤放行或錯誤擋下。每個使用者只需要兩個計數器,是記憶體與準確度之間最好的平衡。

★ Go 的單機實作:golang.org/x/time/rate

rate.Limiter 就是一個權杖桶:容量 b、初始為滿、每秒補 r 個,可以安全地被多個 goroutine 同時使用(依 golang.org/x/time v0.16.0 文件,2026-09-25)。三個主要方法在沒有權杖時的行為不同:

方法沒有權杖時用在
Allow()立刻回傳 falseAPI 伺服器:擋掉並回 429
Wait(ctx)阻塞到拿到權杖或 ctx 被取消呼叫外部 API 的客戶端:自我節流
Reserve()回傳一張預約,告訴你要等多久需要算出 Retry-After 時

依使用者限流的中介層(已在本機以 Go 1.26.4 編譯執行):

type perKey struct {
	mu sync.Mutex
	m  map[string]*rate.Limiter
	r  rate.Limit
	b  int
}
 
func (p *perKey) get(k string) *rate.Limiter {
	p.mu.Lock()
	defer p.mu.Unlock()
	l, ok := p.m[k]
	if !ok {
		l = rate.NewLimiter(p.r, p.b)
		p.m[k] = l
	}
	return l
}
 
func (p *perKey) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		res := p.get(r.Header.Get("X-API-Key")).Reserve()
		if d := res.Delay(); d > 0 {
			res.Cancel() // 不打算等,把預約的權杖還回去
			w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds())+1))
			http.Error(w, "too many requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}
 
// 每 100ms 補一個(每秒 10 個)、容量 5
p := &perKey{m: map[string]*rate.Limiter{}, r: rate.Every(100 * time.Millisecond), b: 5}

實測:同一個 key 連續呼叫 20 次 Allow(),放行 5 個(容量);等 300 ms 後再呼叫 20 次,放行 3 個(0.3 秒 × 每秒 10 個)——跟權杖桶的公式一致。

這個範例的缺陷:map 只增不減,每個出現過的 key 都永遠留著 → 記憶體洩漏。正式環境要定期清掉長時間沒用的 limiter,或用有容量上限的 LRU。

★ 分散式:計數放 Redis 時的兩個問題

書中點出的兩個問題:

  1. 競爭條件:「讀計數 → 檢查 +1 是否超過 → 寫回」三步不是原子的。兩個請求同時讀到 3,都寫回 4,實際應該是 5。書提到兩種常見解法:Lua 腳本(Redis 會原子地執行整段腳本)和 Redis 的 sorted set。
  2. 同步問題:多台限流伺服器各自計數 → 使用者換一台就重新計算。書不建議用 sticky session(不易擴展),建議集中式儲存如 Redis。

固定視窗的原子寫法(Redis 官方文件 INCR 指令的「限流器」模式;本機未安裝 Redis,未實際執行):

-- KEYS[1] = "rl:{user}:{minute}",ARGV[1] = 窗口秒數
local c = redis.call('INCR', KEYS[1])
if c == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return c

為什麼要包成腳本:如果 INCR 和 EXPIRE 分兩次送,程式在兩者之間當掉 → 這個 key 永遠不會過期 → 該使用者被永久限流。

★ 回應要讓客戶端知道怎麼做

  • 狀態碼 429 Too Many Requests。
  • 書列出的標頭:X-Ratelimit-Remaining(窗口內還剩幾次)、X-Ratelimit-Limit(每個窗口的上限)、X-Ratelimit-Retry-After(幾秒後可以重試)。HTTP 標準的 Retry-After 標頭也適用於 429。
  • 客戶端收到 429 要指數退避加隨機抖動重試,否則所有客戶端在同一時刻重試,又造成一波尖峰。

🧪 我實際套用的紀錄

  • 2026-07-15:(待填)

⚠️ 注意

  • 分散式多實例要共享狀態,否則每台各自限流=實際上限被放大 N 倍。

  • 限流要放在最便宜的地方:在閘道擋掉的請求,成本遠低於進到應用程式、查過資料庫之後才擋。多層都有限流時,外層放寬、內層收緊(見 工具-透明多級分流)。

  • Redis 掛了怎麼辦要先決定:fail-open(放行,服務可能被打垮)還是 fail-closed(全部擋掉,服務等於停擺)。多數 API 選 fail-open 並加上單機的 rate.Limiter 當後備。

🔗 相關工具

其餘積木:唯一 ID 生成 複製與分片 × 訊息佇列

📚 經典案例

限流器 × 一致性雜湊 × 鍵值儲存 唯一 ID × 短網址 × 爬蟲 通知 × 動態訊息 × 聊天 × 搜尋自動補全

💬「好的系統設計,是在明確的約束下   做出合理的取捨。」

Link to original

🧰 這本書給我的工具

通用框架與估算(本書前半亦有)沿用既有卡:工具-系統設計思考框架、工具-容量估算(源自 系統設計面試 卷二,兩卷共用)。

✨ 關鍵重點(Layer 1–2)

  • 從 0 到百萬用戶的擴展路徑:單機 → 分離 DB → 負載均衡 → 快取/CDN → 讀寫分離 → 分片。
  • 反覆出現的積木:一致性雜湊(資料分佈)、限流(保護)、唯一 ID 生成、複製與分片、訊息佇列。
  • 案例導向:每題示範「釐清需求 → 高層設計 → 深入 → 取捨」的完整流程。

💬 金句原文(Layer 0)

  • 「好的系統設計,是在明確的約束下做出合理的取捨。」

🔗 相關