Phase 07 — System Design(Day 61–70)

100 Day Engineer Challenge

Phase 07 — System Design(Day 61–70)

這份逐日教材依循內部規劃文件的範圍與排序展開1

本檔案由兩個任務接力完成:本段(T-031)涵蓋 Day 61–65:12 步固定 Framework,以及 URL Shortener / Rate Limiter / News Feed / Chat 四個題型;Day 66–70(Notification / File Storage / Job Scheduler 三個題型,以及 Phase 收尾模擬)由 T-032 接續寫在本檔案後半段,不另開新檔。

Phase 07 本身沒有新的底層知識點——12 步 Framework 是組織方式,7 個題型各自要練的是「把 Phase 01/02/04/05/06 已經學過的知識點,組裝成一個完整系統的能力」2。因為依賴分散在 5 個更早的 Phase,Day 62–65 每個題型開頭都會先列出「這題會用到哪幾天教過的什麼」,讓讀者不會斷點;Rate Limiter(Day 63)是整個課程第 3 次出現同一個主題(Phase 01 Day 9 概念層級 → Phase 05 Day 49–50 單機並發安全實作 → 今天延伸到多節點),會在開頭明確標註「這次比上次多懂什麼」(第 3.4 節深度遞增表格)。

每天的 DoD 依文件第 23 節「System Design 的特殊驗收」,留下 Requirement/ Capacity / API / Data Model / Architecture / Read Path / Write Path /Bottleneck / Scaling / Consistency / Failure / Trade-off 十二欄紀錄。

Day 61 — System Design 固定 Framework:12 個步驟在問什麼、要交出什麼

學習目標

看完今天內容後,能夠:

  1. 準確說出 12 步 Framework 各自的名稱與順序,並解釋為什麼要固定用這個順序,而不是想到哪裡講到哪裡。
  2. 對任一步驟,能說出「這一步在收斂什麼問題」以及「這一步結束時應該留下什麼具體產出」。
  3. 用一個比 Day 62–65 四個正式題型簡單的暖身題(Pastebin)親自走一遍全部 12 步,留下對應的十二欄紀錄。

教材大綱

為什麼需要固定 Framework

System Design 最大的風險不是「不會設計」,而是「漏想」——在 45–60 分鐘的時間壓力下,很容易一頭栽進自己當下覺得有趣的細節(例如一直摳 API schema 的欄位命名),把 Capacity Estimation、Failure、Trade-off 這些同樣重要、但沒那麼「好玩」的步驟整段跳過。固定 Framework 的價值不在於它多聰明,而在於它保證「不管題目是什麼、不管當下對哪個環節比較有靈感,12 個維度都會被覆蓋到」——這是 Phase 07(Day 61–70)唯一要建立的紀律:把 System Design 從「憑感覺天馬行空」變成「照表操課,但這張表本身涵蓋全面」。

12 步逐一拆解

  1. Requirements:先把「這個系統到底要做什麼」講清楚,分成 Functional(使用者可以做哪些操作,例如「使用者可以提交長網址換短網址」)與 Non-functional(系統品質目標,例如可用性、延遲上限、讀寫比例)。輸出:一份明確的功能清單 + 品質目標清單。這一步存在的理由是題目通常故意講得很模糊(例如只說「設計一個 URL Shortener」),不先自己收斂範圍,後面每一步都會建立在錯誤假設上。
  2. Constraints:明確寫下已知的邊界條件與刻意排除的範圍——例如「假設短網址一旦建立就不能修改長網址」「假設不需要即時分析點擊數,可以是最終一致的離線報表」。輸出:一份「已知限制/刻意排除範圍」清單。價值是讓後面的架構決策有依據可以回頭指——面試官問「為什麼不做 XXX」時,答案往往就是「因為 Constraints 那步已經排除了」。
  3. Capacity Estimation:把 Requirements 換算成具體數字——QPS(每秒請求數)、儲存總量、頻寬。輸出:一組數量級估算結果,用來驅動後面「單機夠不夠」「要不要 shard」這類決策。這一步最容易被跳過,但沒有數字,後面「要不要加 Cache」「要不要做 Replication」全部變成憑空猜測。
  4. API:定義 client 與系統之間的合約——有哪些 endpoint、各自的 request/response 長什麼樣子。輸出:具體的 API 規格(不是「有個 API 可以查資料」這種空話,而是實際的 method/path/schema)。
  5. Data Model:設計要儲存的資料結構——有哪些欄位、彼此的關聯、大概會被拿來查詢的欄位是誰。輸出:一份 schema 草圖,欄位足以支撐 API 的需求。
  6. High-Level Architecture:畫出系統由哪些元件組成(Load Balancer、App Server、Cache、DB、Queue⋯),以及資料在元件之間怎麼流動。輸出:一張(文字描述也可以)架構圖,標出每個元件的角色。
  7. Core Flow:針對關鍵操作(通常是最頻繁的讀跟寫),逐步走過整個請求生命週期——從 client 發出請求開始,經過哪些元件,最後回應什麼。輸出:至少一條 Read Path、一條 Write Path 的完整步驟。
  8. Scaling:當流量超過單機承載能力時,系統怎麼撐住——水平擴展 App Server、加 Cache、Sharding、CDN。輸出:明確的擴展策略,並說明「先加什麼、為什麼先加這個」。
  9. Reliability:故障情境與對策——單一元件掛掉,系統怎麼繼續運作(Replication、Retry、Failover)。輸出:一份「元件 X 掛掉時系統行為是什麼」的對照表。
  10. Consistency:明確選定這個系統需要的一致性模型(Strong /Eventual,Day 52 教過的光譜),並說明為什麼選這個、不選更強或更弱的模型。輸出:一句能清楚陳述的一致性保證(例如「讀取可能落後寫入最多 1 秒」)。
  11. Failure:逐一檢視系統裡的 Single Point of Failure,說明萬一它壞了、使用者會看到什麼、系統怎麼恢復。輸出:一份故障情境 ×使用者影響 × 恢復方式的清單。
  12. Trade-offs:回顧整個設計,講清楚每個關鍵決策放棄了什麼、換到了什麼——收斂全部前面 11 步的總結,也是面試官最常追問「為什麼不這樣做」的地方。輸出:一段能撐住反覆追問的取捨陳述。

12 步之間的因果順序(為什麼不能跳著做)

Requirements 沒收斂,Capacity Estimation 算的數字就沒有意義(不知道要估算誰的流量);Capacity Estimation 沒有具體數字,Scaling/Reliability 的決策就沒有依據(不知道要不要真的上 Cache、上 Replication);Data Model 沒定義好,API 的 Response 內容也定義不出來;Core Flow 沒有走過一遍,Failure 分析容易漏掉某個實際會被打到的元件。12 步的順序不是隨意排列,而是「後面的步驟需要前面步驟的產出當輸入」,這也是今天要求逐步走一遍暖身題的原因——跳步驟做出來的設計,通常會在後面某一步發現前面漏了什麼,被迫回頭補。

熱身練習:Pastebin(12 步全部走一遍)

這裡把 12 步直接套在一個比 Day 62–65 四個正式題型更簡單的題目上,先驗證框架操作起來順不順;正式的深度案例從 Day 62 開始。

  1. Requirements:使用者可以貼上一段文字取得一個短網址;使用者可以用短網址讀回原始文字。非功能:讀多寫少、單則貼文最大 1MB。
  2. Constraints:貼文預設不過期(除非使用者指定 TTL);不需要編輯功能(一旦建立即不可變,這個假設會直接影響 Consistency 那步的判斷)。
  3. Capacity Estimation:假設每天 100 萬則新貼文,平均大小 10KB → 每天新增儲存量約 10GB;讀取量抓寫入的 10 倍(每天 1000 萬次),平均 QPS ≈ 1000 萬 / 86400 ≈ 116,尖峰抓 5 倍 ≈ 580 QPS。
  4. APIPOST /pastes {content, ttl?} → {id, url}GET /pastes/{id} → {content}
  5. Data Modelpastes(id PK, content_ref, created_at,expires_at nullable, status)
  6. High-Level Architecture:LB → App Server(stateless)→Cache(Redis)→ DB(metadata)+ Object Storage(實際內容,大內容不適合塞進關聯式資料庫的單一欄位)。
  7. Core Flow:寫入時 App Server 產生亂數 id、把內容寫進 Object Storage、把 metadata 寫進 DB;讀取時先查 Cache,miss 則查 DB 拿 metadata、再從 Object Storage 讀內容、回填 Cache。
  8. Scaling:App Server 水平擴展(無狀態);讀取靠 Cache 擋掉大部分流量;Object Storage 天生水平擴展(S3 類服務不需要自己處理 sharding)。
  9. Reliability:Cache 掛掉,退化成直接查 DB + Object Storage(延遲上升但不中斷服務);DB 用 Replication 保留讀取可用性。
  10. Consistency:貼文一旦建立即不可變,沒有「更新後多久能讀到新值」的一致性問題,只有「寫入完成前查詢會 404」這個單純情境,選 Eventual 完全不影響正確性。
  11. Failure:若先寫 Object Storage 內容、再寫 DB metadata,DB 寫入失敗會留下孤兒內容(沒有任何 metadata 指向它);反過來若先寫 DB 再寫 Storage,DB 有紀錄但內容遺失,讀取時必須明確回傳「內容遺失」而不是讓使用者以為系統壞掉。
  12. Trade-offs:選擇「先寫 DB(狀態設為 pending)→ 寫 Storage 成功後更新為 ready」而非兩者同時寫,因為孤兒 DB 紀錄比孤兒 Storage 內容容易處理(背景任務清掉 pending 太久的紀錄即可),換來的代價是讀取時必須額外檢查 status = ready

Day 61 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 貼上文字取短網址/用短網址讀回文字,讀多寫少,單則≤1MB Capacity: 每天新增 100 萬則、10GB;尖峰讀取 QPS ≈ 580 API: POST /pastes {content, ttl?};GET /pastes/{id}Data Model: pastes(id, content_ref, created_at, expires_at, status)Architecture: LB → App Server → Cache → DB(metadata) + Object Storage(內容)Read Path: Cache miss → DB 查 metadata → Object Storage 讀內容 → 回填 Cache Write Path: DB 寫入 status=pending → Object Storage 寫內容 → DB 更新 status=ready Bottleneck: Object Storage 大內容的寫入延遲;Cache miss 時的雙重查詢 Scaling: App Server 水平擴展;Cache 擋讀取;Object Storage 原生水平擴展 Consistency: Eventual 足夠(內容不可變,只有 pending→ready 這個單純轉換)Failure: DB/Storage 寫入順序決定孤兒資料清理策略,選「先 DB 後 Storage」Trade-off: 多一次 status 檢查,換取「孤兒資料好清理」而非兩邊都可能孤兒``

  1. 依 00-master-curriculum.md 第 11 章與 00-knowledge-dependency-graph.md 第 2.7 節
  2. 依 00-knowledge-dependency-graph.md 第 3.3 節【扇入依賴】

Day 62 — URL Shortener:ID Generation / Hash / DB / Cache / Read-heavy System

學習目標

看完今天內容後,能夠:

  1. 對 URL Shortener 完整跑一遍 12 步 Framework。
  2. 具體推導兩種短碼產生策略(Base62 自增 ID vs Hash 長網址)各自的碰撞處理成本,並選出理由充分的一個。
  3. 說明「讀多寫少」這個特性如何具體影響 Cache/Index/Architecture 的每一個決策,而不是空泛地說「加個 Cache 就好」。

這題會用到哪幾天教過的什麼

依規劃文件的扇入依賴分析1

  • Phase 02 Day 12 HashMap:短碼唯一性檢查、以及「Hash 長網址」這個產生短碼策略的底層直覺(碰撞的必然性、collision 處理方式)。
  • Phase 04 Day 36 Index:短碼查詢要能在 O(log n) 內完成,靠的正是 B-Tree Index(把短碼欄位建成 Index,而不是每次都線性掃描整張表)。
  • Phase 06 Day 56 Cache:讀取路徑套用 Cache Aside 策略(讀多寫少的典型場景,Day 56 陌生題示範用的正是「商品詳情頁」這種讀多寫少案例,跟 URL Shortener 的讀取特性完全同構)。

12 步 Framework

1. Requirements

Functional——使用者提交長網址取得短網址;使用者造訪短網址被 302/301 redirect 到原始長網址(選 302 而非 301:301 是永久重定向,瀏覽器/CDN 可能快取這個結果、之後不再打回本系統,會讓系統失去統計點擊數與之後更換對應關係的能力;302 每次都會重新打回本系統,符合「短網址服務通常想掌握點擊事件」的實務需求)。Non-functional:讀多寫少(典型比例 read:write ≈ 100:1)、短網址要盡量短、redirect 延遲要低(使用者點擊連結不該有明顯等待)。

2. Constraints

假設短網址一旦建立,長網址不可修改(避免「同一個短網址某天突然指向不同內容」的信任問題);假設不需要即時點擊分析(可以是背景批次統計,不擋在 redirect 的關鍵路徑上)。

3. Capacity Estimation

假設每月新增 1 億則短網址(≈ 每天 333 萬則),redirect 流量抓寫入的 100 倍 → 每月 100 億次、每天約 3.3 億次。平均寫入 QPS ≈ 3,330,000 /86400 ≈ 39;平均讀取 QPS ≈ 330,000,000 / 86400 ≈ 3,856,尖峰抓 3 倍≈ 11,570 QPS——這個數字直接決定第 8 步 Scaling 一定要靠 Cache 擋住多數讀取,單機 DB 撐不住上萬 QPS。儲存:每筆資料(短碼 + 長網址 +metadata)約 500 bytes,每月新增 1 億筆 × 500 bytes ≈ 50GB/月,5 年(60 個月)累積約 3TB。短碼長度:Base62(a-z/A-Z/0-9 共 62 個字元)要覆蓋 5 年總量(1 億 × 60 = 60 億筆)不重複,62^6 ≈ 568 億 已有約 9 倍餘裕;選 7 碼(62^7 ≈ 3.5 兆)換取遠超過 5 年規劃期的成長空間(多撐好幾個數量級的成長),多 1 個字元的儲存成本完全可以忽略,用極低代價買下「規劃期之後也不用煩惱短碼用完」的餘裕。

4. API

POST /urls {long_url} → {short_code, short_url}GET /{short_code} → 302 Location: <long_url>

5. Data Model

urls(short_code PK, long_url, created_at, click_count)short_code 是 Primary Key,資料庫預設就會為 PK 建 B-Tree Index(呼應 Day 36),這正是「短碼查詢能維持 O(log n)」的具體來源,不需要額外設計。

6. High-Level Architecture

LB → App Server(stateless,水平擴展)→ Cache(Redis,short_code → long_url 的 key-value)→ DB(PostgreSQL,urls表);額外一個 ID Generation 服務(下方 Core Flow 展開)。

7. Core Flow

  • Write Path:App Server 收到 long_url,用「Base62 自增 ID」或「Hash 長網址取前 7 碼」兩種策略之一產生 short_code(策略比較見 Trade-offs),寫入 DB,回傳 short_url
  • Read Path:App Server 收到 short_code,先查 Cache(Day 56 Cache Aside:命中直接回傳 long_url 做 302 redirect);miss 則查 DB(走 Day 36 的 B-Tree Index 而非線性掃描),拿到 long_url後寫回 Cache 再回應,讓下次同一個短碼的請求命中 Cache。

8. Scaling

讀多寫少的特性讓 Scaling 的重點幾乎全部落在讀取路徑——Cache 命中率只要達到 90%+(熱門短網址通常符合冪律分布,少數短網址佔掉大部分點擊),DB 實際承受的 QPS 會從上萬降到千級以下,單台 DB(或加一兩台 Read Replica)就能撐住;寫入 QPS 本身只有個位數十,App Server 水平擴展即可應付,DB 寫入不是瓶頸。

9. Reliability

Cache 叢集若整批掛掉重啟,會出現 Day 56 陌生題討論過的 cache stampede(大量請求同時 miss、同時打向 DB)——對 URL Shortener 這種讀取極度集中在少數熱門短網址的場景,用 TTL + jitter 加上「同 key 只讓第一個 miss 請求真的查 DB」的鎖機制(Day 56 已教過)就能緩解;DB 用 Replication 提供讀取備援。

10. Consistency

短網址一旦建立即不可變(Constraints 已排除修改長網址),所以 Cache 與 DB 之間即使短暫不一致(Cache 裡是舊值),也不會真的出現「舊值」——因為值本來就不會變,Eventual Consistency 完全足夠,這跟一般「使用者資料更新後 Cache 要多久才追上」的典型 Cache Invalidation 問題(Day 57)性質不同,是 URL Shortener 這題比多數 CRUD 系統更輕鬆的地方。

11. Failure

ID 產生若採 Base62 自增 ID 依賴單一計數器,這個計數器服務本身是 Single Point of Failure——計數器掛掉,全站無法建立新短網址(但既有短網址仍可正常 redirect,因為讀取路徑不依賴計數器);緩解方式是計數器服務本身做 Replication,或改用「每台 App Server 預先跟計數器要一段 ID range(例如一次領 1000 個),領到範圍內自己配發」,減少對計數器的即時依賴頻率。

12. Trade-offs

Base62 自增 ID——優點是天生不會碰撞(計數器保證唯一),缺點是需要一個集中式計數器(即使做了 range 預先領取,仍是額外元件、也讓短碼可被猜出建立順序,可能洩漏「這個服務目前總共產生過多少短網址」這種業務資訊)。Hash 長網址(例如 MD5 後取前 7 碼)——優點是不需要任何集中式狀態,任何 App Server 都能獨立算出短碼;缺點是不同長網址 hash 後前 7 碼相同的碰撞在數學上必然發生(呼應 Day 12 HashMap 的鴿籠原理),需要在寫入時額外查一次 DB 確認短碼未被占用、碰撞時要有重試邏輯(例如加鹽重新 hash)。今天選 Base62 + range 預先領取:URL Shortener 的寫入 QPS 本身不高(第 3 步估算僅個位數十),額外一次 range 領取的開銷可以接受,換到「零碰撞、不需要寫入前多查一次 DB」的簡單性。

Day 62 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 長網址↔短網址雙向轉換,讀多寫少(100:1),redirect 低延遲 Capacity: 寫入 QPS≈39、讀取尖峰 QPS≈11,570;5 年儲存量≈3TB;短碼 7 碼 API: POST /urls {long_url};GET /{short_code} → 302 redirect Data Model: urls(short_code PK, long_url, created_at, click_count)Architecture: LB → App Server → Cache(Redis) → DB(PostgreSQL) + ID 產生服務 Read Path: Cache 命中直接回傳;miss 查 DB(B-Tree Index) → 回填 Cache Write Path: 領取 ID range → Base62 編碼產生 short_code → 寫入 DB Bottleneck: 讀取 QPS 破萬,靠 Cache 命中率 90%+ 把打到 DB 的量壓到千級以下 Scaling: App Server 水平擴展;Cache 擋讀取;DB 加 Read Replica 分散剩餘讀取 Consistency: Eventual 足夠——短網址一旦建立即不可變,不存在「舊值」問題 Failure: ID 計數器是 SPOF,靠 range 預先領取降低即時依賴頻率 + 計數器本身做 Replication Trade-off: Base62+計數器換零碰撞,代價是需要集中式狀態;已排除 Hash 方案的碰撞重試成本``

  1. 依 00-knowledge-dependency-graph.md 第 3.3 節

Day 63 — Rate Limiter(第 3 次出現:多節點共享限流狀態):Algorithm / Redis / Distributed State / Atomicity

學習目標

看完今天內容後,能夠:

  1. 說出 Rate Limiter 這是課程第 3 次出現,跟 Phase 01 Day 9(概念層級)、Phase 05 Day 49–50(單機並發安全實作)比,今天多懂什麼。
  2. 具體推導「每台 App Server 各自維護獨立限流計數器」在多實例部署下會產生什麼實際後果,並用數字說明。
  3. 設計一個用 Redis 集中維護限流狀態的方案,並解釋為什麼需要 Atomicity 保證(而不是簡單地 GET 再 SET)。

這是第幾次出現、這次多懂什麼

Rate Limiting 在整個課程第 3 次出現:Phase 01 Day 9(Reliability 層級)只教了「為什麼需要限流、超過限制要回 429 + Retry-After」的概念層級;Phase 05 Day 49–50 把單機版做對、做並發安全(用 Atomic 保護一個 int64 token 計數器)。今天要處理的是 Day 49 教材本身明確留白的問題:「當服務水平擴展成多個實例,各自維護獨立的單機限流器會導致總限流量 = 單機限制 × 實例數,而非預期的全局限制」——今天要解決「多個節點怎麼共享同一份限流狀態」。

這題會用到哪幾天教過的什麼

  • Phase 05 Day 49–50:單機 Token Bucket 演算法本身(今天沿用同一套演算法邏輯,只是把「儲存 token 數的地方」從單一 process 的記憶體變數,換成一個所有節點都能存取的集中式 store)。
  • Phase 06 Day 52 Consistency:多節點共享狀態涉及一致性取捨——今天需要決定「限流計數要多精確」跟「檢查限流狀態要付出多少延遲」之間的取捨。

12 步 Framework

1. Requirements

對每個 API Key(或每個使用者),限制「每分鐘最多 N 次請求」,超過回 429。Non-functional:檢查限流狀態本身要夠快(不能讓限流檢查自己變成整條請求路徑的瓶頸),且限流要在多台 App Server 之間共享同一份狀態(不能因為請求剛好被路由到不同機器就繞過限制)。

2. Constraints

假設限流粒度是「每個使用者」而非「每個 IP」;允許限流計數在極端情況下有小幅誤差(略多放行幾次),但不允許誤差達到「總放行量遠超設定值」的程度(呼應下方第 3 步的具體反例)。

3. Capacity Estimation

假設一個 API Gateway 後面有 50 台 App Server 實例,限流設定為「每個使用者每分鐘 1,000 次」。若每台實例各自獨立維護計數器(Day 49 的單機版直接搬過來、完全不共享狀態),同一個使用者的請求被 LB 平均分散到 50 台實例,每台實例各自允許 1,000 次,理論上這個使用者實際能通過的總請求數高達 1,000 × 50 = 50,000 次/分鐘——是設定值的 50 倍,限流規則名存實亡。這個數字直接說明:分散式限流不能只是把單機版複製 50 份,必須有一個集中的地方記錄「這個使用者本分鐘已經用掉幾次」。

4. API

不對外新增 API,而是在既有 API 前面插入限流檢查——對外的行為變化是超過限制時回 429 Too Many Requests + Retry-After header(沿用 Phase 01 Day 9 教過的具體回應格式)。

5. Data Model

Redis 裡每個使用者一個 key,例如 ratelimit:{user_id}:{window}window 是目前所屬的時間窗口編號,例如 Unix 時間戳除以 60 取整),value 是這個窗口內已使用的次數,並設定 TTL(略長於窗口長度,避免 key 永久殘留)。

6. High-Level Architecture

LB → App Server → 集中式 Redis(所有 App Server 共用同一個 Redis 叢集)→ 後端服務。限流檢查發生在 App Server 收到請求、轉發給後端服務之前。

7. Core Flow

  • Write Path(限流計數更新):請求進來時,App Server 對 Redis 執行一段原子操作(見第 12 步 Trade-offs 展開):把對應使用者當前窗口的計數 +1,並回傳更新後的計數值。
  • Read Path(限流判斷):拿到更新後的計數值,若超過設定上限,App Server 直接回 429,不轉發請求給後端服務(限流檢查本身兼具「讀取當前狀態」與「寫入新狀態」,不是分開的兩次操作——這正是 Atomicity 這個訓練重點要強調的地方)。

8. Scaling

Redis 本身要能撐住「50 台 App Server × 每台高並發」打來的計數更新——用 Redis Cluster 依 user_id 做 sharding(呼應 Phase 06 Day 54 Sharding),把不同使用者的計數分散到不同 Redis 節點,避免所有流量集中打單一 Redis 實例。

9. Reliability

Redis 若暫時不可用,兩種降級策略各有取捨:(a)Fail Open——Redis 打不通時直接放行所有請求,優點是不會因為限流元件掛掉就讓整個 API 全部打不開,缺點是限流保護在這段期間完全失效;(b)Fail Closed——Redis 打不通時直接拒絕所有請求,優點是絕不會超發,缺點是 Redis 一掛,整個系統對外等於全部關閉。多數場景選 Fail Open(限流本來就是「保護系統資源」的手段,手段本身故障不該連帶讓核心功能整個癱瘓),但如果限流保護的是金錢相關資源(例如防止刷券),可能反過來選 Fail Closed。

10. Consistency

跟第 3 步的反例對照,今天選擇的方案(集中式 Redis + 原子操作)能維持「同一個使用者的限流計數在所有節點看到的是同一份最新值」,接近 Strong Consistency;代價是每次請求都多一次到 Redis 的網路往返(相較於 Day 49 純記憶體操作幾乎零延遲),這是「準確的全局限流」與「檢查限流狀態的延遲」之間必須接受的取捨。

11. Failure

如果限流檢查的「讀取計數」與「寫入新計數」是兩個分開的 Redis 指令(先 GET 判斷、通過才 INCR),多個 App Server 同時對同一個使用者發出請求時,可能全部 GET 到「還沒超過上限」的舊值、全部判斷通過、全部接著 INCR,導致實際通過的請求數超過設定上限——這跟 Day 49 陌生題討論過的 Check-Then-Act race pattern 本質完全相同,只是這次的「共享狀態」從單一 process 的 int64 換成 Redis 裡的一個 key,證明同一個 race 問題會在不同層級(單機記憶體、跨節點共享 store)重複出現。

12. Trade-offs

解法是用 Redis 的 INCR(本身是原子操作,讀取舊值、加 1、寫回新值在 Redis 內部是單一不可分割的步驟,不會被其他並發指令插入)取代「先 GET 再判斷再 INCR」;若限流規則更複雜(例如要同時檢查多個時間窗口),改用 Lua Script 讓整段邏輯在 Redis 內部一次執行完成(Redis 對單一 Lua Script 的執行本身也是原子的)——這跟 Day 49 用「先 Add 後回補」取代「先 Load 再 Add」是同一個原則的不同層級實作:把「檢查 + 更新」這個複合操作,收斂成底層儲存系統本身能保證原子性的單一指令,而不是在應用層拆成兩步再自己想辦法加鎖。

Day 63 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 每個使用者每分鐘最多 N 次請求,超過回 429,多節點共享同一份狀態 Capacity: 50 台實例若各自獨立計數,實際放行量可達設定值的 50 倍(50,000 vs 1,000)API: 不新增對外 API,限流檢查插在既有 API 前,超限回 429 + Retry-After Data Model: Redis key ratelimit:{user_id}:{window},value=計數,設 TTL Architecture: LB → App Server → 集中式 Redis(Cluster,依 user_id 分片)→ 後端服務 Read Path: 讀取當前窗口計數值,判斷是否超過上限 Write Path: 對 Redis 執行原子 INCR(或 Lua Script)更新計數,讀寫合一非兩步 Bottleneck: 每次請求多一次到 Redis 的網路往返,延遲換取全局準確性 Scaling: Redis Cluster 依 user_id 做 Sharding,分散計數更新流量 Consistency: 集中式 store + 原子操作,接近 Strong Consistency(單機版只是 Eventual 的近似)Failure: 先 GET 後 INCR 兩步分開會重現 Check-Then-Act race,改用單一原子操作修正 Trade-off: Redis 不可用時 Fail Open(保護手段故障不該癱瘓核心功能)vs Fail Closed(防刷場景)``

Day 64 — News Feed:Fan-out / Cache / Ranking / Read-write Trade-off

學習目標

看完今天內容後,能夠:

  1. 具體推導 Fan-out on Write 與 Fan-out on Read 兩種模型各自的讀寫放大倍率,並用「粉絲數」這個變數說明為什麼單一模型無法通吃所有使用者。
  2. 設計一個 Hybrid(混合)模型處理「多數使用者」與「極端大量粉絲的帳號」兩種情境。
  3. 說明 Ranking(排序)查詢如何依賴 Index,以及為什麼「依熱門程度排序」比「依時間排序」在資料庫層面複雜得多。

這題會用到哪幾天教過的什麼

  • Phase 06 Day 53 Replication:News Feed 是讀取極度密集的場景(每次打開 App 都要讀 Feed),需要 Read Replica 分散讀取壓力。
  • Phase 06 Day 56 Cache:每個使用者的 Feed 結果本身適合被快取(Cache Aside:使用者打開 App 時先查 Cache,miss 才重新組裝 Feed)。
  • Phase 04 Day 36 Index:Feed 排序查詢(依時間或分數排序取前 N 筆)依賴 Composite Index(依 user_id + 排序欄位建立的複合索引)。

12 步 Framework

1. Requirements

使用者發布一則貼文;使用者打開 App 看到自己關注對象的最新貼文,依時間或分數排序。Non-functional:讀取(打開 App 看 Feed)遠比寫入(發一則貼文)頻繁,Feed 載入延遲要低(使用者對「打開 App 要等」的容忍度極低)。

2. Constraints

假設 Feed 內容允許最終一致(剛發的貼文,關注者幾秒內看到即可,不要求毫秒級即時);假設有少數帳號粉絲數極高(百萬等級的「大 V」),這個假設會直接影響第 7 步 Core Flow 的設計(不能只用單一模型)。

3. Capacity Estimation

假設 1,000 萬活躍使用者,平均每人追蹤 200 人、平均每人每天發文 2 則。「平均每人追蹤 200 人」在整個關注關係圖裡等於「平均每人也擁有 200 位粉絲」——因為 總關注邊數 = 總粉絲邊數(每一條「A 追蹤 B」的邊,同時是 A 的一次追蹤、也是 B 的一次被追蹤),除以同一個使用者總數,平均追蹤數與平均粉絲數在整個網路的總體統計上必然相等(少數帳號粉絲數遠高於平均值,正是靠其他大量帳號粉絲數低於平均值來平衡)。若採 Fan-out on Write(發文時立刻把貼文推送進所有粉絲的 Feed),一般使用者發一則貼文只需要寫入約 200 位粉絲的 Feed(寫入放大 200 倍,尚可接受);但一個擁有 1,000 萬粉絲的帳號發一則貼文,會觸發 1,000 萬次 Feed 寫入——這正是「單一模型無法通吃」的具體數字證據(一般帳號的寫入放大是 200 倍,極端帳號是 1,000 萬倍,相差 5 萬倍)。

4. API

POST /posts {content} → {post_id}GET /feed?cursor=X&limit=20 → [posts](用 cursor 分頁而非 offset,避免 Feed 隨時間變動導致 offset 分頁重複/漏掉貼文)。

5. Data Model

posts(post_id PK, author_id, content, created_at)follows(follower_id, followee_id);針對 Fan-out on Write 模型還需要 feed_entries(user_id, post_id, score, created_at),並在(user_id, score DESC) 建 Composite Index(呼應 Day 36 Composite Index:查詢時 WHERE user_id = ? ORDER BY score DESC LIMIT 20剛好符合 Composite Index「等值條件在前、排序欄位在後」的最佳使用模式)。依時間排序(ORDER BY created_at DESC)之所以比依熱門程度排序簡單得多,是因為 created_at 一旦寫入就不再變動,Index 建好之後永遠反映正確順序;score(熱門程度)通常是「時間衰減 +按讚數 + 留言數」這類多個特徵加權組合出來的動態值,會隨著其他使用者持續互動而不斷改變——資料庫本身不負責「什麼叫熱門」這個業務邏輯,score 必須由應用層或背景任務依權重公式算好才寫進這個欄位;而且 score 每次變動,(user_id, score) 這個 Composite Index 也要跟著重新維護(呼應 Day 36 Index 段落「每次 INSERT/UPDATE 都要同步維護 B-Tree」),這是「熱門排序在資料庫層面遠比時間排序複雜」的具體原因:不是排序語法比較難寫,而是排序依據本身的計算與 Index 維護成本高得多。

6. High-Level Architecture

LB → App Server → Cache(每個使用者的 Feed 結果)→ DB(Primary 承接寫入 + 多個 Read Replica 承接讀取)+ 背景 Fan-out Worker(透過 Queue 非同步處理推送)。

7. Core Flow

  • Write Path(一般帳號):發文寫入 posts 表後,把「產生 Feed 條目」丟進 Queue(呼應 Phase 06 Day 58 Messaging 的 Producer/Consumer 解耦——發文請求不需要等 Fan-out 全部完成才回應使用者),背景 Worker 消化 Queue、對每個粉絲的feed_entries 各寫入一筆。
  • Write Path(大 V 帳號):不做 Fan-out on Write(1,000 萬次寫入不現實),改成 Fan-out on Read——貼文只寫入 posts 表,粉絲讀取 Feed 時,系統即時查詢「我關注的大 V 帳號有沒有新貼文」並臨時合併進結果(Hybrid 模型的核心)。
  • Read Path:使用者打開 App,先查 Cache(Day 56 Cache Aside);miss 則從 feed_entries 讀出一般帳號已預先 Fan-out 好的條目,再即時查詢該使用者關注的大 V 帳號是否有新貼文(Fan-out on Read 補上的部分),兩者合併排序後回填 Cache。

8. Scaling

讀取靠 Cache 擋掉重複打開 App 的請求;DB 讀取靠 Read Replica 分散(呼應 Day 53 Replication,讀取量遠大於寫入量的場景正是 Read Replica 最典型的適用情境);Fan-out Worker 透過 Queue 可以水平擴展多個 Worker 實例並行消化。

9. Reliability

Fan-out Worker 若處理到一半當機,Queue 沒收到 ack 的訊息會被重新投遞給其他 Worker(呼應 Day 58 Retry 機制),但這代表同一則貼文可能被重複 Fan-out 給同一個粉絲——需要 Idempotency 設計(feed_entries(user_id, post_id) 做唯一約束,重複寫入時用 upsert 而非直接 insert,避免同一則貼文在 Feed 裡出現兩次)。

10. Consistency

Feed 選 Eventual Consistency——一般帳號的貼文透過 Queue 非同步 Fan-out,粉絲不會立即看到(延遲通常是秒級),這個延遲對使用者體驗完全可以接受,換到「發文請求本身不需要等待百上千次寫入全部完成才回應」的低延遲。

11. Failure

Fan-out on Read 那條路徑(處理大 V 帳號)如果在使用者讀取 Feed 的當下即時查詢失敗或逾時,不該讓整個 Feed 載入失敗——應該降級成「先回傳已預先 Fan-out 好的一般帳號內容,大 V 帳號的最新貼文這次先不顯示」,這是「部分結果好過完全失敗」的具體應用(呼應 Phase 06 Day 59 Partial Failure 的設計思路)。

12. Trade-offs

純 Fan-out on Write——讀取極快(Feed 已經預先組裝好,直接讀)但大 V 帳號發文的寫入成本失控;純 Fan-out on Read——寫入永遠 O(1)(只寫一次 posts 表)但每次讀取都要即時合併所有關注對象的貼文,讀取延遲隨關注數增加而變差。今天選 Hybrid:多數帳號(粉絲數低於某個門檻,例如 1 萬)用 Fan-out on Write 換取讀取速度,少數大 V 帳號用 Fan-out on Read 避免寫入放大失控——用「粉絲數門檻」把兩種模型的成本都壓在可接受範圍內,這正是本題「Read/Write Trade-off」訓練重點要具體回答的問題:沒有一種模型是全面最優解,門檻切分才是答案。

Day 64 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 發文 + 讀取關注對象的最新貼文 Feed,讀取遠比寫入頻繁 Capacity: 一般帳號發文寫入放大 200 倍;1000 萬粉絲帳號放大 1000 萬倍,相差 5 萬倍 API: POST /posts {content};GET /feed?cursor=X&limit=20(cursor 分頁)Data Model: posts / follows / feed_entries(user_id, post_id, score),(user_id,score) 建 Composite Index Architecture: LB → App Server → Cache → DB(Primary+Read Replica) + Fan-out Worker(Queue)Read Path: Cache miss → 讀預先 Fan-out 的 feed_entries + 即時查大 V 帳號新貼文 → 合併排序 Write Path: 一般帳號走 Queue 非同步 Fan-out on Write;大 V 帳號只寫 posts 表(Fan-out on Read)Bottleneck: 大 V 帳號發文若走 Fan-out on Write 會造成百萬級寫入放大 Scaling: Cache 擋讀取;Read Replica 分散讀取;Fan-out Worker 靠 Queue 水平擴展 Consistency: Eventual——非同步 Fan-out 延遲通常秒級,換取發文請求低延遲 Failure: Worker 重試造成重複 Fan-out,靠 (user_id,post_id) 唯一約束 + upsert 做 Idempotency Trade-off: 粉絲數門檻切分 Fan-out on Write(多數帳號)與 Fan-out on Read(大 V 帳號)``

Day 65 — Chat:Connection / Ordering / Delivery / Presence

學習目標

看完今天內容後,能夠:

  1. 說明為什麼 Chat 系統需要從 HTTP 的短連線請求-回應模型,換成 WebSocket 這種長連線模型,並具體指出兩者在 Connection 管理上的差異。
  2. 設計一套「使用者被路由到哪台 Chat 伺服器」的連線註冊機制,處理訊息送達(Delivery)與線上狀態(Presence)。
  3. 具體推導訊息 Ordering 在多台伺服器同時處理不同使用者訊息時可能出現的亂序情境,並提出對策。

這題會用到哪幾天教過的什麼

  • Phase 01 Day 2 Connection / Keep-alive:今天要處理的是 Day 2「連線是有狀態的通道,維護連線需要資源」這個概念的延伸——Chat 需要的不是「重複使用短連線」(Keep-alive 解決的問題),而是「整個對話期間維持同一條長連線」(WebSocket),對「同時開多少連線」的資源壓力遠比一般 HTTP 服務更重,因為每個線上使用者都對應一條長期存活的連線。
  • Phase 06 Day 58 Messaging:Ordering / Delivery Semantics /Idempotency 直接沿用——聊天訊息本質上就是一種 Message,Producer 是發送訊息的使用者,Consumer 是接收訊息的使用者,中間可能經過 Queue 做暫存與重試。

12 步 Framework

1. Requirements

使用者之間可以即時傳送文字訊息;訊息要依發送順序送達;使用者能看到對方目前是否在線(Presence)。Non-functional:訊息延遲要低(「即時」的核心訴求);系統要能同時維持大量並發連線。

2. Constraints

假設是一對一聊天(不含群組聊天的額外複雜度);假設訊息需要持久化(使用者重新登入或換裝置要看得到歷史訊息,不是純粹的「連線期間才存在」的暫時通道)。

3. Capacity Estimation

假設 100 萬使用者同時在線,每條 WebSocket 連線在伺服器端佔用一個 file descriptor 與一份連線狀態(呼應 Phase 01 Day 2 Connection 教過的 control block 概念,只是這裡是應用層維護的連線狀態,不只是 TCP 層);假設單台伺服器實務上能穩定維持約 5 萬條並發 WebSocket 連線(受 file descriptor 上限與記憶體限制),需要 1,000,000 / 50,000= 20 台伺服器才能承接全部連線——這個數字直接決定第 6 步 Architecture 需要一個「使用者被分配到哪台伺服器」的路由機制,不能假設任何兩個使用者都連在同一台伺服器上。

4. API

建立連線後改用 WebSocket 訊息格式而非傳統 REST——CONNECT /ws?token=X(升級為 WebSocket);連線建立後透過同一條連線雙向傳送 {type: "message", to, content} /{type: "presence_update", user_id, status}

5. Data Model

messages(message_id PK, from_user, to_user, content, sent_at,delivered_at nullable)connections(user_id, server_id,connected_at) 記錄「這個使用者目前連在哪台 Chat 伺服器」,存在 Redis(需要極快查詢、且連線狀態本身是短生命週期資料,不適合放進主要的關聯式資料庫)。

6. High-Level Architecture

Client → LB(需支援 WebSocket 長連線,不能用一般無狀態 HTTP LB 的假設)→ Chat Server(維護實際 WebSocket 連線)→ Redis(連線註冊表 + Pub/Sub 做跨伺服器訊息轉發)→ DB(訊息持久化)。

7. Core Flow

  • Write Path(發送訊息):使用者 A 透過自己連著的 Chat Server 送出訊息;該 Server 先把訊息寫進 DB(持久化,確保即使對方離線也不遺失),接著查 Redis 的 connections 表找出使用者 B 目前連在哪台 Server;若 B 連在同一台 Server,直接透過該連線推送;若 B 連在別台 Server,透過 Redis Pub/Sub 把訊息轉發給 B 所在的那台 Server,再由那台 Server 推送給 B。
  • Read Path(讀取歷史訊息 / Presence 查詢):使用者重新連線或滑動聊天紀錄時,直接查 DB 的 messages 表(依 (from_user,to_user, sent_at) 建 Index);查詢對方是否在線,直接查 Redis 的 connections 表(有紀錄即代表在線)。

8. Scaling

Chat Server 水平擴展(每台各自承接一部分使用者的連線);Redis 的connections 表與 Pub/Sub 是跨伺服器轉發的關鍵共用元件,需要用 Redis Cluster 承接(呼應 Day 63 Redis Sharding 的思路,只是這裡分片的是連線註冊表而非限流計數);DB 訊息寫入依 to_user(或對話 ID)做 Sharding(呼應 Day 54 Sharding),讓不同使用者的訊息落在不同分片,避免單一分片承擔全部寫入。

9. Reliability

Chat Server 若當機,該伺服器上所有使用者的 WebSocket 連線會全部中斷——這是長連線模型特有的故障模式(跟無狀態 HTTP 服務「掛掉一台,LB 自動轉發到其他台,使用者幾乎無感」完全不同);用戶端需要有斷線自動重連邏輯,重連後對哪台伺服器連線由 LB 重新分配,並更新 Redisconnections 表。

10. Consistency

Presence(線上狀態)容忍短暫不一致——使用者 A 剛好在斷線瞬間,B 可能會多看幾秒鐘「A 顯示在線」才更新成離線(透過心跳超時偵測,而非即時感知斷線),這是 Presence 系統普遍接受的 Eventual Consistency;但訊息本身的持久化寫入(DB)要求較強的保證,不能因為想追求低延遲就跳過落地直接只推送(否則對方離線時這則訊息就永久遺失)。

11. Failure

訊息 Ordering 在多台 Chat Server 各自獨立處理不同使用者的情境下,可能出現亂序——例如使用者 A 快速連續送出兩則訊息,若因為網路延遲或 Server 處理排隊,Redis Pub/Sub 轉發到 B 所在伺服器的順序跟送出順序不一致,B 收到訊息的順序就會跟 A 發送的順序不同(呼應 Day 58 Ordering 教過的「多個獨立路徑無法保證全局順序」問題)。對策:messages 表的 sent_at(或一個嚴格遞增的 sequence number)作為權威順序依據,Client 端收到訊息後依這個欄位重新排序顯示,而不是依「網路上收到的先後順序」直接顯示——這把「保證順序」的責任從「傳輸路徑」移到「訊息本身帶有可排序的依據 + Client 端重新排序」,不需要強迫整條轉發路徑本身做到嚴格有序(成本高很多)。

12. Trade-offs

訊息推送前是否等待 DB 寫入完成——選「先寫 DB 再推送」而非「先推送再非同步寫 DB」,犧牲一點點延遲(多等一次 DB 寫入確認),換到「即使推送失敗(對方剛好斷線),訊息也已經持久化,重新連線後可以從歷史紀錄補齊」,這跟 Day 62 URL Shortener 選擇的「先寫 DB 再寫 Storage」是同一種「持久化優先於即時性」的設計原則,只是這次的場景是即時通訊而非內容儲存。

Day 65 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 一對一即時訊息傳送,依發送順序送達,可查詢對方線上狀態 Capacity: 100 萬併發連線,單台伺服器抓 5 萬連線上限,需 20 台 Chat Server API: CONNECT /ws?token=X 升級為 WebSocket;{type:"message",to,content}Data Model: messages(message_id,from_user,to_user,content,sent_at) + Redis connections(user_id,server_id)Architecture: Client → 支援 WS 的 LB → Chat Server → Redis(連線註冊+Pub/Sub) → DB Read Path: 歷史訊息查 DB(Index on from/to/sent_at);線上狀態查 Redis connections 表 Write Path: 先寫 DB 持久化 → 查 Redis 找對方所在伺服器 → 同機直推或跨機 Pub/Sub 轉發 Bottleneck: 長連線模型下單一 Chat Server 當機會讓其上所有使用者連線全部中斷 Scaling: Chat Server 水平擴展;Redis Cluster 承接連線註冊表;DB 依使用者/對話做 Sharding Consistency: Presence 用心跳超時判斷,容忍數秒延遲;訊息持久化要求較強保證,不可跳過落地 Failure: 多伺服器轉發路徑可能造成訊息亂序,靠 sent_at/sequence number + Client 端重排解決 Trade-off: 先寫 DB 再推送(犧牲少量延遲換取推送失敗也不遺失訊息),優先持久化而非即時性``

Day 66 — Notification System:Queue / Retry / Worker / Idempotency

學習目標

看完今天內容後,能夠:

  1. 完整跑一遍 Notification System 的 12 步 Framework,把 Phase 06 Day 60 用七問格式討論過的同一個系統,升級成正式 12 步規格。
  2. 具體推導「Worker 處理到一半 crash」為什麼會自然導致 at-least-once 而非 exactly-once,並設計對應的 Idempotency 機制。
  3. 說明多通道(Email/Push/SMS)通知如何透過同一套 Queue + Worker 架構分流處理,而不是各自獨立一套系統。

這是第幾次出現、這次多懂什麼

Notification System 不是第一次出現——Phase 06 Day 60(Distributed Systems 收尾驗收)已經用同一個題目當作「Design:Notification System」的驗收情境,但當時只要求用七問格式(duplicate/retry/ordering/failure/idempotency/scaling/observability)討論,沒有走過完整 12 步。今天要做的是把同一個系統重新套進正式 Framework,多出的是 Capacity Estimation 具體數字、API/Data Model 具體規格、Architecture 圖、Read/Write Path 明確拆分——這些在 Day 60 的七問格式裡沒有要求,這次不能只重複 Day 60 已經想過的部分,要真正補齊。

這題會用到哪幾天教過的什麼

  • Phase 06 Day 58 Messaging:Producer/Consumer/Queue/Retry/DLQ/Delivery semantics 是這題的核心骨架——通知服務本質上就是一個訊息系統,觸發通知的服務是 Producer,Worker 是 Consumer。
  • Phase 06 Day 60:Idempotency 在分散式重試情境下的深化定義(idempotency key + 去重存儲)直接套用在這題。
  • Phase 01 Day 8 Reliability:Retry/Backoff 的基本形狀(本題延伸到「呼叫外部 Email/Push/SMS 供應商失敗時」的重試)。

12 步 Framework

1. Requirements

Functional——其他內部服務可以觸發一則通知(例如「訂單已出貨」「有人按讚你的貼文」),系統負責透過使用者偏好的管道(Email/Push/SMS,可能不只一種)送達。Non-functional:觸發通知的呼叫方不應該被通知的實際發送速度拖慢(呼叫方只關心「有沒有成功交給通知系統」,不關心「Email 供應商多久才真的送出」);同一個事件不應該讓使用者收到重複通知。

2. Constraints

假設通知內容(文案、多語系版本)由呼叫方準備好、以純文字/模板 ID 傳入,本系統不負責文案生成邏輯;假設外部 Email/Push/SMS 供應商本身是黑盒 API,偶爾會逾時或回錯,系統無法控制它們的可用性,只能設計自己這端的重試與降級。

3. Capacity Estimation

假設平台每天觸發 5,000 萬個內部事件(訂單狀態變更、社群互動等),假設平均每個事件觸發 1.5 則通知(部分事件會同時觸發 Email + Push)→ 每天約 7,500 萬則通知;平均寫入 QPS ≈ 75,000,000 / 86400 ≈ 868,尖峰抓 5 倍 ≈ 4,340 QPS。外部供應商 API 通常有自己的速率限制(例如某 Email 供應商限制 100 QPS/帳號),這代表系統不能假設「收到請求就能立刻送出」,Worker 端必須有自己的節流與排隊能力,呼應第 8 步 Scaling。

4. API

POST /notifications {user_id, event_type, channel[], template_id,payload, idempotency_key} → {notification_id, status: accepted}——呼叫方拿到 accepted 只代表「系統已經接手」,不代表已經送達。

5. Data Model

notifications(notification_id PK, user_id, channel, template_id,payload, idempotency_key UNIQUE, status, attempts, created_at,sent_at nullable)idempotency_key 建 UNIQUE 約束——這是本題「避免重複通知」的資料庫層保證,呼叫方若因為自己重試而送出同一個idempotency_key 兩次,第二次 INSERT 會因為違反唯一約束而失敗,系統據此判斷「這則通知已經處理過」。

6. High-Level Architecture

觸發通知的內部服務(Producer)→ Queue(依 channel 分開,例如notifications.email / notifications.push / notifications.sms三條 Queue)→ 各自的 Worker Pool → 對應的外部供應商 API → DB(狀態追蹤)。

7. Core Flow

  • Write Path:Producer 呼叫 POST /notifications,App Server 先用 idempotency_key 做一次 INSERT(唯一約束擋重複),成功後把訊息依 channel 丟進對應 Queue,回應 accepted
  • Read Path(Worker 消化):Worker 從 Queue 取出訊息、呼叫對應供應商 API 送出,成功則更新 status=sent, sent_at=now(),失敗則依第 9 步的重試策略處理。

8. Scaling

每個 channel 各自的 Worker Pool 可以獨立水平擴展(Email 流量大就多開 Email Worker,不影響 Push/SMS);Worker 端要對供應商的速率限制做節流——例如維護一個本地或 Redis 共享的 token bucket(呼應 Day 49–50 與 Day 63 教過的限流機制,這裡是「系統主動限制自己打出去的速率」而非「限制別人打進來」,是同一套演算法反過來用)。

9. Reliability

供應商 API 逾時或回 5xx 時,Worker 依 Exponential Backoff 重試(呼應 Day 8);重試達上限(例如 5 次)仍失敗,訊息移入 DLQ(呼應 Day 58),並把 notifications.status 標記為 failed,讓維運人員或後續批次可以追蹤處理不了的通知,而不是讓它在 Queue 裡無限重試拖垮整體吞吐量。

10. Consistency

Eventual——通知的「已送達」狀態本身允許延遲(使用者不需要在觸發事件的瞬間立刻收到通知,秒級到分鐘級延遲通常可接受),這正是選擇非同步 Queue + Worker 而非同步呼叫供應商 API 的理由。

11. Failure

Worker 從 Queue 取出訊息、呼叫供應商 API 成功送出,但在把 status更新為 sent 之前當機——訊息若走 at-least-once 語意的 Queue(未收到 ack 就會被重新投遞),會被另一個 Worker 重新取出並「再送一次」,導致使用者收到重複通知(供應商已經真的送過一次,DB 卻不知道)。這正是 at-least-once delivery 的必然代價,不是實作疏漏。

12. Trade-offs

對策是在呼叫供應商 API 之前,先用 idempotency_keynotification_id 查一次 status——若已經是 sent 就直接跳過、不重複呼叫供應商(用一次資料庫查詢的成本,換掉「使用者收到重複通知」的體驗成本);但這個檢查跟「呼叫供應商 API」之間仍有極短的競態窗口(Worker A 查完 status 還沒送出,Worker B 也查到 status 尚未送出,兩者都送了)——完全消除這個窗口需要更重的分散式鎖機制(見 Day 68 Job Scheduler 的 Distributed Locking),但對通知系統而言,接受「極低機率的重複通知」比為了徹底消除它引入額外的鎖競爭與複雜度更划算,這是本題明確選擇 at-least-once + best-effort dedup,而非追求 exactly-once 的理由。

Day 66 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 內部事件觸發多通道通知,呼叫方不被實際送達速度拖慢,避免重複通知 Capacity: 每天 7500 萬則通知,平均 QPS≈868,尖峰≈4,340;供應商自身有速率限制 API: POST /notifications {user_id,channel[],template_id,idempotency_key}Data Model: notifications(idempotency_key UNIQUE, status, attempts, sent_at)Architecture: Producer → Queue(依channel分流) → Worker Pool → 供應商API → DB Read Path: Worker 消化 Queue,送出前先查 status 是否已 sent 避免重複呼叫供應商 Write Path: idempotency_key 唯一約束擋重複 INSERT → 依 channel 丟進對應 Queue Bottleneck: 外部供應商 API 自身速率限制,Worker 需自建 token bucket 節流 Scaling: 各 channel Worker Pool 獨立水平擴展,互不影響 Consistency: Eventual——送達狀態允許秒級到分鐘級延遲 Failure: Worker 送出成功但更新 status 前當機,會被重新投遞造成重複發送(at-least-once 必然代價)Trade-off: 送出前查 status 做 best-effort dedup,接受極低機率重複,換掉分散式鎖的複雜度``

Day 67 — File Storage:Object Storage / Metadata / Upload / Large Files

學習目標

看完今天內容後,能夠:

  1. 完整跑一遍 File Storage 系統的 12 步 Framework。
  2. 具體說明為什麼大檔案上傳需要 Multipart/Chunked Upload,而不是用單一 HTTP Request 把整個檔案傳完。
  3. 設計 Metadata 與實際檔案內容分離儲存的架構,並解釋為什麼不能把檔案內容直接塞進關聯式資料庫的欄位。

這題會用到哪幾天教過的什麼

  • Phase 01 Day 8 Reliability:Timeout/Retry——大檔案上傳耗時長,一般 HTTP Request 的 timeout 假設(幾秒到幾十秒)對大檔案不成立,需要專門設計。
  • Phase 04 Day 31 Page/Buffer:資料庫本身以 Page 為單位讀寫(通常 8KB),把數百 MB 的檔案內容直接塞進一個欄位,會讓每次讀寫這一列都要搬動大量 Page,這是本題不把檔案內容存進關聯式資料庫的底層原因。

12 步 Framework

1. Requirements

Functional——使用者可以上傳檔案(可能達數 GB),之後可以下載/分享該檔案。Non-functional:上傳/下載大檔案要能在不穩定網路下可靠完成(不能因為傳到一半斷線就整個重來);儲存要能無限水平擴展(使用者持續上傳,總儲存量沒有上限)。

2. Constraints

假設檔案一旦上傳完成即不可修改(要更新內容需要上傳一個新檔案、取得新的檔案 ID,這個假設直接簡化 Consistency 那步的討論,呼應 Day 61 Pastebin 熱身題同樣的簡化技巧);假設存取權限控管(誰能下載這個檔案)由外部 Auth 系統負責,本題只處理儲存與傳輸本身。

3. Capacity Estimation

假設平台每天新增 100 萬個檔案,平均檔案大小 5MB(含少量數 GB 的大檔案拉高平均值)→ 每天新增儲存量約 5TB,5 年約 9PB——這個量級直接排除「自建儲存叢集手動管理磁碟」的做法,必須用天生支援水平擴展的 Object Storage(例如 S3 相容服務)。上傳 QPS 抓每天 100 萬次(集中在部分時段),尖峰約每秒數十到數百次「建立一次上傳」的請求(不含實際傳輸位元組數)。

4. API

POST /files/initiate {filename, size, content_type} → {file_id,upload_url(s)}(發起上傳,取得後續上傳用的目標位置);PUT <upload_url> <chunk bytes>(實際上傳資料,見 Core Flow 展開為何拆成多個 chunk);POST /files/{file_id}/complete(通知系統所有分塊都上傳完成);GET /files/{file_id} → 302 導向實際檔案 URL。

5. Data Model

files(file_id PK, owner_id, filename, size, content_type,storage_key, status, created_at)——storage_key 是這個檔案在 Object Storage 裡的實際路徑/key,metadata(檔名、擁有者、狀態)存在一般的關聯式資料庫,檔案內容本身完全不經過這個資料庫。

6. High-Level Architecture

Client → App Server(只處理 metadata 與簽發上傳授權,不經手實際檔案位元組)→ Object Storage(直接承接檔案內容的上傳/下載流量)→DB(metadata)。App Server 與檔案傳輸路徑分離是這題架構的核心:檔案的實際位元組不流經應用伺服器,避免應用層變成大檔案傳輸的瓶頸。

7. Core Flow

  • Write Path(大檔案上傳):Client 呼叫 initiate 拿到file_id 與一組(或多個)上傳目標;大於某個門檻(例如 100MB)的檔案,App Server 要求 Client 把檔案切成固定大小的區塊(例如每塊 10MB),每個區塊各自用一個 PUT 請求直接上傳到 Object Storage(不經過 App Server),全部區塊上傳完成後呼叫 complete,App Server 通知 Object Storage 把這些區塊合併成最終檔案、把files.status 更新為 ready
  • Read Path(下載):Client 呼叫 GET /files/{file_id},App Server 查 DB 取得 storage_key,直接回傳一個 302 導向 Object Storage 的短期簽章 URL(而非自己代理傳輸整個檔案內容),讓實際下載流量直接發生在 Client 與 Object Storage 之間。

8. Scaling

Object Storage 本身天生水平擴展(呼應 Day 62 URL Shortener 提過的同一個特性,S3 類服務不需要自己處理 sharding);App Server 因為不經手實際檔案位元組,只處理 metadata 請求,負載遠低於檔案傳輸量本身,水平擴展壓力小很多。

9. Reliability

大檔案上傳若整個過程用單一 HTTP Request 傳完,只要傳到 99% 時網路中斷,前面傳輸的位元組全部作廢、必須整個重傳——這對數 GB 的檔案在不穩定網路下幾乎不可能成功。改成 Multipart Upload(第 7 步已展開)後,單一區塊上傳失敗只需要重傳那一個區塊(例如 10MB),其餘已成功的區塊不受影響,這正是 Chunked/Multipart Upload 存在的核心理由——把一次「要嘛全部成功要嘛全部重來」的長時間操作,拆成多個「各自獨立成功/失敗」的短時間操作。

10. Consistency

Eventual 對 metadata 已經足夠(files.statusuploadingready 有些微延遲不影響正確性);但 complete 呼叫本身要能可靠地把所有區塊正確合併——若漏掉某個區塊就標記 ready,下載時會拿到損毀的檔案,這一步需要在 Object Storage 端有校驗機制(例如比對所有區塊的數量與各自的 checksum)。

11. Failure

Client 上傳到一半(例如已完成 6/10 個區塊)之後徹底離線、再也不會回來完成上傳——這會在 Object Storage 留下 6 個孤兒區塊、DB 裡留下一筆永遠停在 status=uploading 的紀錄,兩者都是資源洩漏。對策是背景清理任務定期掃描 status=uploadingcreated_at 超過某個時限(例如 24 小時)的紀錄,刪除對應的孤兒區塊並把 DB 紀錄標記為abandoned——這跟 Day 61 Pastebin 熱身題「用 status 欄位 + 背景清理處理孤兒資料」是同一個設計原則的重複應用。

12. Trade-offs

App Server 是否要代理所有上傳/下載流量(Client → App Server →Object Storage)而非讓 Client 直接對 Object Storage 上傳/下載——代理模式的優點是 App Server 可以在傳輸過程中做額外檢查(例如即時掃毒、內容審核);缺點是應用伺服器要承擔全部檔案傳輸的頻寬與運算成本,違背「讓天生擅長處理大流量傳輸的 Object Storage 直接面對 Client」的設計初衷。今天選擇「App Server 只簽發短期授權 URL、實際傳輸完全繞過 App Server」,把額外檢查(如果需要)放在上傳完成後的非同步後處理階段,而不是擋在傳輸路徑上。

Day 67 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 上傳/下載可能達數 GB 的檔案,網路不穩定下仍可靠完成,儲存無限水平擴展 Capacity: 每天新增 100 萬檔案、5TB,5 年約 9PB;上傳尖峰每秒數十到數百次請求 API: POST /files/initiate;PUT <upload_url> <chunk>;POST /files/{id}/complete;GET /files/{id}Data Model: files(file_id, owner_id, storage_key, status, size),內容不進關聯式資料庫 Architecture: Client → App Server(僅metadata) → Object Storage(實際傳輸) → DB(metadata)Read Path: 查 DB 取 storage_key → 302 導向 Object Storage 短期簽章 URL Write Path: initiate 取得上傳目標 → 各區塊直傳 Object Storage → complete 合併並轉 ready Bottleneck: 單一 HTTP Request 傳完大檔案在不穩定網路下幾乎不可能成功 Scaling: Object Storage 原生水平擴展;App Server 只處理 metadata,負載遠低於傳輸量 Consistency: metadata 用 Eventual;complete 合併需校驗區塊數量與 checksum 避免損毀檔案 Failure: 上傳中途永久離線留下孤兒區塊與 status=uploading 紀錄,靠背景清理任務回收 Trade-off: 選擇繞過 App Server 直傳 Object Storage,換掉「傳輸路徑上做額外檢查」的能力``

Day 68 — Job Scheduler:Queue / Worker / Scheduling / Retry / Distributed Locking

學習目標

看完今天內容後,能夠:

  1. 完整跑一遍 Job Scheduler 的 12 步 Framework。
  2. 具體推導「多個節點同時搶同一個排程任務」會造成什麼後果,並設計 Distributed Locking 機制避免同一個任務被重複執行。
  3. 比較用 PostgreSQL Advisory Lock 與用 Redis 分散式鎖各自的取捨。

這題會用到哪幾天教過的什麼

  • Phase 06 Day 58 Messaging:Queue/Worker 的基本骨架(本題的「排程」本質上是「在特定時間點才把任務放進 Queue」,執行機制跟一般 Queue+Worker 相同)。
  • Phase 05 Day 49–50 Concurrency 的 Mutex/lock 概念(單機層級的互斥)——今天要把它延伸到跨節點:多台 Worker 伺服器要怎麼確保「同一個排程任務在同一個時間點只被一台實際執行」。
  • Phase 04 Day 36–40 Transactions/Lock 的 Advisory Lock(資料庫層級可以直接拿來當分散式鎖使用的機制)。

12 步 Framework

1. Requirements

Functional——系統可以註冊「在特定時間點/固定週期執行某個任務」(例如「每天凌晨 2 點跑一次帳務結算」);到了指定時間,任務會被實際執行且只執行一次,不會因為有多台 Worker 而被重複執行。Non-functional:即使某台 Worker 當機,排定的任務仍要被其他 Worker 接手執行,不能因為單台故障就整批任務漏跑。

2. Constraints

假設任務本身的執行邏輯(實際要做什麼)由呼叫方以程式碼形式部署好,本系統只負責「在正確時間觸發、確保不重複、失敗時重試」,不負責任務內部邏輯是否正確;假設排程精度到分鐘級即可(不要求毫秒級觸發時間)。

3. Capacity Estimation

假設系統維護 10 萬筆排程任務定義,多數是每天或每小時執行一次的週期任務,平均每分鐘需要檢查並觸發約 100,000 / (24*60) ≈ 70 個到期任務(假設任務時間平均分佈),尖峰時段(例如整點)可能瞬間有數千個任務同時到期——這代表「檢查哪些任務到期」本身也要能承受尖峰負載,不能用簡單的全表掃描。

4. API

POST /schedules {cron_expr, job_type, payload} → {schedule_id}(註冊一個排程);GET /schedules/{schedule_id}/runs(查詢這個排程過去的執行紀錄,用於除錯與稽核)。

5. Data Model

schedules(schedule_id PK, cron_expr, job_type, payload,next_run_at, status)——next_run_at 是這個排程下一次該執行的時間點,每次觸發後依 cron_expr 重新計算並更新;job_runs(run_id PK, schedule_id, started_at, finished_at nullable, status,worker_id) 記錄每一次實際執行的結果。next_run_at 需要建 Index(呼應 Day 36),因為「查詢哪些任務的 next_run_at 已經到期」是這個系統最高頻的查詢。

6. High-Level Architecture

Scheduler Coordinator(定期掃描 schedules 表找出到期任務)→Queue(把到期任務放進去)→ Worker Pool(消化 Queue、實際執行任務邏輯)→ DB(job_runs 記錄結果)。

7. Core Flow

  • Write Path(觸發):Coordinator 每分鐘掃描一次WHERE next_run_at <= now() 的排程(走 Day 36 Index,而非全表掃描),把到期任務的資訊丟進 Queue,並立刻把該筆 next_run_atcron_expr 推算到下一次應該執行的時間(避免同一筆任務在下一次掃描時被重複判定為到期)。
  • Read Path(執行):Worker 從 Queue 取出任務,取得第 9 步展開的 Distributed Lock 後才真正執行任務邏輯,執行完寫入 job_runs

8. Scaling

Coordinator 若只有單一實例,本身不是效能瓶頸(掃描 10 萬筆排程即使全表掃描也在毫秒級,況且有 Index),但是 Single Point of Failure(見第 11 步);Worker Pool 可以水平擴展處理實際任務執行的運算負載。

9. Reliability

任務執行邏輯本身可能因為外部依賴(例如任務要呼叫的下游服務)暫時失敗,Worker 依 Exponential Backoff 重試(呼應 Day 8/Day 66),重試達上限則標記 job_runs.status=failed 並觸發告警,交由人工介入而非無限重試。

10. Consistency

next_run_at 的更新必須跟「把任務放進 Queue」這兩件事保持一致——如果只更新了 next_run_at 卻沒有真的把任務放進 Queue(或反過來),會導致「這次該執行的任務漏跑,但系統誤以為已經處理過」,這是本題 Consistency 段落要特別小心的地方,通常用資料庫 Transaction 把「讀取到期任務、更新 next_run_at、寫入待觸發紀錄」包在同一個交易裡(呼應 Day 36–40 Transaction 的原子性保證)。

11. Failure(核心:分散式鎖)

如果 Coordinator 有多個實例同時運行(為了避免第 8 步提到的 SPOF 而做 Replication),兩個 Coordinator 實例可能在同一分鐘各自掃描到同一筆到期任務,各自都把它放進 Queue,導致同一個排程任務被觸發兩次、被兩個不同的 Worker 同時執行——如果任務本身有副作用(例如「寄出一封帳務結算通知」「扣款」),重複執行會造成實際的業務錯誤(不像單純的重複讀取那樣無害)。

12. Trade-offs(Distributed Locking 具體設計)

解法是在「處理某一筆到期任務」之前,先嘗試取得一把跟這筆任務綁定的分散式鎖,只有成功取得鎖的那個 Coordinator/Worker 才真正繼續(沒取到鎖的直接跳過,視為別的節點已經在處理)。兩種實作各有取捨:

  • PostgreSQL Advisory Lock(呼應 Day 36–40 已教過的機制)——pg_try_advisory_lock(schedule_id) 直接在既有資料庫上取鎖,不需要引入新元件,缺點是鎖的持有跟資料庫連線生命週期綁定,連線斷掉鎖會自動釋放(這其實是優點也是限制:優點是不會有「持有鎖的節點掛掉、鎖永遠不釋放」的問題,限制是不能讓鎖跨越多個連線持有)。
  • Redis 分散式鎖(例如 SET key value NX PX <ttl>)——不佔用資料庫連線,效能通常更好,但需要額外設定合理的 TTL 並處理「任務執行時間可能超過 TTL 導致鎖提前釋放、另一個節點趁機搶到鎖」這個經典陷阱(緩解方式是鎖的持有者定期 renew TTL,只要還在執行就持續續約)。

今天選擇 PostgreSQL Advisory Lock:這個系統的排程任務數量級(10 萬筆、每分鐘檢查一次)不需要 Redis 等級的高吞吐量,複用既有資料庫、少維護一個元件,換到的代價是鎖的粒度與資料庫連線數綁定,需要留意連線池大小是否足夠。

Day 68 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 排程任務在指定時間只被執行一次,單台 Worker 故障不影響其他任務照常觸發 Capacity: 10 萬筆排程,平均每分鐘約 70 個到期,整點尖峰可能瞬間數千個 API: POST /schedules {cron_expr, job_type, payload};GET /schedules/{id}/runs Data Model: schedules(next_run_at 建 Index)、job_runs(schedule_id, status, worker_id)Architecture: Coordinator(掃描到期任務) → Queue → Worker Pool → DB(job_runs)Read Path: Worker 取得 Distributed Lock 後才真正執行任務邏輯 Write Path: Coordinator 掃描到期排程 → 丟進 Queue → 同一交易內更新 next_run_at Bottleneck: 多 Coordinator 實例可能同時掃到同一筆到期任務,觸發重複執行 Scaling: Worker Pool 水平擴展;Coordinator 掃描本身輕量,瓶頸不在這裡 Consistency: 「讀到期任務+更新next_run_at+入隊」需包在同一交易,避免漏跑或誤判已處理 Failure: 多節點搶同一任務用 Distributed Lock 擋下,PostgreSQL Advisory Lock vs Redis 鎖各有取捨 Trade-off: 選 Advisory Lock 複用既有資料庫,換掉 Redis 更高吞吐但多一個元件與 TTL 陷阱``

Day 69 — Phase 07 整合複習:7 題型共同 Pattern

學習目標

看完今天內容後,能夠:

  1. 不看任何一題的詳細內容,直接說出 7 個題型(URL Shortener / Rate Limiter / News Feed / Chat / Notification / File Storage / Job Scheduler)各自用到哪些共同機制(Cache/Queue/Sharding/Replication/Idempotency/Index/Distributed Lock)。
  2. 對每個共同機制,說出「為什麼這個機制會在多個題型裡重複出現」的歸納理由,而不是只背「這題有用到 Cache」這種表面對應。
  3. 用這張歸納表格,反過來推導一個新題目:看到題目敘述,能快速判斷它大概率需要哪些機制。

共同 Pattern 對照表

| 機制 | URL Shortener | Rate Limiter | News Feed | Chat | Notification | File Storage | Job Scheduler ||---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|| Cache(讀取加速) | ✓ | | ✓ | | | | || Queue(非同步解耦) | | | ✓ | (Pub/Sub) | ✓ | | ✓ || Sharding(狀態分片) | | ✓ | | ✓ | | | || Replication(讀取備援) | ✓ | | ✓ | | | | || Idempotency(防重複) | | | ✓(upsert) | | ✓ | | || Index(B-Tree 查詢加速) | ✓(PK) | | ✓(Composite) | ✓ | | | ✓ || Distributed Lock / Atomicity | | ✓ | | | | | ✓ |

為什麼這些機制會重複出現(歸納,不是重複列題目)

Cache 只出現在「讀寫比例懸殊、且讀取結果短期內不太會變」的題型:URL Shortener 的短碼一旦建立就不可變(Day 62 已推導),News Feed 的 Feed 結果允許秒級延遲(Eventual Consistency)——兩者都符合「查詢結果可以暫存一段時間、不會立刻過期」的前提。Rate Limiter 表面上也是「高頻讀取」,但它每次都要讀「當下最新的計數值」才有意義(讀到舊值會導致超放行,見 Day 63),這正是它不能用 Cache、只能直接打權威 store 的原因——Cache 的適用條件不是「讀取頻繁」,而是「讀取頻繁 + 容忍讀到稍舊的值」,兩個條件缺一不可,這是本表格真正的歸納結論,比「哪幾題有打勾」更重要。

Queue 出現在所有「呼叫方不該被下游處理速度拖慢」的題型:News Feed 的 Fan-out、Notification 的供應商呼叫、Job Scheduler 的任務執行,三者的共同點是「觸發動作」與「實際完成動作」之間可能有明顯延遲(Fan-out 給百萬粉絲、呼叫外部供應商、執行任務邏輯都不是毫秒級操作),用 Queue 把兩者解耦,讓觸發方立刻拿到回應,實際處理交給背景 Worker——Queue 解決的不是「量大」,而是「觸發與完成之間的延遲差」,這也是 Chat 的 Pub/Sub 雖然形式上也是訊息中介,但它是「即時轉發」而非「背景延遲處理」,兩者目的不同,不能混為一談。

Sharding 只出現在「單一權威狀態的存取量本身就會超過單機承載」的題型:Rate Limiter 的限流計數、Chat 的連線註冊表,都是「多節點必須看到同一份最新狀態」(不能像 Cache 一樣容忍暫時不一致),但這份狀態的存取量在大規模場景下會超過單一節點能承載的程度,Sharding 把它切成多份、依 key(user_id)分散到不同節點,讓「必須是權威、但又要能承受高併發存取」這兩個看似衝突的要求同時成立。

Idempotency 只出現在「有實際副作用、且可能被重試」的題型:News Feed 的 Fan-out Worker 重試會造成同一則貼文重複寫入 Feed,Notification 的供應商呼叫重試會造成使用者收到重複通知——兩者的共同點是「操作本身有外部可觀察的副作用」(多一筆 Feed 條目、多發一次通知),而且這個操作處在一個 at-least-once 語意的重試路徑上。純讀取查詢(URL Shortener 的 redirect、File Storage 的下載)沒有這個問題,因為讀取重複執行對系統狀態沒有影響。

Index 幾乎每個牽涉到「依某個欄位查詢/排序」的題型都會用到:這是最普遍的機制,反而說明它是「基本功」而非「這題特別需要」——凡是有查詢,就要回頭問「這個查詢的 WHERE/ORDER BY 欄位有沒有 Index」,這是 Phase 04(Day 31–40)建立的習慣,在 System Design 裡不需要每次特別強調,但檢查清單裡永遠不能漏掉。

Distributed Lock / Atomicity 只出現在「多個節點會搶著修改同一份共享狀態、而且不能只接受『大概正確』」的題型:Rate Limiter 的計數更新、Job Scheduler 的任務觸發,兩者都是「兩個節點同時操作會產生實際錯誤後果(超放行、任務重複執行)」而非「讀到舊值頂多顯示慢半拍」——這跟 Idempotency 處理的問題形狀類似(都涉及重複),但機制不同:Idempotency 是「重複執行了也沒關係,因為有去重」,Distributed Lock 是「一開始就不讓第二次執行發生」,選哪一種取決於「補救的成本」與「預防的成本」哪個更低(Notification 選補救、Job Scheduler 選預防,正是因為兩者副作用的嚴重程度不同)。

Day 69 過關標準(DoD)

``text 產出:完整填寫上方 7×7 對照表(不看教材,憑記憶重建,再對照本文核對)產出:針對 Cache/Queue/Sharding/Idempotency/Distributed Lock 五個機制,各自用自己的話寫一句「這個機制的適用條件是____,不是單純的____」(呼應本文「Cache 不是讀取頻繁就要用」等歸納句型)產出:任選一個表格裡的「空格」(例如 Notification 沒有 Sharding),解釋為什麼這題不需要這個機制——空格跟打勾一樣重要,能說出「為什麼不需要」才算真的理解適用條件,而不是背下對照關係``

Day 70 — Phase 07 收尾:45–60 分鐘計時模擬(陌生題)

學習目標

看完並完成今天內容後,能夠:

  1. 在時間壓力下,靠自己(不翻教材)走完完整 12 步 Framework,覆蓋全部 12 個維度,不因為時間不夠就跳過 Capacity/Failure/Trade-offs 這類「沒那麼好玩」的步驟(呼應 Day 61 開頭強調的固定 Framework 核心價值)。
  2. 面對一個沒有出現在第 11 章題型清單裡1的新題目,能自己判斷它該用到 Day 61–69 教過的哪些機制組合,而不是因為「沒看過這題」就卡住。

為什麼選這題當陌生題

今天的題目「限時搶購(Flash Sale)系統」不在文件列出的 7 個題型裡,但它需要的全部是 Day 61–69 已經教過的機制(Atomicity/Distributed Lock、Load Shedding、Strong Consistency 的取捨)——陌生題訓練的是「重新組合已知機制」的能力,不是要求你懂沒教過的新知識,這是 Phase 07 § 20「最重要的驗收方式:陌生題」的核心精神:給一個沒看過的問題,能不能拆出來。

計時規則

  1. 先花 2 分鐘讀完下方題目敘述(只讀一次,不要一邊讀一邊開始寫)。
  2. 接著設定 45–60 分鐘計時,開始寫。建議的步驟時間分配(12 步不必均分,Capacity/Core Flow/Failure 通常需要更多時間):Requirements+Constraints 5 分鐘、Capacity Estimation 8 分鐘、API+Data Model 8 分鐘、Architecture+Core Flow 15 分鐘、Scaling+Reliability+Consistency 10 分鐘、Failure+Trade-offs 10 分鐘(總計約 56 分鐘,可依個人狀況調整,但不要單一步驟超過 15 分鐘)。
  3. 計時結束前,不要往下看「參考解」——先寫完自己的十二欄紀錄,計時結束後才對照參考解檢查差異,中途偷看會讓這次練習失去意義。

題目:限時搶購(Flash Sale)系統

設計一個電商限時搶購系統:某商品在特定時間開賣,庫存有限(例如 1,000 件),同時可能有數十萬使用者在開賣瞬間搶購。系統要保證不超賣(賣出數量絕對不超過庫存),且要在極端流量尖峰下保持可用。

自我檢查(十二欄,先自己填完再往下看參考解)

``text Requirement:Constraints:Capacity:API:Data Model:Architecture:Read Path:Write Path:Scaling:Reliability:Consistency:Failure:Trade-off:``

參考解(先完成上方計時練習,再往下對照)

1. Requirements

使用者在指定開賣時間搶購限量商品(例如 1,000 件),系統保證:(a) 不超賣(賣出數量絕對不超過庫存)(b) 公平——同一時間到達的請求裡,先到的人優先搶到。Non-functional:開賣瞬間會有極高並發流量(例如平時 QPS 的百倍甚至千倍在同一秒湧入),系統要扛得住流量尖峰而不整個垮掉。

2. Constraints

假設商品與庫存數量在開賣前已經設定好、不會臨時調整;假設「搶到」的定義是「成功建立訂單、鎖定庫存」,不要求同一秒內完成付款(可以是搶到後給一段時間視窗完成付款,逾時釋放庫存給下一位)。

3. Capacity Estimation

假設某次搶購吸引 50 萬使用者同時在開賣瞬間發送請求,庫存僅 1,000 件——這代表 (a) 超過 99.8% 的請求注定失敗(搶不到)(b) 瞬間 QPS 可能高達數萬甚至數十萬(50 萬人幾乎同時按下按鈕),但真正需要精確處理「扣庫存」這件事的,只有最終會成功的 1,000 筆——這個對比(50 萬 vs 1,000)直接決定架構的核心原則:要在真正碰觸庫存扣減之前,先用便宜的手段擋掉絕大多數注定失敗的請求,不能讓 50 萬個請求全部都去搶同一把鎖或同一次 DB 更新。

4. API

GET /flash-sales/{id}(查詢商品與剩餘庫存,唯讀,可大量 Cache);POST /flash-sales/{id}/purchase {user_id} → {order_id, status:reserved | sold_out}

5. Data Model

flash_sale_items(item_id PK, total_stock, remaining_stock,sale_start_at)orders(order_id PK, item_id, user_id, status,reserved_at, expires_at)(item_id, user_id) 建 UNIQUE 約束(防止同一使用者對同一場搶購重複下單佔用庫存)。

6. High-Level Architecture

LB → 前端限流層(在真正碰資料庫之前先擋掉多數流量)→ App Server →Redis(庫存的權威計數,原子扣減)→ Queue(非同步落地訂單到 DB)→DB。

7. Core Flow

  • Write Path:請求先過一層粗篩(呼應 Day 9 教過的 Rate Limiting / Load Shedding:對單一使用者的重複請求限流,且系統整體超過設計承載量時直接拒絕多餘請求、回「活動太熱門請稍後」,而不是讓所有請求排隊等到超時)。通過粗篩的請求對 Redis 執行原子操作 DECR remaining_stock(呼應 Day 63 INCR 的同一原理反過來用),若扣減後的值 ≥ 0 代表搶購成功,若 < 0(代表已經被扣到負值,也就是庫存已經沒了)則立刻回滾(INCR 加回去)並回傳sold_out;搶購成功的請求非同步丟進 Queue,由 Worker 落地一筆orders 紀錄(狀態 reserved)。
  • Read Path:查詢剩餘庫存直接讀 Redis(不查 DB,DB 只在下單成功後才寫入,讀取剩餘庫存這種高頻查詢完全不該打 DB)。

8. Scaling

粗篩層(限流/Load Shedding)要能在應用伺服器最外層就擋掉多數流量,不讓 50 萬個請求全部滲透到會碰觸 Redis 的路徑;Redis 單一 key 的DECR 操作雖然是原子的,但仍是單一 key、單一 Redis 節點在處理,是這個系統設計裡刻意集中(而非分散)的一點——因為「1,000 件庫存的精確扣減」這件事本質上就需要一個單一權威來源,分散到多個節點反而會製造超賣風險(這跟 Day 63 限流的多節點共享狀態問題是同一類問題:不能讓多個地方各自維護副本再各自扣減)。

9. Reliability

Redis 若在搶購瞬間掛掉,整個系統應該直接 Fail Closed(呼應 Day 63 提過的兩種降級策略,這裡選擇跟限流不同的方向)——寧可讓使用者看到「活動異常、請稍後」也不能讓庫存扣減失控導致超賣(因為超賣在電商情境是實際的業務損失,要花錢賠償或緊急補貨,後果遠比「拒絕服務」嚴重)。

10. Consistency

庫存扣減本身要求 Strong Consistency(絕不能讓兩個使用者都以為自己搶到最後一件),這是用 Redis 單一節點的原子操作直接保證的;但「訂單真正落地進 DB」這一步允許 Eventual(非同步透過 Queue 處理,使用者拿到 reserved 狀態的回應時,DB 紀錄可能還在寫入路上)。

11. Failure

如果 Worker 把 Redis 扣減成功的訂單寫進 DB 時失敗(例如 DB 暫時不可用),會出現「Redis 顯示庫存已扣、但 DB 沒有對應訂單紀錄」的不一致——這裡選擇讓 Worker 重試寫入 DB(訊息還在 Queue 裡,呼應 Day 58 的 Retry 機制),而不是把 Redis 的扣減操作回滾,因為回滾後若這件庫存被另一個使用者搶走、原本的 Worker 重試又寫入成功,會出現同一件庫存被兩個訂單佔用的更嚴重問題;保持「Redis 扣減是權威決定」、DB 寫入只許重試到成功為止,是這裡選擇的方向。

12. Trade-offs

用 Redis 原子操作 + 前端粗篩,換掉「直接讓所有請求打 DB Transaction 用 Row Lock 搶庫存」這個看似直覺的做法——Row Lock 在 50 萬並發搶 1,000 列的情境下,會造成大量請求排隊等待同一把鎖(甚至可能超過 DB 連線池上限而整個服務打不開),Redis 的單一原子操作雖然一樣是「集中處理」,但 Redis 本身的吞吐量與延遲特性遠比在關聯式資料庫上做高並發 Row Lock 競爭更適合這種瞬間尖峰場景,代價是多維護一個 Redis 作為庫存的權威來源,且要處理 Redis 與 DB 之間可能短暫不一致的情況(第 11 步已展開)。

Day 70 過關標準(DoD)—— System Design 特殊驗收(十二欄)

``text Requirement: 限量商品開賣瞬間搶購,不超賣,極端流量尖峰下保持可用 Capacity: 50 萬並發請求對 1,000 件庫存,99.8% 注定失敗,尖峰 QPS 數萬到數十萬 API: GET /flash-sales/{id}(查剩餘庫存);POST /flash-sales/{id}/purchase Data Model: flash_sale_items(remaining_stock)、orders((item_id,user_id) UNIQUE)Architecture: LB → 前端限流層 → App Server → Redis(權威庫存) → Queue → DB Read Path: 剩餘庫存直接讀 Redis,不查 DB Write Path: 粗篩擋多數流量 → Redis 原子 DECR 扣庫存 → 成功者非同步落地 DB Bottleneck: 50 萬請求若全部直接碰 DB Row Lock 會造成鎖排隊甚至打爆連線池 Scaling: 前端粗篩層先擋掉注定失敗的請求,只讓少量請求滲透到 Redis 扣減路徑 Consistency: 庫存扣減 Strong Consistency(Redis 單節點原子操作);訂單落地 DB 為 Eventual Failure: Redis 扣減成功但 DB 寫入失敗,靠 Worker 重試而非回滾 Redis,避免同一庫存被兩單佔用 Trade-off: Redis 原子操作換掉 DB Row Lock 高並發競爭,代價是多一個權威來源與短暫不一致視窗``

  1. 依 00-master-curriculum.md 第 11 章