100 Day Engineer Challenge
Phase 06 — Distributed Systems(Day 51–60)
這份逐日教材依循內部規劃文件的範圍與排序展開1。
本檔案由兩個任務接力完成:本段(T-029)涵蓋 Day 51–55:CAP /Consistency / Replication / Sharding / Consistent Hashing;Day 56–60(Cache / Cache Invalidation / Messaging / Failure,以及 Phase 收尾對應 Notification System 驗收)由 T-030 接續寫在本檔案後半段,不另開新檔。
Day 51–55 的順序依 prerequisite chain2:CAP 排第一,作為整個 Phase 的框架性入口——後面的 Consistency/Replication/Sharding 全部是「在 CAP 的取捨下,系統實際選了什麼」的具體案例。接著是 Consistency → Replication 這條鏈:Replication lag 正是 Strong 與 Eventual consistency 之間落差的物理成因,先懂 Consistency 的分類,Replication lag 才知道自己在破壞哪一種保證。最後是 Sharding → Consistent Hashing:Sharding 決定「一筆資料放哪個節點」,Consistent Hashing 是為了讓 Node join/leave 時只需要搬動一小部分資料——這個好處要對照「沒有 Consistent Hashing 時 Hash Sharding 加減節點要重算全部 mapping」才能體會,所以先教 Hash sharding 的基本痛點,Day 55 再帶出 Consistent Hashing 的解法。Cache/Cache Invalidation(依賴本段 Consistency 的分類,以及 Phase 05 的 Race condition)與 Messaging/Failure(收斂整個 Phase 開頭 Core Map:Network→Failure→Timeout→Retry→Duplicate→Idempotency→Ordering→Consistency→Replication→Partition)留給 T-030。
每個主題依分類標準要求的 5 段式內容撰寫3(Why / Mechanism / Trade-off / Failure mode / Backend 連結),Core Fundamentals 額外附一則陌生題示範。本 Phase 沒有像 Phase 02–05 那樣有固定題數的特殊驗收格式(master-curriculum 第 10 章沒有訂出對應 Phase 06 的專屬問題清單),因此每天的過關標準(DoD)改用「設計一個具體情境並寫下完整分析」的格式:情境貼近真實系統會遇到的具體故障或設計決策,分析要能推導出量化或可驗證的結論,不是空泛的「想一想」。
Day 51 — CAP:Partition 發生時,系統到底在取捨什麼
學習目標
看完今天內容後,能夠:
- 準確說出 CAP 定理的三個要素分別是什麼,並解釋為什麼 Partition Tolerance 在有網路的分散式系統裡不是「可選項」。
- 對一個具體的分散式寫入情境,判斷系統選擇 CP 還是 AP,並說明各自犧牲了什麼、換到了什麼。
- 分辨 CAP 的 Consistency 跟 ACID 的 Consistency 是兩個不同的概念,不會把兩者混用。
教材大綱
CAP 定理
Core FundamentalsLv.4What:CAP 定理指出,一個分散式系統在網路分割(Partition,節點之間的連線斷掉、但兩邊各自仍在運作)發生時,最多只能同時滿足 Consistency(一致性)與 Availability(可用性)其中一個,無法兩者兼得。Partition Tolerance(分割容忍度)在橫跨多台機器、透過網路溝通的系統裡不是可以放棄的選項——只要系統存在,網路分割就有可能發生,系統必須對「分割發生時要怎麼辦」有明確的答案,這正是 CAP 定理逼你回答的問題。
分散式系統天生會遇到網路分割——機房之間的專線斷線、跨區域連線逾時、甚至同一機房內某台交換器故障,都會造成部分節點彼此看不到對方,但各自仍存活、仍能回應各自這一側的請求。如果不理解 CAP,很容易誤以為可以設計出「什麼都不犧牲」的完美分散式系統;實際上一旦分割發生,系統的每一筆寫入請求都必須在「拒絕服務直到能確保一致」與「先接受、之後再想辦法合併」之間做出選擇,CAP 逼你在設計階段就先想清楚這個選擇,而不是留到事故發生時手忙腳亂。
具體推導——兩個節點 A、B 互為 replica,兩者之間的網路忽然斷了(Partition),但兩邊各自都還能正常處理各自收到的請求。這時一個 client 對 A 發出寫入請求,系統有兩個選擇:
- 選 Consistency(CP):A 判斷自己現在無法確認 B 是否能同步收到這筆寫入(因為連線斷了),為了不造成「A 寫成功了、但 B 沒有」的不一致,A 拒絕這筆寫入請求,回傳錯誤或「暫時不可用」——這是犧牲 Availability,換取「不會出現不一致的資料」。
- 選 Availability(AP):A 接受這筆寫入請求並立刻回應成功,即使 B 目前收不到這筆更新(因為網路斷了)。這時 A 與 B 的資料暫時不一致,要等網路恢復、資料重新同步(Day 52 要學的 Eventual Consistency)才會收斂回一致——這是犧牲 Consistency,換取「系統在分割期間仍持續可用」。
沒有發生 Partition 的時候(網路正常),A、B 之間可以正常同步,Consistency 跟 Availability 可以同時滿足,不用犧牲——這是 CAP 最常被誤解的地方:CAP 講的是「Partition 發生當下」的取捨,不是系統永遠、隨時都要犧牲一個要素。
CP 系統在分割期間會有部分節點拒絕請求、變得不可用,適合資料正確性優先、錯了無法挽回的場景(金融交易、庫存扣減、座位預訂);AP 系統在分割期間持續可用,但可能回傳過期或彼此不一致的資料,適合可用性優先、可以容忍短暫不一致再收斂的場景(社群動態、按讚數、瀏覽紀錄)。
Failure Modes:常見誤解有兩個。其一,以為 CAP 是「三選二」,可以自由挑任兩項——這是不精確的說法:只要系統橫跨多台機器且透過網路溝通,Partition 永遠有可能發生,Partition Tolerance 無法真的被「放棄」;真正的選擇只發生在 Partition 期間,是 C 還是 A 的抉擇,不是三選二。其二,把 CAP 的 Consistency 跟 ACID Transaction 的 Consistency 混為一談——CAP 的 C 指的是 Linearizability(線性一致:所有節點看到相同、依序的最新寫入結果),跟 ACID 的 Consistency(資料完整性約束不被破壞,例如外鍵、唯一鍵不能違反)是完全不同的概念,這是初學者最容易搞混、也最常在面試被追問的地方。
Backend Applications:選 PostgreSQL 主從架構搭配同步複寫(Synchronous Replication)時,本質上是往 CP 方向靠——Primary 要等 Replica 確認收到才回應 client;反之 DynamoDB、Cassandra 這類系統預設走 AP,允許「最終一致」,換取更高的寫入吞吐與可用性。Backend 工程師在選資料庫或設計複寫策略前,第一個要回答的問題正是「這個資料,Partition 發生時我要選哪一邊」,這個答案會直接決定後續的架構選型。
陌生題示範:情境——你在設計一個線上購物網站,其中「庫存扣減」與「商品瀏覽數計數器」兩個功能都要橫跨多個資料中心複寫。網路分割發生時,你會分別對這兩個功能選 CP 還是 AP?請說明理由。
推導:庫存扣減——選 CP。如果選 AP,分割期間兩個資料中心各自接受扣減庫存的寫入,恢復連線後可能出現「賣超」(兩邊各自把庫存扣到 0,但實際只有一份庫存),造成無法挽回的業務損失(賣出不存在的商品、還要處理退款與客訴);即使分割期間變得不可用(暫停接單),後果也遠比賣超小。瀏覽數計數器——選 AP。使用者體驗上「瀏覽數字暫時不精確」完全可以接受(少算幾筆、多算幾筆不影響業務決策),但如果因為網路分割就讓整個網站的商品頁面打不開(不可用),反而是更嚴重的問題;AP 讓系統在分割期間持續可寫(各自累加),恢復連線後再合併(Eventual Consistency)。這個對照說明 CAP 的選擇不是全站統一的,而是要依「這筆資料錯了、能不能事後補救」逐一分析決定的。
Day 51 過關標準(DoD)—— 具體情境分析
```text 情境:一個演唱會訂票系統,橫跨兩個資料中心(DC-A、DC-B)以 Primary-Primary 架構運作(兩邊都能接受寫入,之後互相同步)。某晚兩個資料中心之間的專線忽然中斷(network partition),期間仍有大量使用者同時嘗試搶購同一場、剩下最後 10 張票的座位。
分析:1. 這是典型的 CP vs AP 抉擇情境,關鍵資料是「剩餘票數/座位佔用狀態」。2. 若選 AP(分割期間兩邊都繼續接受訂票請求):DC-A 和 DC-B 各自可能都賣出最後 10 張票(各自看到的剩餘票數還夠),分割恢復後合併資料,可能出現「同一個座位賣給兩個人」或「總銷售量超過實際座位數」的超賣——這是不可逆的業務錯誤(已經跟兩位使用者收費、寄出確認信)。3. 若選 CP:分割發生時,其中一邊(例如設計上指定的「非權威」一方 DC-B)直接拒絕新的訂票請求(回傳「暫時無法訂購,請稍後再試」),只允許持有最新座位狀態的一方(DC-A)繼續處理——犧牲 DC-B 使用者的可用性,換取「不會賣出同一個座位兩次」的正確性。4. 結論:座位佔用/庫存這類「一旦重複配發就無法挽回」的資料應該選 CP,即使代價是部分使用者在分割期間暫時無法操作;這跟今天教材「瀏覽數計數器選 AP」的案例形成對照——同一個系統裡,不同資料的 CAP 選擇可以不同,取決於「這筆資料錯了、能不能事後補救」。```
Day 52 — Consistency 模型:Strong 到 Eventual 之間的光譜
學習目標
看完今天內容後,能夠:
- 準確定義 Strong Consistency、Eventual Consistency、Read-after-write Consistency、Monotonic Read Consistency 四種模型各自的保證強度。
- 對一個「使用者看到過期或倒退資料」的 bug 情境,判斷是缺少哪一種 Consistency 保證造成的。
- 說明為什麼大部分正式系統會落在 Strong 與 Eventual 之間,而不是兩極選一。
教材大綱
Consistency 模型
Core FundamentalsLv.4What:Consistency 模型定義了「多個節點各自保有的資料副本,在什麼條件下,讀取會看到什麼樣的結果」的一組具體保證強度:
- Strong Consistency:任何一次讀取都保證看到最近一次成功寫入的結果,如同系統只有一份資料一樣,不管讀哪個節點。
- Eventual Consistency:不保證讀取立刻看到最新寫入,但保證「如果沒有新的寫入進來,經過一段時間後,所有節點最終會收斂到相同的值」。
- Read-after-write Consistency(也稱 Read-your-writes):保證「同一個使用者,自己剛寫入的資料,自己接下來的讀取一定看得到」,即使其他使用者可能還看不到。
- Monotonic Read Consistency:保證「同一個使用者連續讀取,不會看到時間往回跳」——一旦讀到某個版本的資料,之後的讀取不會讀到比它更舊的版本,即使是被路由到不同節點回應。
這幾個模型是 Strong 與 Eventual 之間的光譜。Backend 工程師常見的誤解是「只有 Strong vs Eventual 兩極」,實際上大部分正式系統落在中間——用 Read-after-write 或 Monotonic Read 這類「弱於 Strong、但比純 Eventual 更可用」的折衷保證,因為使用者體感上最需要的往往不是「全域絕對一致」,而是「自己剛做的事,自己看得到、而且不會忽然倒退」。
用一個社群 App 留言功能的例子貫穿四種模型。使用者 A 在 replica-1 發表留言:
- Strong:留言送出後,不管誰、不管讀哪個 replica,馬上都看得到——代價是寫入要等所有(或多數)replica 同步完成才能回應成功。
- Eventual:留言送出後,A 立刻在 replica-1 看得到,但如果另一個使用者 B 剛好讀到還沒同步到的 replica-2,暫時看不到;過一小段時間(複寫延遲後)B 也會看到。
- Read-after-write:系統至少保證「A 自己」接下來不管讀哪個 replica,都看得到自己剛發表的留言(例如把 A 的後續讀取導向他剛寫入的那個 replica,或在 A 的 session 裡記錄他寫入時的版本號、讀取時比對版本),但不保證 B 立刻看到。
- Monotonic Read:假設 A 剛好讀到了同步較快的 replica,看到自己的留言,下一秒因為負載平衡切到另一個複寫較慢的 replica——如果沒有 Monotonic Read 保證,A 可能忽然「看不到」剛剛看到的留言(資料倒退);有這個保證,系統會確保 A 之後讀到的版本不會比他已經讀過的版本舊。
Strong Consistency 要付出「寫入等待多節點確認」的延遲代價,且在網路分割時可能不可用(呼應 Day 51 的 CP 選擇);Eventual Consistency 換到最高的可用性與寫入吞吐量,代價是使用者體感上可能看到過期、甚至時間倒退的資料;Read-after-write /Monotonic Read 是折衷——只針對「同一個使用者的體感」加強保證,不用付出對「所有使用者」都強一致的完整代價,通常靠「把同一個使用者的請求導向固定節點」或「版本號比對」實作,成本遠低於全域 Strong Consistency。
Failure Modes:沒有 Read-after-write 保證的系統,常見的 bug 情境是「使用者剛更新了自己的個人資料,重新整理頁面卻看到舊資料」——通常是讀取請求被負載平衡導到另一個還沒同步到最新寫入的節點;沒有 Monotonic Read 保證的系統,可能出現「使用者剛看到自己的訂單狀態變成『已出貨』,重新整理後又變回『處理中』」這種資料倒退的詭異體驗,會嚴重打擊使用者對系統的信任。
Backend Applications:常見做法是「寫入後的短時間內,把該使用者的讀取請求導向剛才處理寫入的那個節點(sticky read)」,或在 session/cookie 裡記錄使用者上次寫入時拿到的版本號/時間戳,讀取時只接受不低於該版本號的節點回應——這是在不用付出全域 Strong Consistency 代價的前提下,解決「使用者自己」的體感問題,是很多正式系統(例如銀行 App「交易剛完成馬上查詢餘額」)實際採用的折衷方案。
陌生題示範:情境——一個線上訂單系統,使用者下單後立刻被導向「訂單詳情頁」。工程師發現:極少數情況下,訂單詳情頁顯示「找不到此訂單」,重新整理一次後又正常顯示。請診斷這是哪一種 consistency 問題,並提出至少一種修復方案。
推導:這是缺乏 Read-after-write consistency 的典型症狀——下單的寫入請求打到某個節點(例如 replica-1)並成功,但下單成功後導向的訂單詳情頁讀取請求,被負載平衡打到另一個尚未同步到這筆新訂單的節點(例如 replica-2),造成「剛寫入、馬上讀不到」。修復方案:(1) 在下單成功的回應裡帶上寫入時的版本號/時間戳,訂單詳情頁的讀取請求附帶這個版本號,若目標節點的資料版本落後於此,改讀 Primary 或等待同步;(2) 短時間內(例如 5 秒)把同一個使用者的讀取請求固定導向剛處理寫入的節點(sticky session);(3) 若資料庫支援,直接對這類「寫入後立刻讀」的關鍵路徑改用同步複寫或直接讀 Primary,犧牲一點延遲換取正確性。
Day 52 過關標準(DoD)—— 具體情境分析
```text 情境:一個即時聊天 App,訊息「已讀」狀態需要在多個裝置(使用者的手機、平板、桌機同時登入同一帳號)之間同步。工程師發現一個 bug:使用者在手機上把一則訊息標記已讀,幾秒後在平板上打開同一個對話,「已讀」標記卻消失了(顯示成未讀),過幾秒後才又恢復。
分析:1. 這是 Monotonic Read 被違反的症狀——使用者(同一人,不同裝置)先讀到了「已讀」這個較新的狀態,之後的讀取(在平板上)卻讀到了比它更舊的「未讀」狀態,出現「資料倒退」的體感。2. 根本原因:已讀狀態的寫入(手機標記已讀)打到某個節點(例如 replica-1,Lag 較低),平板端的讀取請求被路由到另一個 Lag 較高、還沒同步到這筆更新的節點(例如 replica-2),造成暫時性的倒退。3. 修復方向:(a) 針對「已讀狀態」這類需要跨裝置即時同步的資料,讀取時附帶使用者上次讀到的版本號/時間戳,若目標節點版本落後,改讀 Primary 或等待同步完成再回應(Read-after-write/Monotonic Read 的實作手法);(b) 或把「已讀狀態」這類低寫入量但高即時性要求的資料,改用同步複寫或集中在單一權威節點處理,不透過可能有 Lag 的 Replica。4. 結論:純粹的 Eventual Consistency 對這個功能不夠用,至少需要加上 Monotonic Read 保證(同一使用者的讀取不會時間倒退),才能符合「已讀狀態不會忽然消失又恢復」的產品預期。```
Day 53 — Replication:多份拷貝解決容錯與讀取吞吐,但代價是 Lag
學習目標
看完今天內容後,能夠:
- 說出 Primary/Replica(Leader/Follower)架構的基本運作流程,解釋 Replication 解決的兩個問題(單點故障、讀取吞吐上限)。
- 對比同步複寫與非同步複寫,說明各自的延遲代價與資料安全性保證。
- 對一次 Primary 故障轉移情境,推導出可能遺失哪些寫入、為什麼。
教材大綱
Replication
Core FundamentalsLv.4What:Replication 是把同一份資料複製到多台機器(節點)上的機制。最常見的架構是 Primary/Replica(或稱 Leader/Follower):所有寫入都先打到 Primary 節點,Primary 處理完後,把這筆變更(透過 write-ahead log 或類似機制)傳播給一到多個 Replica 節點,Replica 節點套用相同的變更,維持與 Primary 相同的資料狀態。
單一節點存在兩個問題:其一,單點故障——這台機器掛了,資料完全不可存取;其二,讀取吞吐上限——所有讀取請求都打到同一台機器,CPU/IO 很快就會成為瓶頸。Replication 透過「多份拷貝」同時解決這兩個問題:一份資料掛了還有其他份可用(容錯),讀取請求可以分散到多個 Replica(水平擴展讀取吞吐)。
具體流程——Primary 收到一筆UPDATE users SET balance = balance - 100 WHERE id = 1 的寫入,先在自己的 write-ahead log(WAL)記錄這筆變更,套用到自己的資料,然後把這段 WAL 內容傳給每個 Replica;每個 Replica 收到後,依序重放(replay)這段 log,讓自己的資料狀態逐步追上 Primary。這個傳播過程需要時間(網路傳輸 + Replica 重放),這段時間差就是 Replication lag——Replica 上看到的資料,永遠是「稍微落後 Primary 一點」的狀態(除非採用同步複寫)。
同步複寫(Synchronous Replication):Primary 要等到至少一個(或多數)Replica 確認已經套用完這筆變更,才回應 client「寫入成功」——保證被確認的 Replica 資料不落後,代價是寫入延遲變高(要等網路來回加上 Replica 處理時間)。非同步複寫(Asynchronous Replication):Primary 寫完自己的資料就立刻回應 client 成功,不等 Replica 確認——寫入延遲低,但 Replica 可能落後,若 Primary 在傳播完成前掛掉,這筆已經回應成功的寫入可能在所有 Replica 上都遺失(因為 Replica 從來沒收到)。
同步複寫犧牲寫入延遲/吞吐換取資料安全性(呼應 Day 51 CP 傾向);非同步複寫犧牲「保證不遺失已確認的寫入」換取低延遲/高吞吐(呼應 AP 傾向)。多 Replica 也不是越多越好——若採同步複寫,等待確認的節點越多,延遲越高;若採非同步複寫,每個 Replica 各自有各自的 Replication lag,Lag 越大,讀到過期資料的機率與嚴重程度越高。
Failure Modes:Primary 掛掉、Replica 被升級(promote)成新 Primary 時,如果原本是非同步複寫,新 Primary 手上的資料可能落後於舊 Primary 掛掉前處理過的最後幾筆寫入——這幾筆「已經回應 client 成功,但還沒傳到任何 Replica」的寫入,在故障轉移(failover)後就永久遺失了,這是分散式系統裡「已提交的寫入卻消失」的典型成因;如果多個 Replica 之間的 Lag 差異很大,讀取被路由到 Lag 較大的 Replica 時,會看到明顯過期的資料,這正是 Day 52 Eventual Consistency 在 Replication 架構下的具體物理成因。
Backend Applications:PostgreSQL 的 Streaming Replication 就是這個模型的實作——Primary 把 WAL 串流給 Replica;許多系統對「金額」相關的寫入採用 Semi-synchronous Replication(至少等一個 Replica 確認,但不用全部確認)作為延遲與安全性的折衷;設計 read replica 架構時,Backend 工程師要清楚知道「讀取分散到 replica」這個優化,本質上是拿一致性(可能讀到過期資料)去換取讀取吞吐,不是沒有代價的免費午餐。
陌生題示範:情境——一個系統採用非同步複寫,一個 Primary + 兩個 Replica(Replica-A 通常落後 50ms,Replica-B 因為跨區域網路,通常落後 800ms)。Primary 突然故障,監控系統自動把 Replica-A 升級為新 Primary。請分析:(1) 哪些寫入可能遺失?(2) 為什麼選 Replica-A 而不是 Replica-B 升級比較合理?
推導:(1) Primary 故障前,任何「已經回應 client 成功、但還沒被 Replica-A 收到並套用」的寫入都可能遺失——具體範圍是 Primary 故障前最後 50ms(Replica-A 的典型 lag)內處理的寫入,這些寫入在 client 眼中已經成功,但新 Primary(Replica-A)上根本沒有這筆資料,之後也不會再出現(因為舊 Primary 已經掛了)。(2) 選 Lag 較小的 Replica-A 升級,是因為它比 Replica-B 更接近舊 Primary 的最新狀態——升級成新 Primary 後遺失的寫入範圍(50ms 內)遠小於選 Replica-B 會遺失的範圍(800ms 內);這也說明 failover 機制設計時,「選哪個 Replica 接手」本身就是一個要主動比較 Lag 大小的決策,不能隨機選。
Day 53 過關標準(DoD)—— 具體情境分析
```text 情境:一個金融交易系統要求「任何已經回覆使用者交易成功的請求,之後絕對不能遺失」(Zero data loss on acknowledged writes)。目前架構是 1 個 Primary + 2 個 Replica,全部走非同步複寫,Replica 平均 Lag 30ms。
分析:1. 目前架構違反「Zero data loss」要求——非同步複寫下,Primary 回應「成功」的當下,Replica 可能都還沒收到這筆寫入;若這時 Primary 立即故障,這筆已確認成功的交易永遠遺失(沒有任何 Replica 有這筆資料)。2. 修復方向:改採 Semi-synchronous Replication——Primary 收到寫入後不立刻回應,而是等到「至少一個 Replica 確認已收到並套用這筆寫入的 WAL」才回應 client 成功;即使 Primary 之後故障,至少有一個 Replica 保證有這筆資料,故障轉移後升級這個 Replica 為新 Primary 不會遺失這筆已確認的交易。3. 代價量化:假設 Primary 到 Replica 的網路來回時間(RTT)是 5ms,改成 Semi-sync 後,每筆寫入的延遲會從「原本只等 Primary 自己處理完(例如 2ms)」增加到「等 Primary 處理 + 等至少一個 Replica 確認(2ms + 5ms RTT ≈ 7ms)」,寫入延遲增加約 3.5 倍,但換到「已確認的寫入不會遺失」這個對金融交易系統而言不可退讓的保證。4. 結論:對「資料遺失等於業務事故」的場景,Semi-sync(而非全非同步)是必要的最低限度,全同步(等全部 Replica 確認)則進一步犧牲更多延遲換取「更多份拷貝立即可用」,是否需要視容錯要求(能容忍幾個節點同時故障)而定。```
Day 54 — Sharding:把資料本身切開,突破單機容量與寫入上限
學習目標
看完今天內容後,能夠:
- 解釋 Sharding 跟 Replication 目的上的根本差異(切開資料 vs 複製資料),說出 Sharding 解決的問題是 Replication 解決不了的。
- 對比 Hash Sharding 與 Range Sharding 的分佈特性與 range query 能力,並各自舉出典型的失敗案例。
- 對一個「單一 shard 熱點」情境,診斷成因並提出至少一種修復方案。
教材大綱
Sharding
Core FundamentalsLv.4What:Sharding(分片)是把一份資料集切成多個子集(shard),分散存放到不同節點上的技術,跟 Replication「同一份資料存多份拷貝」的目的不同——Sharding 是「同一份資料集只存一份,但拆開放」,目的是突破單一節點的儲存容量與寫入吞吐上限(Replication 只能提高讀取吞吐與容錯,寫入仍全部要經過同一個 Primary;Sharding 才能真正把寫入負載也分散到多台機器)。
當資料量或寫入吞吐大到單一節點(即使搭配 Replication)也撐不住時(例如一張表有幾十億筆資料,遠超單機磁碟或記憶體容量,或寫入 QPS 遠超單一 Primary 能處理的上限),必須把資料本身切開,分散到多個獨立的 Primary(每個各自負責一部分資料、各自能接受寫入)——這是 Replication 無法解決、只有 Sharding 能解決的問題。
兩種主要分片策略:
Hash Sharding:對每筆資料的某個 key(例如 user_id)算 hash,用hash(key) % N(N 為 shard 數量)決定這筆資料放哪個 shard。優點是資料分佈通常很均勻(好的 hash function 讓每個 shard 拿到差不多筆數的資料),不會有「某些 shard 特別肥大」的問題;缺點是同一個 shard 上的資料彼此沒有明顯關聯,做 range query(例如「查 user_id 100 到 200 之間的所有資料」)沒辦法只查一個 shard,得查遍所有 shard 再合併結果。
Range Sharding:依照 key 的值域切段,例如 user_id 1–1000000 放 shard-1、1000001–2000000 放 shard-2。優點是 range query 效率高(查一段連續範圍通常落在同一個或少數幾個 shard);缺點是資料分佈容易不均勻——如果 key 是遞增的(例如用自增 ID 或時間戳當 sharding key),最新資料永遠往同一個(最後一個)shard 塞,造成「熱點 shard」(hotspot),這個 shard 的寫入負載遠高於其他 shard,違背了 Sharding 分散負載的初衷。
Hash Sharding 犧牲 range query 效率換取資料分佈均勻;Range Sharding 犧牲分佈均勻(有熱點風險)換取 range query 效率。實務上常見混合策略:用 Hash Sharding 決定資料放哪個 shard,但在同一個 shard 內部用 Range 排序儲存,部分兼顧兩者的優點。
Failure Modes:Range Sharding 若直接用遞增 ID 或時間戳當 key,最典型的失敗案例是寫入熱點——例如一個以 created_at 排序分片的 log 系統,所有「現在」產生的新資料全部落在同一個(最新時間範圍的)shard,其他 shard 完全閒置,等於整個系統的有效寫入吞吐被壓縮回單一 shard 的上限,Sharding 帶來的擴展性完全沒發揮;Hash Sharding 若 hash function 選得不好(或 shard 數量與資料特性沒對齊),也可能出現某幾個 hash bucket 特別擠的資料傾斜(data skew),雖然機率遠低於 Range Sharding 的熱點問題。
Backend Applications:設計一個高流量的「使用者事件記錄」系統時,若用 event_id(遞增)做 Range Sharding,幾乎必然遇到熱點;改用 hash(user_id) % N 分片,同一個使用者的所有事件會落在同一個 shard(方便查詢「這個使用者的所有事件」),且不同使用者均勻分散到不同 shard,避免熱點——這正是 Day 55 要學的 Consistent Hashing 解決「shard 數量改變時,Hash Sharding 要怎麼盡量少搬資料」這個延伸問題的前置理解。
陌生題示範:情境——一個訂單系統原本用 order_id(自增整數,隨時間單調遞增)做 Range Sharding,4 個 shard 各自負責一段連續的 order_id 範圍。上線後發現:99% 的寫入流量都打在同一個 shard(負責目前最大 order_id 範圍的那個),其他 3 個 shard 幾乎沒有寫入流量。請診斷原因,並提出至少一種修復方案(不能只說「改用別的資料庫」)。
推導:原因——order_id 隨時間單調遞增,Range Sharding 依 id 值域切段,代表「現在」產生的所有新訂單,order_id 都落在目前最大的那個區段,也就是同一個 shard;隨著時間推進,新訂單永遠打在「當前最新區段」對應的那個 shard,形成典型的 Range Sharding 熱點。修復方案:(1) 改用 Hash Sharding,對 order_id(或改用 user_id)算 hash 決定 shard,讓新訂單均勻打散到所有 shard,代價是「查某個 order_id 區間的所有訂單」這類 range query 需要查遍所有 shard 再合併;(2) 若必須保留 range query 能力,改成複合 sharding key(例如先用 hash(order_id) 決定大分區,分區內部再依 order_id 排序),在均勻分佈與 range query 效率間取折衷;選哪個方案要回頭確認這個系統的查詢模式究竟更依賴「均勻寫入」還是「range 查詢」。
Day 54 過關標準(DoD)—— 具體情境分析
```text 情境:一個物聯網(IoT)感測器資料系統,目前用 Range Sharding,sharding key 是 device_id(裝置註冊時依序遞增分配的整數 ID),4 個 shard 平均切分 device_id 值域。上線半年後,維運發現:新裝置集中在最近註冊的少數幾個批次(因為公司剛簽下幾個大客戶,一次註冊上萬台裝置),這些新裝置的資料寫入量遠高於舊裝置(新裝置回報頻率更高),導致負責「最新 device_id 區段」的那個 shard 負載遠高於其他 3 個。
分析:1. 這是 Range Sharding 熱點問題的變形——不是「key 本身遞增造成寫入集中在同一 shard」(今天教材的主要案例),而是「shard 內的資料特性不均勻」(新裝置回報頻率高於舊裝置,而新裝置又恰好都落在同一個 shard),本質上仍是 Range Sharding「依 key 值域切分」沒有考慮「每筆資料實際負載不均」的先天限制。2. 短期修復:把負載過高的那個 shard 進一步拆分成 2 個更小的 shard(re-shard),暫時緩解,但沒有解決「新一批裝置未來還是會集中在某個 shard」的根本問題。3. 長期修復:改用 Hash Sharding(hash(device_id) % N),讓裝置不論註冊時間先後,都均勻打散到所有 shard——代價是「查某個時間範圍內註冊的所有裝置」這類 range query 需要查遍所有 shard 再合併,需要評估這類查詢在系統裡的重要性/頻率是否可以接受這個代價。4. 結論:這個案例說明 Range Sharding 的熱點風險不只發生在「key 本身單調遞增」的教科書案例,任何「key 的值域切段方式,與資料實際負載強相關」的情境都可能出現類似問題,選 sharding 策略前要分析「負載跟哪個維度相關」,不能只看 key 的分佈是否均勻。```
Day 55 — Consistent Hashing:讓節點增減時只搬動一小部分資料
學習目標
看完今天內容後,能夠:
- 解釋 Hash Ring 的運作方式,說出資料如何在環上找到自己歸屬的節點。
- 推導 Node Join / Node Leave 時,Consistent Hashing 相較於單純
hash(key) % N減少了多少搬遷成本。 - 解釋 Virtual Node 解決的負載不均問題,並能對一個負載不均的情境提出修復方案。
教材大綱
Consistent Hashing
Core FundamentalsLv.4What:Consistent Hashing 是 Hash Sharding 的進階版本,解決「當 shard/節點數量改變(加入或移除節點)時,Hash Sharding 用hash(key) % N 要重新計算幾乎所有資料要搬去哪裡」的問題。做法是把 hash 值域想像成一個環(Hash Ring,例如 0 到 2^32-1 首尾相接),每個節點在環上被分配一個或多個位置(用節點自身的 ID 算 hash 決定其位置),每筆資料算出自己的 hash 值後,沿著環「順時針找到第一個節點」,就是這筆資料所屬的節點。
用 hash(key) % N 的 Hash Sharding,如果 N 從 4 變成 5(加一台新機器),幾乎每一筆資料的 hash(key) % N 結果都會變(因為除數變了),代表幾乎全部資料都要重新搬動位置——這在資料量大的系統裡是災難性的成本(要搬動 TB 甚至 PB 等級的資料,且搬動期間服務可能要暫停或降級)。Consistent Hashing 的環狀設計讓「加一個節點」只會影響環上這個新節點附近的一小段資料(原本歸屬給環上下一個節點的那一小段,現在改歸屬給新節點),其他資料完全不受影響,把「擴充/縮減節點」的搬遷成本從「幾乎全部資料」降到「平均 1/N 的資料」。
具體推導——假設環的 hash 值域是 0–99(簡化),有 3 個節點分別位於環上位置 10、40、70(用節點 ID 算 hash 決定)。一筆資料 hash 出來是 55,沿環順時針找「大於等於自己 hash 值的第一個節點」——55 之後最近的是位置 70 的節點,所以這筆資料歸屬給它;hash 出來是 5 的資料,順時針找到的是位置 10 的節點(5 到 10 之間沒有其他節點)。
Node Join:新增一個節點,算出它在環上的位置(例如 25)。這只會影響「原本歸屬給位置 40 節點(因為它是原本落在 10 到 40 之間的資料,順時針第一個碰到的節點)」的那一段資料裡,離 25 較近的那一小段——這一小段的資料改成順時針先碰到新節點 25,歸屬轉移給它;40 之後、70 之前的資料,以及 70 之後、10 之前(繞過環首尾相接處)的資料完全不受影響。只有「原本歸屬給節點 40 的資料中,落在 10 到 25 之間」的那一小段需要搬遷。
Node Leave(節點故障或主動下線):與 Join 相反,該節點原本負責的一整段資料,全部轉移給環上順時針方向緊接著的下一個節點,其他節點的資料完全不受影響。
Virtual Node(虛擬節點):直接把每個實體節點在環上只放一個位置,會有兩個問題:其一,環上位置是用 hash(節點 ID) 決定,運氣不好時,幾個實體節點的位置可能剛好聚在環的一小段,造成負載不均(有的節點負責的環弧段長,有的短);其二,節點數量少時(例如只有 3 個),環被切成的弧段數量也少,資料分佈的均勻度天生就受限。解法是讓每個實體節點在環上對應「多個」虛擬位置(例如每個實體節點對應 100–200 個虛擬節點,分散在環的不同位置,實際存取時虛擬節點再對應回它所屬的實體節點)——虛擬節點數量夠多時,統計上每個實體節點分到的環弧段總長度會趨於平均,即使實體節點數量少,也能靠增加虛擬節點數彌補分佈不均的問題。
Consistent Hashing 換取「節點增減時搬遷成本大幅降低(平均 1/N)」,代價是實作複雜度遠高於單純的 hash(key) % N(需要維護環結構,查找「順時針第一個節點」通常要用排序資料結構,例如平衡樹或跳表,維持 O(log N) 查找);Virtual Node 進一步改善負載均勻度,代價是要維護「虛擬節點 → 實體節點」的映射表,虛擬節點數量選擇本身也是一個需要調整的參數(太少均勻度不夠,太多維護與查找成本增加)。
Failure Modes:如果不用 Virtual Node,且實體節點數量少(例如剛上線只有 2–3 台機器),Consistent Hashing 的負載不均問題可能比預期嚴重——某個節點在環上恰好負責一大段弧,承受遠高於平均的流量,這時「理論上均勻分散」的假設會失效;Node Leave 是故障(不是主動下線)時,該節點原本負責的資料段,如果沒有搭配 Replication(同一段資料在多個節點各有拷貝),這段資料會直接不可用,Consistent Hashing 本身只解決「資料放哪裡」的問題,不解決「資料只有一份會不會遺失」的問題,需要跟 Day 53 的 Replication 搭配使用。
Backend Applications:分散式快取系統(例如一個自建的 Memcached/Redis Cluster 分片層)是 Consistent Hashing 最典型的應用場景——快取節點加減(擴容、機器故障重啟)是常態,如果用hash(key) % N 的簡單分片,一次擴容會讓幾乎所有快取 key 瞬間對應到錯誤的節點,等於瞬間全部快取失效(cache stampede,大量請求同時打到後端資料庫);Consistent Hashing 讓這次擴容只有一小部分 key 的快取「失效」(找不到自己原本的資料,要重新從資料庫載入),大幅降低擴容當下對後端的衝擊。
陌生題示範:情境——一個 4 節點的 Consistent Hashing 叢集(無 Virtual Node),環上位置分別是 0、25、50、75(值域 0–99),目前發現這個叢集「新增一個節點到環上位置 60」後,某個節點的負載忽然暴增到接近全部流量的一半。請診斷可能原因,並提出至少一種修復方案。
推導:先確認「新增節點到 60」本身不該造成暴增(新增節點只會把 60 之前、原本歸屬給 75 節點的一小段資料轉移給新節點 60,理論上應該讓 75 節點負載下降,其他節點不受影響);如果反而某節點暴增到接近一半流量,代表問題不是這次新增操作造成的,而是原本沒有 Virtual Node 的先天負載不均問題被放大顯現——4 個節點在環上位置分別是 0、25、50、75,看似均勻(每 25 一個),但如果資料 key 的 hash 分佈本身有偏態(不是均勻分佈),或者其中一段弧恰好對應到熱門 key 集中的區域,沒有 Virtual Node 分散風險,這段弧的負載就會集中在對應的單一節點上。修復方案:幫每個實體節點配置多個(例如 100–200 個)Virtual Node,分散在環的不同位置,讓「同一個實體節點負責的多段弧」加總起來趨近統計均勻,降低「單一弧段恰好對到熱點」時對整體負載的影響。
Day 55 過關標準(DoD)—— 具體情境分析
```text 情境:一個分散式快取叢集用 Consistent Hashing,目前有 8 個實體節點,各自配置 150 個 Virtual Node(共 1200 個虛擬位置分散在環上)。維運計畫把叢集擴容到 10 個節點(新增 2 台)。假設目前每個節點各自快取約 100GB 資料,叢集資料量與流量在擴容前後大致不變。
分析:1. 用 Consistent Hashing(且有 Virtual Node 保證分佈均勻)擴容時,理論搬遷比例約為「新增節點數 ÷ 擴容後總節點數」:新增 2 個節點、擴容後共 10 個,約 2/10 = 20% 的資料需要搬遷到新節點(原本歸屬給舊節點、剛好落在新節點附近弧段的那部分資料)。2. 對照:如果原本用的是 hash(key) % N 的簡單 Hash Sharding(沒有 Consistent Hashing),N 從 8 變成 10,幾乎每一筆資料的hash(key) % N 結果都會改變(除數變了,餘數重新分佈),保守估計超過 80–90% 的資料都要重新搬遷——用具體數字對照:8 節點、共 800GB 資料,Consistent Hashing 擴容約搬 800GB × 20% = 160GB;簡單 Hash Sharding 擴容可能要搬 800GB × 85% ≈ 680GB,差距超過 4 倍。3. Virtual Node 的角色:若沒有 Virtual Node(每個節點只在環上一個位置),這次擴容「理論上 20%」的估計值會因為負載不均而失準——可能新節點恰好插在環上「冷門」的一段,實際搬遷量遠低於 20%,也可能插在「熱門」一段,搬遷量遠高於 20%,且擴容後兩個新節點各自分到的負載也可能明顯不均。有了 150 個 Virtual Node 分散風險,實際搬遷比例會很接近理論值 20%,且新節點分到的負載也會接近平均。4. 結論:Consistent Hashing + Virtual Node 讓這次擴容的資料搬遷成本可預期、可估算(約 20%、160GB),而不是簡單 Hash Sharding 那種「幾乎全部重新分配」的不可控成本,這正是 Day 51–55 從 CAP 到 Consistent Hashing 一路建立的知識鏈:分散式系統的擴充/縮減操作本身,也要被當成一個需要分析代價的「情境」,不是「多加一台機器就好」這麼簡單。```
Day 56 — Cache:四種讀寫策略,決定「誰負責跟資料庫打交道」
學習目標
看完今天內容後,能夠:
- 說出 Cache Aside、Read Through、Write Through、Write Behind 四種策略各自的讀寫路徑,指出每種策略裡「應用程式碼」跟「cache 層本身」誰負責真正去跟資料庫互動。
- 對一次 cache miss(快取沒命中),畫出四種策略各自的處理流程差異。
- 對一個具體場景判斷該選哪種寫入策略,並用一致性與效能的取捨說明理由。
教材大綱
Cache 四種讀寫策略
Core FundamentalsLv.4What:Cache 是放在應用程式與主要資料儲存(通常是資料庫)之間、存取速度快很多但容量小很多的暫存層,目的是讓「重複被讀取的資料」不用每次都打到較慢的資料庫。依照「cache miss 時誰負責去源頭載入資料」以及「寫入操作先打 cache 還是先打資料庫、由誰負責同步兩邊」,可以分成四種策略:Cache Aside、Read Through、Write Through、Write Behind。
如果每次讀取都直接查資料庫,資料庫很快就會被「大量重複、其實答案不太會變」的讀取請求(例如商品詳情、使用者個人資料)壓垮;Cache 把這類讀取攔在資料庫前面,用速度快很多的記憶體層級儲存回應。但「什麼時候該把資料放進 cache、放進去之後資料庫改了要怎麼辦」不是只有一種做法,四種策略是四種不同的責任劃分方式,各自在「實作複雜度落在誰身上」與「資料一致性代價」之間做出不同取捨。
- Cache Aside(Lazy Loading,最常見):讀取時,應用程式先查 cache;命中就直接回傳;沒命中(miss)則應用程式自己去查資料庫,拿到結果後手動寫回 cache,再回傳給呼叫端。寫入時,應用程式直接寫資料庫,然後把 cache 裡對應的舊 entry 刪除(不是更新成新值——為什麼是刪除而不是更新,Day 57 會詳細推導)。應用程式同時負責「讀 miss 時填 cache」與「寫入後讓 cache 失效」兩件事,cache 層本身很單純(只是一個 key-value 儲存),不知道資料庫的存在。
- Read Through:跟 Cache Aside 的差異在於「誰負責 miss 時去源頭載入」——這裡改成 cache 層本身知道怎麼去源頭(資料庫)載入資料,應用程式只跟 cache 打交道,miss 時 cache 層自己去查資料庫、填好自己,再回應給應用程式;應用程式的程式碼裡完全不出現「查資料庫」這個分支。
- Write Through:寫入請求先送到 cache 層,cache 層收到後同步寫入資料庫,等資料庫確認寫入成功,才回應應用程式「寫入成功」;cache 裡的值在寫入當下就已經是最新值,跟資料庫永遠同步(因為每次寫入都是「cache 跟 db 一起寫、一起確認」)。
- Write Behind(Write Back):寫入請求只先寫進 cache,cache 層立刻回應「寫入成功」(不等資料庫),資料庫的實際寫入是之後才非同步、通常是批次(batch)進行的。應用程式得到很低的寫入延遲,代價是資料庫暫時落後於 cache 的最新狀態,而且如果 cache 在「非同步寫回資料庫」完成之前當機或資料遺失,這些還沒被真正寫進資料庫的變更會永久遺失。
Cache Aside 實作簡單、彈性大(不需要 cache 層知道資料庫的存在),是四種裡最常見的預設選擇,但寫入後到下次讀取重新載入之間可能有短暫的 cache/資料庫不一致(Day 57 展開);Read Through 讓應用程式邏輯更乾淨,但把「怎麼查資料庫」這個複雜度移進 cache 層本身,cache 層變成一個更重的元件、也成為讀取路徑上不能繞過的單點依賴;Write Through 保證 cache 與資料庫隨時同步,代價是每次寫入都要多等一次資料庫確認,延遲比 Write Behind 高;Write Behind 換來最低的寫入延遲,但承擔資料遺失風險與資料庫短暫落後的代價,通常只用在能接受「偶爾遺失一小段最新寫入」的場景。
Failure Modes:Cache Aside 若 cache 層整個掛掉重啟(所有 entry 一次清空),大量請求同時 miss、同時湧向資料庫(thundering herd/cache stampede),可能瞬間打垮原本被 cache 保護著的資料庫;Write Behind 若 cache 在批次寫回資料庫之前當機,這批還沒落地的變更就此消失,且應用程式完全不會知道(因為當初回應的是「寫入成功」);Read Through/Write Through 因為 cache 層本身要負責跟資料庫溝通,若 cache 層的資料庫連線設定或逾時設得不好,會把資料庫的問題直接放大成 cache 層的問題。
Backend Applications:Redis 搭配應用程式手動控制讀寫,是 Cache Aside 最典型的實作方式;CDN 的運作模式接近 Read Through(CDN 節點本身知道怎麼去源站抓取沒快取過的內容);資料庫本身的 Buffer Pool(Phase 04 教過)在某些實作裡也採用類似 Write Behind 的概念(先寫進記憶體中的 dirty page,稍後才真正 flush 到磁碟),是同一套「先寫快取層、稍後才寫真正持久層」思路在不同層級的應用。
陌生題示範:情境——一個電商網站的商品詳情頁,流量極高,幾乎全部是讀取(瀏覽商品),寫入極少(只有客服後台偶爾調整庫存數字或商品描述)。請問該選哪種 cache 策略,為什麼?
推導:選 Cache Aside 最合適。這個場景讀多寫少,miss 時才載入、命中時直接從 cache 回應,配合適當的 TTL(Day 57 展開)即可大幅降低資料庫負載;寫入極少,代表「寫入後讓 cache 失效」的額外邏輯執行頻率很低,不需要為了極少數的寫入引入 Write Through 那種「每次寫入都多付出一次同步等待」的開銷,也不需要 Read Through 把「怎麼查資料庫」這個複雜度搬進 cache 層——應用程式的讀取邏輯本來就簡單(miss 就查一次資料庫),沒有理由為此增加 cache 層的複雜度。
Day 56 過關標準(DoD)—— 具體情境分析
```text 情境:一個新聞網站的熱門文章頁面採用 Cache Aside,cache 沒有設定 TTL(只在文章被編輯時才手動 invalidate)。某天凌晨,負責 cache 的 Redis 叢集因為維護重啟,所有 cache entry 一次清空;重啟完成的瞬間,早上通勤尖峰時段剛好開始,大量使用者同時湧入讀取當天的熱門文章。
分析:1. 這是典型的 cache stampede(快取雪崩)情境——cache 清空後,第一波大量並發讀取請求全部 miss,每個請求(如果沒有額外保護)都各自去查一次資料庫,而且很可能是查詢「同一篇」熱門文章,資料庫瞬間收到大量重複、其實答案完全一樣的查詢。2. 沒有 TTL 讓問題更難自然緩解——因為過去這個 cache 從來不會自然過期,資料庫平常幾乎不會被直接打到,資料庫本身可能連容量規劃都沒有為「突然全部 miss」的情境準備,重啟後的瞬間流量峰值遠超資料庫平常的負載。3. 修復方向:(a) 為熱門內容設定合理 TTL 並搭配隨機抖動(jitter,讓不同 entry 的過期時間不完全相同),避免大量 entry 同一時刻一起過期而重演這次的問題;(b) 對同一個 cache key 的 miss,加上「只讓第一個請求真的去查資料庫,其他並發請求等待這次查詢結果後直接複用」的機制(常見做法是查詢前先嘗試取得一把該 key 的鎖,拿不到鎖的請求短暫等待後重新查 cache,而不是各自直接打資料庫);(c) Redis 叢集重啟前,可以考慮先從舊叢集匯出熱門 key 預先載入新叢集(warm-up),避免完全空白重啟。4. 結論:Cache Aside 雖然實作簡單,但「cache 大規模同時失效」這個情境本身需要額外設計(TTL 抖動、查詢合併/鎖)來緩解,不能假設「cache miss 只會零星發生」。```
Day 57 — Cache Invalidation:讓快取追上資料庫的最新狀態
學習目標
看完今天內容後,能夠:
- 說出 stale data、race、TTL、invalidation 這幾個機制之間的關係,解釋為什麼 cache 幾乎必然會遇到「資料過期」的問題。
- 具體推導一個 cache invalidation 的 stale data race 情境,並提出至少一種解法。
- 說明 cache invalidation 策略選擇背後的一致性(consistency)與效能的 trade-off。
教材大綱
Cache Invalidation
Core FundamentalsLv.4What:Cache 本質上是資料庫資料的一份拷貝。當資料庫裡的資料被更新後,如果 cache 裡對應的舊拷貝沒有被清除或更新,就會變成stale data(過期資料)——讀者讀到的是還沒反映最新寫入的資訊。Invalidation(失效化)是「讓 cache 知道自己手上的資料已經過期,該被清除或重新載入」這整套機制的統稱。
Cache 存在的前提就是「跟資料庫維持一份幾乎一樣、但可能有落差的拷貝」;一旦資料庫變了、cache 沒跟著變,兩邊就不一致。系統必須有一個明確的策略,決定「什麼時候、用什麼方式讓 cache 追上資料庫的最新狀態」,否則使用者會持續看到過期的資訊——這正是 Day 52 Consistency 討論的「一致性 vs 可用性/效能」取捨,具體發生在 cache 這個場景。
兩種主要機制,通常搭配使用:
- TTL(Time To Live):每筆 cache entry 設定一段存活時間,時間到了自動視為失效——下次讀取會變成 cache miss,重新從資料庫載入最新值。這是最簡單、幾乎每個 cache 系統都會用的機制,代價是「在 TTL 到期之前,這段時間內看到的都可能是過期資料」,TTL 越長,可能過期的時間窗越大。
- Invalidation on write(寫入時主動失效):資料被寫入資料庫的同時,主動刪除(或更新)cache 裡對應的 entry,不用等 TTL 到期才修正。這比純 TTL 更即時,但要求寫入路徑本身要知道「這次寫入會影響哪些 cache key」,寫入邏輯與 cache 邏輯必須綁在一起維護。
Stale Data Race 的具體推導(今天的核心):即使用了 invalidation on write,仍然存在一個經典的 race 情境——這裡的「race」沿用 phase-05-concurrency.md Day 44 教過的 Race Condition 定義(多個操作同時存取同一份共享狀態,結果依賴誰先誰後完成),情境從「兩個 thread 同時存取同一塊記憶體」換成「一次 cache 讀取的『寫回 cache』動作,與一次 cache 失效同時發生」:
```text 時間軸(key = "product:123",DB 值從 v1 更新成 v2):
t0 Client A 讀取 product:123 → cache miss(cache 目前是空的)t1 Client A 開始查資料庫,讀到 v1(此時資料庫還是舊值)t2 Client B 更新資料庫 product:123(v1 → v2)t3 Client B 依 invalidation on write,刪除 cache 裡 product:123 這個 entry(此時 cache 本來就是空的,這一步沒有實際效果)t4 Client A(t1 查資料庫比較慢,現在才拿到結果)把自己讀到的舊值 v1 寫進 cache
結果:資料庫已經是 v2,但 cache 裡長期停留在 v1——而且不會被自動修正,因為 Client B 的 invalidation(t3)發生在 Client A 把 v1 寫進 cache(t4)之前,之後不會再有任何寫入操作觸發下一次 invalidation,這筆過期資料會一直留在 cache 裡,直到 TTL 到期(如果有設定的話)。```
解法(至少需要一種,以下列出常見的三種):
- 短 TTL 作為保底:即使發生上述 race,最壞情況也只會讓過期資料停留到 TTL 到期為止,把「無限期停留」的風險上限收斂成一個可控的時間窗。這是最簡單、幾乎必然要有的保底機制,但不解決 race 本身。
- 版本號/時間戳比對:Client 從資料庫讀到資料時,一併記下這筆資料的版本號或最後更新時間;準備把資料寫進 cache 之前,先確認「目前 cache 裡(或資料庫裡)的版本是否已經被更晚的寫入超越」,如果已經超越就放棄這次寫入 cache(因為自己手上的是舊資料),避免用舊值覆蓋掉別人已經寫入的新狀態。
- 調整寫入順序,優先「先寫 DB 再 invalidate cache」:把 Client B 的動作順序固定成「先完成資料庫寫入,再刪除 cache」(而不是反過來),可以大幅縮小上述 race 發生的時間窗(詳見下方陌生題示範的推導),雖然不能百分之百消除,但配合方案 1 的短 TTL,已足以把風險壓到大多數業務場景可接受的範圍。
TTL 越短,過期資料停留的時間窗越小(一致性更好),但 cache 命中率越低(更多請求變成 miss,打回資料庫,等於犧牲了 cache 原本要節省的負載);invalidation on write 讓資料更即時,但增加寫入路徑的複雜度(每個會影響資料的寫入邏輯都要記得觸發對應的 invalidation),且如上所述,仍然存在 race 窗口,不能單獨依賴它保證絕對即時。
Failure Modes:最常見的失效模式是「寫入路徑忘記觸發某個 cache key 的 invalidation」——這筆資料在資料庫早已更新,但因為程式碼裡漏寫了對應的 cache 清除邏輯,cache 裡的舊值會一直存在,直到 TTL 到期才被動修正(如果 TTL 設得很長甚至沒設,這筆資料可能永遠不會自動修正);TTL 設太長,會讓「使用者反映『我剛剛才改的資料,怎麼還是顯示舊的』」這類客訴變得頻繁。
Backend Applications:Redis 常見的做法是「更新資料庫後刪除 cache key(DEL),而不是直接把 cache 更新成新值(SET)」,理由正是上面推導的 race——如果兩個並發寫入的完成順序顛倒,直接SET 新值可能被另一個較舊的寫入覆蓋回更舊的值,而 DEL 則是讓下一次讀取重新從資料庫載入最新值,即使順序顛倒,最終讀到的也會是當下資料庫的真實狀態;CDN 的 cache purge(清除)機制,本質上就是 invalidation 在跨地理節點場景下的具體應用。
陌生題示範:情境——工程師在設計 Cache Aside 的寫入路徑時,糾結「先刪 cache、再寫資料庫」還是「先寫資料庫、再刪 cache」哪個更安全?
推導:「先刪 cache、再寫資料庫」反而更容易造成長期 stale——考慮以下時序:Client B 先刪除 cache(此時 cache 變空),這時如果有另一個讀取請求 Client A 剛好進來,發現 miss,去查資料庫,這時 Client B 的資料庫更新可能還沒完成,Client A 讀到的仍然是舊值 v1,並把 v1 寫回 cache;接著 Client B 才完成資料庫更新(v1 → v2)。結果 cache 裡是 v1(過期),而且這次不會再被任何後續動作修正,直到 TTL 到期或下一次寫入才會被刷新。反之「先寫資料庫、再刪 cache」,本題上方推導的 race 雖然仍有可能發生,但視窗明顯更小(Client A 必須剛好在 Client B 完成資料庫寫入之後、但 Client A 自己拿著更早讀到的舊值去寫回 cache 這兩個動作之間插入),且發生機率遠低於前者。因此大多數 Cache Aside 的實務建議都是「先寫資料庫、再刪 cache」,並用短 TTL 兜底剩餘的風險。
Day 57 過關標準(DoD)—— 具體情境分析
```text 情境:一個電商網站的庫存扣減功能採用 Cache Aside,商品庫存數字(product:456:stock)有 cache,TTL 設定 60 秒,寫入路徑是「先扣減資料庫庫存、再刪除對應 cache key」。上線後客服陸續收到少量客訴:「頁面顯示還有庫存,下單卻失敗說已售完」。
分析:1. 這是 stale data 造成的體感問題——頁面顯示的庫存數字來自 cache,如果 cache 裡的值比資料庫實際值更新(更大),使用者會看到「看似還有貨」的過期數字,實際送出訂單時,後端下單邏輯是直接查資料庫(或有額外的併發保護機制)確認真實庫存,才會出現「顯示有庫存、下單卻失敗」的落差。2. 根本原因:即使寫入路徑是「先寫資料庫、再刪 cache」,仍可能發生本日教材推導過的 race——在某次扣減庫存的資料庫寫入完成、但 cache 刪除還沒執行完成的極短時間窗內,剛好有另一個讀取請求 miss(例如 TTL 剛好到期)並把「扣減前」的舊庫存數字重新寫回 cache,導致 cache 裡短暫停留一個比實際庫存更大的過期值,直到下一次寫入或 TTL 到期才會修正。3. 修復方向:(a) 對「庫存數字」這類「顯示值與實際可用性直接掛鉤」的資料,縮短 TTL(例如從 60 秒降到 5–10 秒),把過期視窗壓到使用者不太會注意到的範圍;(b) 更根本的作法是不讓「顯示用的庫存數字」直接等於「真正扣減用的庫存數字」——頁面顯示可以容忍些微延遲(Eventual Consistency 可接受),但真正決定「能不能下單」的判斷永遠直接查資料庫或用資料庫層級的原子扣減(例如UPDATE ... WHERE stock > 0),不讓下單流程依賴可能過期的 cache 值。4. 結論:庫存這類「顯示不精確可以忍受,但實際交易判斷絕不能靠可能過期的 cache」的資料,正確做法是把「讀取展示」與「交易判斷」兩條路徑分開處理,只用 cache 加速前者,後者永遠繞過 cache 直接對資料庫做原子操作,這樣即使 cache 短暫 stale,也不會造成「顯示有庫存、下單卻失敗」以外更嚴重的超賣問題(呼應 Day 51 CAP 對「庫存」這類資料選 CP 的結論)。```
Day 58 — Messaging:Producer / Consumer / Queue 解耦,以及訊息可能重複、亂序的現實
學習目標
看完今天內容後,能夠:
- 說出 Producer、Consumer、Queue、Retry、DLQ、Ordering、Delivery semantics、Idempotency 在一個訊息系統裡各自扮演的角色。
- 分辨 At-most-once、At-least-once、Exactly-once 三種 delivery semantics 的差異,並解釋為什麼純粹的 Exactly-once 在有網路的分散式系統裡幾乎不存在。
- 明確說出 Idempotency 這次(第二次)出現時,比 Day 5(Phase 01)多了哪兩個新變數,並說明為什麼需要額外的機制才能處理。
教材大綱
Producer / Consumer / Queue
Core FundamentalsLv.4What:Producer 是產生訊息並送進 Queue 的一方(例如訂單服務在訂單建立後產生一則「訂單已建立」事件);Queue 是暫存訊息的緩衝區,負責把 Producer 產生訊息的速度跟 Consumer 消化訊息的速度解耦開來;Consumer 是從 Queue 取出訊息並實際處理的一方(例如負責發送 email 通知的 worker)。
如果 Producer 直接同步呼叫 Consumer 的處理邏輯(例如訂單服務直接同步呼叫發信 API),Producer 這次請求的延遲會被 Consumer 實際處理時間拖累,而且一旦 Consumer 暫時不可用(例如發信服務當機或過載),Producer 這端的請求也會跟著失敗。引入 Queue 讓兩者解耦:Producer 只要把訊息放進 Queue 就能立刻回應使用者,不需要等到真正處理完成;Consumer 可以按照自己能負荷的速度、甚至用多個實例平行消化 Queue 裡累積的訊息,兩邊的故障也不會直接互相拖累。
具體流程——Producer 呼叫 Queue 的 API 把一則訊息(通常帶結構化內容,例如 {event: "order_created", order_id: 123})放進去,Queue 把訊息持久化(寫進磁碟或至少多副本記憶體,視系統而定)並回應 Producer「已收下」;Consumer(可能有多個實例)向 Queue 拉取(或由 Queue 推送)待處理訊息,處理完成後回報 Queue「這則訊息已成功處理」(ack,acknowledge),Queue 收到 ack 後才把這則訊息標記為完成、可以清除;如果 Consumer 在處理完成前當機、或處理失敗,Queue 沒收到 ack,會依照設定把這則訊息重新交給其他 Consumer 實例(Retry,下方展開)。
解耦換來的代價是「最終處理」而非「立即處理」——Producer 看到訊息已經送進 Queue,不代表這件事真正被處理完成了,需要額外的機制(下方 Delivery semantics)才能確認訊息真的被 consumer 妥善處理;引入 Queue 這個額外元件,本身也是新增的一個故障點與需要維運的基礎設施,不是沒有代價的純加分。
Failure Modes:如果 Queue 本身沒有做持久化(純記憶體暫存),Queue 所在的進程或機器當機,會讓所有還沒被 Consumer 取走的訊息瞬間全部遺失(呼應 Day 59 的 Lost Message);如果 Consumer 處理速度長期跟不上 Producer 產生訊息的速度,Queue 裡的訊息會無限累積(backlog),最終佔滿儲存空間,或造成訊息從產生到真正被處理之間的延遲不斷拉長,變得不可接受。
Backend Applications:Kafka、RabbitMQ、AWS SQS、NATS 都是這個模式的具體實現;Phase 08(Day 76–80)會把這裡的抽象概念,對應到 NATS 這個真實系統的具體 API 操作。
陌生題示範:情境——一個通知系統,如果訂單服務不透過 Queue,而是直接同步呼叫發信服務的 API 來發送「訂單已建立」通知,會遇到什麼具體問題?
推導:(1) 訂單建立這個請求的回應時間,會被發信服務的處理時間直接拖累——即使發信本身只需要 200ms,也等於每次下單都多付出這 200ms 延遲,使用者體感變差;(2) 發信服務如果暫時過載或當機,訂單建立的請求會因為這次同步呼叫失敗而跟著失敗(或需要訂單服務自己實作 retry 邏輯),把兩個本該獨立的功能(建立訂單、發送通知)綁死在同一次請求的成敗上,即使訂單本身其實已經成功建立;(3) 如果某天需要一次發送給大量使用者(例如促銷活動通知),同步呼叫模式無法平滑地把「產生通知」與「實際發送」的速度分開處理,容易讓發信服務瞬間過載。引入 Queue 後,訂單服務只需要把「訂單已建立」這個事件丟進 Queue 就能立刻回應,發信這件事交給獨立的 Consumer 按照自己的速度慢慢消化,兩邊互不拖累。
Retry / DLQ
Core FundamentalsLv.4What:Retry 是 Consumer 處理訊息失敗時,不直接放棄,而是讓這則訊息重新變成「可以被再次嘗試處理」的狀態(重新排入 Queue,通常延遲一段時間後才重試);DLQ(Dead Letter Queue,死信佇列)是「重試多次仍然失敗」的訊息最終被移去的地方,讓這類訊息不再繼續佔用正常佇列的資源、也不會無限重試下去。
Consumer 處理失敗的原因很多,有些是暫時性的(下游服務剛好短暫過載、網路瞬斷),重試一次很可能就成功;但有些是永久性的(訊息格式本身有問題、對應的資料在資料庫裡已經不存在),不管重試幾次結果都一樣。如果沒有上限,永久性失敗的訊息會被無限重試,持續佔用 Consumer 的處理資源、持續在系統裡製造錯誤日誌噪音,卻永遠不會真正成功;DLQ 提供一個「認賠出場」的機制,把這類訊息隔離開來,留給人工事後檢查或另外處理,讓正常訊息的處理不被卡住。
具體流程——Consumer 處理訊息失敗後,Queue 依照設定的重試策略(例如:延遲 1 秒、5 秒、30 秒,共重試 3 次,每次間隔遞增,即 exponential backoff)重新把訊息交給 Consumer;如果達到設定的最大重試次數後仍然失敗,Queue 把這則訊息移動到專屬的 DLQ,不再自動重試,改由監控告警通知人工介入。
重試次數與間隔設得太短、太密集,可能讓一次暫時性的下游過載被大量重試請求進一步惡化(雪崩效應);設得太長、次數太少,則可能讓本來很快就能自行恢復的暫時性失敗,被過早判定為永久失敗送進 DLQ。DLQ 讓「壞訊息」不再干擾正常流程,但 DLQ 裡堆積的訊息如果沒有人監控、定期處理,等於把問題丟到別處放著不管,跟訊息真的遺失在使用者體感上沒有兩樣。
Failure Modes:沒有 DLQ、也沒有重試次數上限時,一則格式錯誤、永遠處理失敗的訊息可能被無限重試,不只浪費運算資源,還會讓 Consumer 的錯誤日誌被同一個問題洗版,掩蓋掉其他真正需要關注的錯誤;有 DLQ 但沒有對應的監控告警,DLQ 裡堆積的失敗訊息不會被任何人發現,功能上等同於訊息真的遺失了。
Backend Applications:AWS SQS 原生支援設定 DLQ 與最大重試次數;這裡的機制與 Day 59 的 Timeout/Retry 情境直接相關——Consumer 判斷「這次處理算失敗、要觸發 retry」的依據,往往正是收不到下游服務在期限內回應(Timeout)。
陌生題示範:情境——一個訊息因為對應的收件人 email 帳號已經被使用者刪除(收件地址永久失效),導致發信一定會失敗。如果這個 Consumer 的重試設定是「無限重試、間隔固定 1 秒」,會發生什麼問題?
推導:這則訊息會被以每秒一次的頻率無限重試下去,因為失敗原因是永久性的(收件地址不存在),重試永遠不會成功;這不只浪費 Consumer 資源在一個注定失敗的任務上,如果 Queue 沒有把失敗訊息與其他正常訊息分開處理,還可能拖慢正常訊息的處理吞吐量;修復方式是設定合理的最大重試次數(例如 3–5 次)搭配遞增的重試間隔,超過上限後移入 DLQ,交由人工確認「這則訊息是不是真的不該再重試」(例如發現收件地址已失效,就把這則訊息標記為永久放棄,而不是持續佔用資源)。
Ordering / Delivery Semantics
Core FundamentalsLv.4What:Ordering(順序性)討論「多筆訊息是否按照送出的順序被處理」;Delivery Semantics(傳遞語意)討論「一筆訊息保證會被處理幾次」,常見有三種:At-most-once(最多處理一次,可能一次都沒處理到,但絕不會重複處理)、At-least-once(至少處理一次,保證會被處理到,但可能重複處理)、Exactly-once(保證恰好處理一次,不多不少)。
Producer 跟 Consumer 之間隔著網路與 Queue 這個中介,「訊息有沒有真的被處理、被處理了幾次、處理的順序跟送出的順序是否一致」這幾件事,在有網路延遲、機器可能故障的現實條件下,都不是天生保證的,必須由系統設計者明確選擇要提供哪一種保證,並理解自己選的保證代表什麼代價。
三種 delivery semantics 的實作方式——At-most-once 是 Producer 或 Queue 用「射後不理」(fire-and-forget)的方式送出,不等待 ack、也不重試,訊息如果在傳輸過程中遺失就真的遺失了,優點是實作最簡單、延遲最低;At-least-once 是 Consumer 處理完訊息後才回報 ack,Queue 在收到 ack 之前都當作「這則訊息還沒被成功處理」,如果 Consumer 在處理完成、但 ack 送出前當機,Queue 會依照 Retry 機制重新把這則訊息交給另一個 Consumer 實例,這保證訊息一定會被處理到,但也代表同一則訊息有可能被處理超過一次;Exactly-once 在理論上聽起來最理想,但在真正有網路、節點可能故障的分散式系統裡幾乎不存在「協定本身」的原生保證——實務上所謂的 Exactly-once 效果,通常是「At-least-once 送達 + Consumer 端自行去重(Idempotency,下方展開)」組合出來的結果,而不是底層傳輸協定真的只送一次。Ordering 方面,單一 Queue(或單一 partition)內部通常能保證訊息按照 FIFO(先進先出)的順序被交付;但如果用多個 Consumer 平行消化同一個 Queue,或訊息被分散到多個 partition,不同訊息之間實際被處理完成的順序就可能跟送出的順序不同。
At-most-once 延遲最低、實作最簡單,但會漏訊息,只適合「漏了也無所謂」的場景(例如非關鍵的統計埋點);At-least-once 是大多數正式訊息系統的預設選擇(不會漏訊息,但 Consumer 必須自己處理重複);Exactly-once(透過 At-least-once + 去重組合出來)實作複雜度最高,需要額外維護一份去重紀錄,且這份紀錄本身的保存期限與儲存成本也是要設計的參數。
Failure Modes:最常見的錯誤,是誤以為系統原生提供真正的 Exactly-once 語意,因而完全沒有在 Consumer 端加上任何去重機制——一旦網路重試造成訊息重複送達(At-least-once 的正常現象,不是 bug),就會真的發生重複處理的後果(例如重複發送同一封通知信、重複執行同一筆扣款)。
Backend Applications:Kafka 與 NATS JetStream 都是「At-least-once+ 需要 Consumer 自行實作 Idempotency 才能達到業務上等同 Exactly-once 效果」的實際案例,這也是為什麼下一個小節的 Idempotency 在 Messaging 情境下如此重要——它是把 At-least-once 「補完」成業務上可接受行為的必要環節,不是可有可無的錦上添花。
陌生題示範:情境——一個系統的 Consumer 假設自己用的訊息系統提供 Exactly-once 保證,因此完全沒有實作任何去重邏輯。上線後某次網路短暫不穩定,工程師發現同一位使用者收到了兩封一模一樣的訂單確認信。請診斷原因。
推導:這個訊息系統實際提供的是 At-least-once(幾乎所有主流訊息系統的預設都是如此,純粹的協定層級 Exactly-once 在有網路的分散式系統裡幾乎不存在),網路短暫不穩定導致 Consumer 處理完訊息、正要送出 ack 時網路中斷,Queue 沒收到 ack,判定這次投遞失敗,依 Retry 機制把同一則訊息重新交給 Consumer(可能是同一個或另一個實例)再處理一次——但實際上第一次的發信動作早已經成功執行,於是使用者收到了兩封信。修復方式:Consumer 端必須自行實作 Idempotency(見下方),而不是依賴系統本身提供不存在的 Exactly-once 保證。
Idempotency(分散式重試安全層級,第二次出現)
Core FundamentalsLv.4這是 Idempotency 第二次出現:phase-01-backend-cs-fundamentals.mdDay 5 定義過 Idempotency 在「HTTP method 語意層級」的範圍——「在一次 client 手動重試、request 本身沒有遺失或重複送達的前提下,同一個操作重複做結果要一樣」,並在該處明確標註這個定義不解決「網路本身讓同一則訊息送達兩次、系統要怎麼知道這是重複」的問題,留給 Distributed Systems(本 Phase)處理。今天要重新引入 Idempotency,並不是重新定義,而是在 Day 5 的定義之上疊加兩個新變數。
What:這裡談的 Idempotency,是「當重試不再是使用者手動點兩下、而是系統自動觸發,且訊息因為網路本身的行為(At-least-once delivery)可能真的被送達不只一次時,系統要如何確保『同一筆操作即使被處理兩次,最終效果與只處理一次相同』」。
Why(比 Day 5 多出的兩個新變數):Day 5 的定義建立在兩個前提上——重試是呼叫端手動觸發的、請求本身沒有遺失或重複送達;但在 Messaging 情境下,這兩個前提都不成立:(a) 重試變成系統自動觸發——Consumer 沒送出 ack,Queue 自動重新投遞,呼叫端(甚至 Producer)完全不知道上一次是否真的處理成功過,不像 Day 5 情境下呼叫端至少知道「我剛剛手動按了兩次」;(b) Partition/網路分區下,「這個操作到底做過沒」這件事本身可能在不同節點看到不同答案(呼應 Day 51 CAP)——如果用來判斷「是否已處理過」的去重紀錄本身是分散式儲存的,網路分割可能讓不同節點對同一筆訊息的處理狀態有不一致的認知。這兩個新變數,讓「只看 HTTP method 語意」(Day 5 的層級)完全不夠用——問題不再是「這個操作語意上是否安全重試」,而是「這個操作到底有沒有真的被執行過,系統自己要怎麼確認」,這需要額外的機制才能回答,而不是重試次數控制或選對 method 就能解決。
具體機制——每則訊息帶一個唯一識別碼(idempotency key,例如訂單事件的 event_id);Consumer 在真正執行有副作用的操作(例如發信、扣款)之前,先查一份「去重紀錄」(可能是資料庫的一張表,或 Redis 的一個 set),確認這個 event_id 是否已經處理過:處理過就直接跳過(不重複執行副作用),沒處理過才真正執行,並在執行完成的同時把這個 event_id 記錄為「已處理」。這把 Day 5「method 語意層級安全」(PUT/DELETE 天生具備)升級成「系統實作層級安全」(不管這個操作原本是不是 POST 這種語意上不安全的 method,只要有去重紀錄,重複呼叫都能被偵測並跳過)。
需要額外維護一份「已處理 ID」的紀錄,增加儲存與每次執行前查詢比對的成本;這份紀錄要保留多久也是需要設計的參數——保留太短,超過保留期外的合法重試(例如 Queue 因為某些原因延遲了很久才重新投遞)會被誤判成「沒處理過」而重複執行,等於去重機制失效;保留太長,儲存成本持續累積。
Failure Modes:完全依賴「訊息看起來只會送一次」的假設、沒有實作任何去重機制,一旦網路造成 At-least-once 語意下的重複投遞(這是正常現象,不是罕見 bug),就會真的發生重複扣款、重複發信;去重紀錄的「記錄已處理」與「真正執行副作用」這兩個動作如果沒有安排好先後順序或原子性,可能出現「記錄了已處理、但實際執行失敗」(之後合法的重試會被誤判成重複而跳過,造成漏做)或「執行成功了、但記錄還沒寫入前系統就當機」(下一次重試又會真的重複執行一次)這兩種邊界情況,需要依業務對「寧可漏做也不要重複做」還是「寧可重複也不要漏做」的容忍度,決定先執行還是先記錄。
Backend Applications:支付系統處理第三方(如 Stripe)webhook 時,幾乎都要求利用上游提供的 event id 自行維護一份去重表,避免同一筆 webhook 事件因為網路重試被重複處理成兩筆扣款紀錄;Phase 07 Day 66–68(T-032,Notification System Design)會把這裡的機制,落實成一個具體元件的設計決策——Worker 收到訊息時,用什麼資料結構判斷「這則通知是否已經發送過」。
陌生題示範:情境——一個發送 email 通知的 worker,從 Queue 收到訊息後處理並成功發出郵件,但在真正把 ack 回報給 Queue 之前,這個 worker 的執行環境忽然當機重啟。Queue 因為沒收到 ack,判定這次投遞失敗,把同一則訊息重新投遞給另一個 worker 實例。請問會發生什麼事,並提出解法。
推導:會造成重複發信——第一個 worker 其實已經真的成功發送了這封 email,只是恰好當機在「發信完成」與「回報 ack」之間;從 Queue 的角度看,「沒收到 ack」就等於「這次投遞失敗」,於是重新投遞;第二個 worker 收到同一則訊息,沒有其他資訊可以判斷「這件事其實已經做過了」,於是再次執行「發信」這個動作,導致使用者收到兩封內容一樣的通知信。解法:在真正執行「發信」這個有副作用的動作之前,先用訊息帶的唯一 id 查一次去重存儲,確認沒有處理過的紀錄才送信;發信成功後立刻把這個 id 記錄為已處理。剩下的邊界情況(記錄與發信這兩步之間如果又當機)取決於業務對「寧可漏發也不要重複發」或「寧可重複也不要漏發」哪個更能接受,例如可以先把 id 標記為「處理中」、發信成功後才改標記為「已完成」,讓下一次重試至少知道「上次有嘗試過,需要人工確認是否真的成功」,而不是單純的二元「有紀錄/沒紀錄」。
Day 58 過關標準(DoD)—— 具體情境分析
```text 情境:一個訂單系統用 Queue 傳遞「訂單狀態變更」事件(例如 pending → paid → shipped),同一張訂單的三筆狀態變更事件被送進 Queue 後,因為 Consumer 用了 3 個 worker 實例平行拉取處理,某次「shipped」事件比「paid」事件更早被處理完成,導致訂單系統一度把一筆「還沒付款」的訂單標記成「已出貨」。幾秒後「paid」事件才被處理,狀態又被改回 paid,造成訂單狀態短暫錯亂。
分析:1. 這同時涉及 Ordering 與 Delivery semantics 兩個面向。根本原因是 Ordering 被打破——雖然 Producer 依照 pending → paid → shipped 的順序送出三筆事件,但因為 3 個 worker 平行消化同一個 Queue,誰先處理完全取決於各自的處理耗時,跟送出順序無關,導致「shipped」事件搶先在「paid」事件之前被處理完成。2. Delivery semantics 這裡不是問題核心——三筆事件都各自被處理了剛好一次(沒有重複、沒有遺失),問題完全出在處理的先後順序,跟 At-least-once 是否重複無關。3. 修復方向:(a) 對同一張訂單相關的所有事件,用訂單 id 當作 partition key(或路由鍵),確保同一張訂單的所有事件永遠由同一個 worker 依序處理,犧牲一部分平行度(不同訂單之間仍可以平行處理,只有同一張訂單內部的事件被強制序列化)換取順序保證;(b) 或者在真正套用狀態變更之前,先檢查「目前訂單狀態是否滿足這次變更的前置條件」(例如「訂單要標記成 shipped 之前,狀態必須已經是 paid」),不滿足就把這則事件重新排隊延後處理,而不是直接套用導致狀態機出現不合法的組合。4. 結論:這個情境雖然沒有訊息重複或遺失(Delivery semantics 正常),但 Ordering 被打破本身就足以造成錯誤的業務狀態;對「事件之間有先後依賴關係」的場景,光靠 At-least-once 保證訊息一定會被處理,不代表處理的順序正確,需要額外的 partition key 或前置條件檢查來補上順序保證。```
Day 59 — Failure:六種具體故障樣態,收斂本 Phase 開頭的 Core Map
學習目標
看完今天內容後,能夠:
- 對 Timeout、Retry、Duplicate、Lost Message、Out-of-order Message、Partial Failure 六種情境,各自說出成因並提出至少一種緩解方式。
- 說明這六種情境如何串成本 Phase 開頭的 Core Map(Network→Failure→Timeout→Retry→Duplicate→Idempotency→Ordering→Consistency→Replication→Partition),彼此是因果連鎖而非各自獨立的六個知識點。
- 對一個綜合性的故障情境(不只一種樣態同時發生)做出完整分析。
教材大綱
六種 Failure 情境
Core FundamentalsLv.4What:分散式系統中,請求要跨越網路傳遞,天生會遇到以下六種具體的故障樣態:Timeout(在期限內沒收到回應,不知道對方到底有沒有處理成功)、Retry(對 Timeout 的因應機制,重新送一次請求)、Duplicate(Retry 造成同一個邏輯操作被處理不只一次)、Lost Message(訊息真的遺失,永遠沒有被處理,也沒有任何一方知道要補救)、Out-of-order Message(多筆訊息實際送達或處理的順序,跟送出時的順序不同)、Partial Failure(系統一部分組件故障,其他部分仍在正常運作,不是全有全無的二元狀態)。
這六個情境正是本 Phase 一開始 Core Map 排列的順序(Network→Failure→Timeout→Retry→Duplicate→Idempotency→Ordering→Consistency→Replication→Partition)——每一個都是前一個直接造成的連鎖反應。理解這個因果順序,才能在實際診斷問題時知道「該往前一步問成因、還是往後一步查後果」,而不是把六個情境當成六個互不相關、需要死背的名詞。
逐一說明六種情境彼此的關係——
- Timeout:呼叫端設定一個等待上限,超過這個時間仍未收到回應,就判定「這次請求失敗」。但這個判定本身帶著根本的不確定性:對方可能其實已經處理完成,只是「回應」本身在回程路上遺失或延遲了——「請求真的沒被處理」跟「請求成功但確認訊息遺失」,從呼叫端的視角看起來一模一樣,這正是 Timeout 最難處理的地方。
- Retry:因應 Timeout 帶來的不確定性,如果判斷這個操作可以安全重試(回顧 Day 58 的 Idempotency),呼叫端會重新送一次請求。但因為 Timeout 本身無法分辨「真的失敗」與「其實成功只是回應遺失」,Retry 有可能對「其實已經成功」的操作又送一次。
- Duplicate:Retry 造成的直接後果——同一個邏輯操作被處理超過一次。這件事本身是否會造成問題,完全取決於這個操作是否有 Day 58 Idempotency 機制保護;有保護就只是多做一次無害的重複判斷,沒有保護就會真的造成重複扣款、重複發信等後果。
- Lost Message:跟 Timeout 不同,Lost Message 是訊息真的從未到達或從未被處理,而且往往「沒有任何一方知道該重試」——例如 Producer 自己都不知道這次送出其實失敗了,或者 Queue 本身沒有持久化,進程重啟前佇列裡的訊息就全部消失。這是六種情境裡最難被偵測到的一種,因為「沒有任何錯誤訊號」跟「順利完成但沒人追蹤結果」,從外部完全無法分辨,通常需要額外的機制(例如 Day 58 的 At-least-once + ack 保證、持久化 Queue)才能杜絕。
- Out-of-order Message:多筆訊息經過不同的網路路徑、被不同 Consumer 實例平行處理,實際到達或處理完成的順序,可能跟 Producer 送出時的順序不同(見 Day 58 DoD 情境)。如果這些訊息彼此之間有依賴關係(例如「訂單建立」必須在「訂單狀態更新」之前生效),順序顛倒會造成邏輯錯誤,甚至讓系統狀態進入不該存在的組合。
- Partial Failure:系統由多個組件構成,故障通常不是全有全無,而是「一部分組件掛了,其他部分還能正常運作」。這時呼叫端面對的不是簡單的「成功/失敗」二元結果,而是「結果不確定,某些副作用可能已經發生、某些還沒」這種更複雜的中間狀態——例如一次跨多個服務的操作,前兩個步驟成功、第三步驟因為那個服務剛好故障而失敗,前兩步的副作用已經真實發生,不會因為第三步失敗就自動撤銷。
緩解每一種故障都有各自的代價(下方逐一說明),共通點是「想要更強的保證(不遺失、有順序、恰好一次)」幾乎都要拿延遲、吞吐量、或實作複雜度去換,沒有一種緩解方式是完全沒有代價的。
Failure Modes(這裡指「沒處理好這些故障,會連帶造成什麼更嚴重的後果」):不處理 Timeout 帶來的不確定性、盲目認定「超時 =失敗」並對非冪等操作直接重試,會造成 Duplicate;沒有持久化與 ack 機制、放任 Lost Message 發生,訊息靜默消失、沒有任何人知道要補救;不處理 Out-of-order,狀態機可能被推進到不合法的組合(例如「更新一筆還不存在的訂單」);把整個系統當成單一原子單位對待、忽略 Partial Failure 的存在,會誤判「以為全部失敗、其實部分已經生效」而重複處理,或「以為全部成功、其實部分沒做」而遺漏必要的後續步驟。
Backend Applications:對應的具體緩解方式——Timeout 的數值設定要有依據(例如根據下游服務歷史 P99 延遲設定,而不是拍腦袋決定一個數字);Retry 要搭配 exponential backoff,避免大量請求同時重試造成雪崩式流量衝擊下游(呼應 Day 58 的 Retry/DLQ);Duplicate 靠 Day 58 的 Idempotency 機制解決;Lost Message 靠 persistent Queue(訊息落地持久化)搭配 ack 機制杜絕(呼應 Day 58 At-least-once 的實作方式);Out-of-order 靠訊息帶序號或時間戳,Consumer 端重新排序或拒絕處理「版本已經落後」的訊息(呼應 Day 52 Monotonic Read 的思路——同一個機制,換了一個場景重新出現);Partial Failure 靠每個組件各自獨立的健康檢查與 circuit breaker,讓呼叫端能明確偵測到「是這一部分壞了」,而不是整個系統一起被拖進逾時或當機的狀態。
陌生題示範:情境——一個訂單系統,Consumer 依序從 Queue 收到「訂單建立」與「訂單取消」兩則事件,但因為 Consumer 用了多個 worker 平行處理不同訊息,負責「訂單取消」的 worker 比負責「訂單建立」的 worker 更早執行完成,導致系統一度出現「取消一筆還不存在的訂單」的錯誤。請診斷這是六種情境中的哪一種,並提出至少一種修復方案。
推導:這是 Out-of-order Message 的典型案例——雖然 Producer 按照正確順序送出(先建立、後取消),但因為用多個 worker 平行處理,兩則訊息實際被處理完成的順序被打亂。修復方案:(1) 對同一張訂單相關的所有訊息,用訂單 id 做 partition key,確保同一張訂單的事件永遠由同一個 worker 依序處理(犧牲部分平行度換取順序保證,跟 Day 58 DoD 的解法一致,因為本質上是同一類問題);(2) 在真正執行「取消訂單」這個動作之前,先檢查「這筆訂單目前是否已經存在、且狀態允許被取消」,不滿足就把這則事件重新排隊延後處理,而不是直接執行導致狀態機出現不合法組合。
Day 59 過關標準(DoD)—— 具體情境分析
```text 情境:一個支付通知系統依賴上游銀行透過 webhook 通知「扣款成功/失敗」。某次銀行端網路異常,同一筆扣款的 webhook 通知被銀行重送了 3 次(銀行端也有自己的 Retry 機制),且其中一次通知因為系統這邊剛好在做部署(服務短暫不可用),完全沒有被系統收到就消失了;另外兩次成功送達,其中一次因為訊息路由到不同的處理節點,比另一次更早被處理完成。
分析:1. 這個情境同時包含多種故障樣態:銀行端因為沒收到系統的確認回應而重送,本質是銀行端把「Timeout」判定為「失敗」後觸發「Retry」;同一筆扣款通知因此被送達不只一次,是 Duplicate;部署期間完全沒收到的那一次,是 Lost Message(從系統角度看,這次通知從未存在過);兩次成功送達但處理完成順序可能顛倒(例如後送的先處理完),是 Out-of-order Message;部署期間系統「一部分(負責接收 webhook 的服務)不可用,其他部分(例如資料庫、其他 API)仍正常運作」,是 Partial Failure 的具體體現。2. 這些故障並非各自獨立,而是同一條因果鏈的不同環節:Network 問題(部署期間服務不可用)造成 Failure,Failure 造成銀行端 Timeout 判定,Timeout 造成銀行端 Retry,Retry 造成系統收到 Duplicate 通知——完全對應本 Phase 開頭的 Core Map。3. 修復方向:(a) 對 Lost Message:部署時應採滾動式部署(rolling deployment)或至少保留一個健康實例持續接收 webhook,避免整個接收端完全不可用的窗口;若無法完全避免,需要銀行端提供「查詢某筆扣款目前通知狀態」的補償機制(reconciliation),系統定期主動查詢,而不是完全被動等待 webhook。(b) 對 Duplicate:每筆 webhook 帶銀行提供的唯一交易序號,系統端維護去重紀錄,同一序號只處理一次(Day 58 的 Idempotency 機制)。(c) 對 Out-of-order:處理扣款通知前,先確認這筆交易目前的狀態機是否允許這次更新(例如已經處理過「成功」,不該再被「失敗」通知覆蓋,除非有明確的狀態撤銷邏輯),必要時依交易序號或時間戳排序後才套用。4. 結論:真實系統裡的故障往往不是六種情境擇一發生,而是像這個案例一樣連鎖出現;設計容錯機制時要沿著 Core Map 的因果鏈逐一檢查每個環節有沒有對應的緩解措施,而不是只挑其中一種看似最明顯的故障來處理。```
Day 60 — Phase 06 收尾驗收:Design a Notification System
學習目標
看完今天內容後,能夠:
- 整合 Day 51–59 全部知識點,針對一個 Notification System 完整說明 duplicate、retry、ordering、failure、idempotency、scaling、observability 七項,對應第 10 章明確要求的驗收標準1。
- 對每一項設計決策,具體指出它是哪一天教過的哪個機制的實際應用,而不是憑空舉例。
- 建立「只畫一張架構圖不算完成」的判斷力——本日驗收正是 master-curriculum 明確點名「不能只畫
API → Queue → Worker」的對照案例,七項裡任何一項沒有具體說明,就是同一種「只畫架構圖」的疏漏。
Capstone 設計任務規格
設計一個 Notification System,需求如下:上游服務(例如訂單系統、促銷活動系統)透過發布「通知事件」(例如 order_shipped、promotion_started)觸發通知;系統需要支援多種發送渠道(email、簡訊、App 推播),把同一個通知事件依使用者的渠道偏好分派給對應的發送 worker;系統預期流量會隨使用者數量成長,尖峰時段(例如促銷活動開跑瞬間)通知量可能是平常的數十倍。
對應第 10 章驗收要求2:不能只畫
``text API → Queue → Worker``
必須具體說明以下七項。
逐項設計
1. Duplicate(重複):通知事件從上游服務發布到 Queue、再由 worker 消化的過程中,依 Day 58/59 的分析,Retry(不管是上游服務自己的重試,還是 Queue 對未 ack 訊息的重新投遞)都可能造成同一個通知事件被 worker 收到不只一次。這個系統假設底層 Queue 提供 At-least-once 保證(Day 58),因此必然會發生 Duplicate,不是「機率很低可以忽略」的邊角案例,而是設計上一定要處理的常態。
2. Retry(重試):worker 處理通知失敗時(例如發信服務暫時過載回傳 5xx),依 Day 58 的 Retry/DLQ 機制,用遞增的重試間隔(例如 1s/5s/30s)重試最多 5 次;超過上限仍失敗則移入 DLQ,交由監控告警通知人工檢查(例如發現某個發送渠道的下游服務長期異常)。
3. Ordering(順序性):多數通知事件彼此獨立,不需要順序保證(例如兩個不同使用者的通知互不相關,本來就可以任意順序處理);但同一個使用者、同一個渠道的多筆通知如果彼此有依賴(例如「訂單建立」通知與「訂單取消」通知),依 Day 58 DoD 與 Day 59 陌生題的解法,用「使用者 id + 渠道」組合當作 partition key,確保這個組合底下的事件由同一個 worker 依序處理;沒有依賴關係的通知則不強制排序,保留平行處理的吞吐優勢。
4. Failure(故障):依 Day 59 六種情境逐一檢查——Timeout:worker 呼叫下游發送渠道(例如 email 供應商 API)要設定合理逾時,避免單一慢請求拖住整個 worker;Lost Message:Queue 必須有持久化,worker 崩潰不影響尚未 ack 的訊息;Out-of-order:見上方第 3 項;Partial Failure:三種發送渠道(email/簡訊/推播)各自的下游服務要獨立監控、獨立設定 circuit breaker,其中一個渠道的供應商故障,不影響其他兩種渠道的正常發送(不能讓「推播服務掛了」拖累「email 也發不出去」)。
5. Idempotency(冪等性):依 Day 58 的機制,每個通知事件帶唯一識別碼(例如 event_id + 使用者 id + 渠道的組合,因為同一個event_id 可能要分派給多個渠道各自發送一次,去重的粒度必須是「某個事件在某個渠道對某個使用者」而不是只看 event_id);worker 真正呼叫發送渠道之前,先查一份去重紀錄確認這個組合是否已經發送過,發送成功後立刻記錄,把 Day 58 的機制落實成這個系統的具體實作決策(這正是 Day 58 提到、留給 Phase 07 Day 66–68 落實的那個「用什麼資料結構判斷是否已發送過」的問題,這裡先給出答案)。
6. Scaling(擴展性):依 Day 54/55 的 Sharding/Consistent Hashing 概念,Queue 本身依渠道或使用者 id 做 partition,讓多個 worker 實例可以平行消化不同 partition(而不是所有 worker 搶同一個單一佇列);worker 數量可以依 Queue 的堆積量(backlog)水平擴展——尖峰時段(例如促銷活動開跑瞬間通知量暴增)自動增加 worker 實例數量,離峰時段縮減,這是 Queue 解耦 Producer/Consumer(Day 58)帶來的直接好處:Producer 端的發布速度與 Consumer 端的處理速度可以獨立擴展。
7. Observability(可觀測性,本日新增概念):本 Phase 前面沒有專門教過 Observability 這個詞彙,這裡誠實補一個簡明定義,不是硬套某天教過的內容:Observability 指的是「系統要讓維運者能夠從外部觀察到內部實際發生了什麼」,具體到這個 Notification System 至少需要——(a) 每則通知事件的處理狀態可查詢(pending/sent/failed/in DLQ),出問題時能回答「這則通知到底發生了什麼」;(b) 各渠道的成功率、延遲、Queue 堆積量要有監控指標與告警閾值,例如某個渠道失敗率超過 5% 或 Queue 堆積超過某個數量就觸發告警;(c) DLQ 裡的堆積量本身要有監控,避免第 2 項的 DLQ 變成「丟進去沒人管」的黑洞(呼應 Day 58 對 DLQ 的 Failure Modes 警告)。
Day 60 過關標準(DoD)—— Notification System 完整設計文件
```text 交付物:一份完整的 Notification System 設計文件,至少包含上方逐項設計的七個小節內容(duplicate/retry/ordering/failure/idempotency/scaling/observability),每一項都要能具體回答「為什麼這樣設計」並指出對應到 Day 51–59 哪一天教過的哪個機制,不能只有結論沒有推導。
自我檢查(對照 master-curriculum 明確點名的反面案例):1. 是否只畫了 API → Queue → Worker 就交出,七項裡有沒有任何一項只是提到名詞而沒有具體機制?—— 例如寫「有做 idempotency」但沒說清楚去重的 key 是什麼粒度、去重紀錄存在哪裡,就是不合格。2. Duplicate 與 Idempotency 是否清楚區分「問題」與「解法」的關係——Duplicate 是為什麼會發生(Retry 造成),Idempotency 是怎麼讓 Duplicate 發生了也沒關係,兩者不能混為一談。3. Scaling 是否有具體指出「靠什麼機制擴展」(partition + 水平增加 worker),而不是空泛地說「可以加機器處理更多流量」。4. Observability 是否誠實標註「本 Phase 沒有專門教過,這裡是新增的最小定義」,而不是假裝這是延續前面某天的內容硬拗出處。
結論:這份設計文件本身就是本 Phase(Day 51–60)的整合驗收——CAP(Day 51)決定了通知資料本身的一致性選擇傾向(通知這類「晚一點送達也沒關係、但不該重複」的資料,通常偏 AP,靠 Idempotency 而非強一致性解決正確性問題);Consistency/Replication/Sharding/Consistent Hashing(Day 52–55)支撐了 Scaling 一項的具體機制;Cache/Cache Invalidation(Day 56–57)雖然沒有直接出現在七項裡,但若這個系統要展示「使用者最近收到哪些通知」這類查詢功能,會直接用到 Day 56–57 的 cache 策略;Messaging/Failure(Day 58–59)則直接構成 Duplicate/Retry/Ordering/Failure/Idempotency 五項的核心內容。完成這份設計文件,代表 Day 51–60 的知識已經能被組織成一個具體系統的設計決策,而不是十個各自獨立的名詞定義。```
Day 56–60 到此結束,Phase 06(Day 51–60)全部完成,涵蓋 CAP、Consistency、Replication、Sharding、Consistent Hashing、Cache、Cache Invalidation、Messaging、Failure,並用 Day 60 的 Notification System 設計文件對應本 Phase 在第 10 章訂下的驗收目標3。Day 57 的 stale data race 明確引用了 Phase 05 Day 44 的 Race Condition 定義(依規劃文件第 3.2 節要求的標註引用4);Day 58 的 Idempotency 明確標註這是第 2 次出現,並具體說明比 Phase 01 Day 5 多出的兩個新變數(第 3.7 節的具體解法指示)。T-031/T-032(Phase 07,Day 61–70)的 Notification/Chat/Job Scheduler 等題型,以及 T-034(Phase 08 NATS,Day 76–80)、T-036(Phase 09 Agent Reliability,Day 86–90 附近)都將直接建立在本 Phase 的 Messaging/Failure/Idempotency 模型之上,把這裡教過的抽象概念延伸到更具體的系統或情境。