📌 30 秒摘要(Layer 3)
現代應用的難點多在「資料」而非「運算」。本書把資料系統拆解成一套可評估的骨架:任何系統都在追求可靠、可擴展、可維護三大目標;再逐層深入資料模型、儲存引擎、編碼演進,以及分散式的三大支柱——複製、分區、交易,最後談分散式系統的根本難題(不可靠的時鐘/網路)與一致性和共識。是理解「資料在多台機器間如何正確流動」的聖經。
🗺 心智圖(Canvas)
設計資料密集型應用
Link to original 📊 設計資料密集型應用
DDIA — Martin Kleppmann 資料在多台機器間如何正確流動
🎯 什麼情境該想到我
當你要評估或設計一個資料/後端系統,想有一套「該顧哪些面向」的總框架時。
⚙️ 怎麼用(三個評估軸)
- 可靠性(Reliability):即使硬體/軟體/人為出錯,仍能正確運作。→ 設計容錯,主動注入故障測試。
- 可擴展性(Scalability):先定義負載參數(QPS、讀寫比、資料量),再看負載成長時效能如何。用百分位(p95/p99)談延遲,別只看平均。
- 可維護性(Maintainability):可運維性、簡單性(控制複雜度)、可演進性。
面對任何系統,逐軸問一遍,就不會漏掉非功能面。
★ 可靠性:三種故障,三種應對
故障類型 例子 ★ 應對 硬體 磁碟壞、機器當、斷電 冗餘(複製、多可用區)——這是最容易解的一種 軟體 bug、記憶體洩漏、失控的重試 ★ 難得多:相關聯的故障會同時發生在所有節點上 ★ 人為 設定錯誤、誤操作 ★ 統計上最大的故障來源 ★ 「人為是最大來源」這一點最常被忽略。對策不是「叫人更小心」,而是:
- 讓正確的事容易做、錯的事難做(好的預設值、危險操作要多一道確認)
- 沙箱環境讓人可以安全地試
- ★ 快速回滾比「不要出錯」更實際(見 工具-低風險發布模式)
- 完整的遙測(見 工具-遙測與監控)
★ 可擴展性:必須先定義「負載參數」
「這個系統可擴展嗎」是一個無法回答的問題。 要先問:
① 負載參數是什麼? ★ 不同系統的關鍵參數完全不同: Web 服務 → QPS 資料庫 → 讀寫比 聊天室 → 同時在線人數 ★ Twitter → 「每個使用者的追蹤者數分布」(不是總推文數) ② 負載增加時,系統怎麼變? 固定資源下,效能降低多少? 要維持效能,資源要增加多少?★ Twitter 的例子值得記住:
瓶頸不是「每秒幾則推文」,而是**「名人發一則推文要寫進幾百萬個時間線」**——
找對負載參數,才找得到真正的瓶頸。 找錯了,優化全部做在沒用的地方。★★ 延遲一定要用百分位,不能用平均
100 個請求:99 個 10ms,1 個 5000ms 平均 = 59.9ms ← ★ 這個數字騙了你 p99 = 5000ms ← ★ 這才是「最糟的那 1% 使用者」的體感
為什麼 說明 ★ 平均會被長尾拉走,但又掩蓋長尾 它既不代表典型,也不代表最糟 ★ 尾延遲放大(tail latency amplification) 一個請求要呼叫 10 個後端,只要有一個慢,整個請求就慢。
後端 p99 = 1%,則前端「至少碰到一個慢的」機率 ≈ 9.6%——尾巴被放大了 10 倍★ 最重要的使用者常在尾巴 資料最多的往往是最有價值的客戶 要看的指標:p50(典型)、p95、p99、p999、max。
★ 而且百分位不能平均(三台機器的 p99 各是 100ms ≠ 整體 p99 是 100ms)——
要用直方圖或 t-digest 合併(見 順序統計量與中位數)。★ 可擴展的兩個方向
垂直(scale up) 水平(scale out) 做法 換更大的機器 加更多機器 複雜度 ★ 低(什麼都不用改) 高(分區、複製、一致性) 上限 有物理上限 幾乎無限 建議 ★ 先垂直。單機現在可以很大(TB 記憶體、上百核心) 垂直撐不住才水平 ★ 「無狀態服務水平擴展很容易,有狀態的很難」——
所以架構設計的一個核心目標是把狀態集中到少數地方(資料庫、快取),讓其他部分無狀態。★ 可維護性:三個子維度
維度 問什麼 可運維性 ★ 出事時能不能快速知道、快速定位、快速修?(遙測、runbook、可回滾) 簡單性 ★ 新人多久能上手?複雜度是本質的還是偶然的?(見 工具-管理複雜度) 可演進性 需求變了,改起來容易嗎? ★ 「偶然複雜度」是可以被消除的(Brooks 的區分):
本質複雜度來自問題本身;偶然複雜度來自我們的解法——
工具爛、抽象錯、歷史包袱。可維護性的工作幾乎都在消除偶然複雜度。★ 怎麼實際使用這三軸
在設計 review 或系統設計面試時,逐軸問一遍:
可靠性 → 哪裡會掛?掛了會怎樣?人為失誤怎麼防?能多快回滾? 可擴展性 → 負載參數是什麼?10 倍時哪裡先爆?p99 是多少? 可維護性 → 誰來維運?出事怎麼查?新人要多久上手?★ 這三個問題涵蓋了幾乎所有「非功能需求」,而它們正是系統設計評審真正在看的東西
(見 AI 系統設計框架、工具-系統設計思考框架)。🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 三者互相衝突。更可靠通常更慢更貴;更可擴展通常更複雜(更難維護)。
這是取捨不是清單——要明說你選了哪一邊、為什麼。- ★ 不要過早追求可擴展性。「未來可能要撐一百萬使用者」是最常見的過度設計理由。
先做能跑的簡單系統,量出真正的瓶頸再擴展——而且那時你才知道正確的負載參數。- 可靠性有邊際成本。從 99% 到 99.9% 不貴;從 99.99% 到 99.999% 可能要十倍投入。
★ 先問業務需要多少個 9,不要預設越高越好。- ★ 平均值幾乎永遠是錯的指標(見上)。
- 可維護性最難量化,所以最容易被犧牲。→ 用可觀測的代理指標:
DORA 的交付指標:吞吐量(變更前置時間、部署頻率、失敗部署的恢復時間)與不穩定度(變更失敗率、部署重工率),共五項;「失敗部署的恢復時間」取代了早期的 MTTR(查證 2026-09-25,見 工具-部署管線與持續交付)。- 「可擴展的架構」不存在,只有「針對某種負載可擴展」。負載模式變了就要重新設計。
🔗 相關工具
- 工具-系統設計思考框架 —— 怎麼把這三軸用在實際設計上
- 工具-容量估算 —— 可擴展性的量化
- 工具-管理複雜度 —— 可維護性的核心
- 工具-服務容錯設計 —— 可靠性的具體手段
- 工具-遙測與監控 —— 可運維性的基礎
- 順序統計量與中位數 —— ★ 百分位怎麼算、為什麼不能平均
- 工具-用度量取代模糊判斷 —— DORA 四指標
- 設計資料密集型應用
資料模型與查詢語言 關聯 / 文件 / 圖模型;選模型=選「資料之間關係」的表達方式。
儲存與檢索 LSM-Tree(追加寫、寫優化)vs B-Tree(就地更新、讀優化);OLTP vs OLAP 欄式儲存。
🎯 什麼情境該想到我
當你要改資料格式/schema/API,又擔心「新舊版本同時在線」會壞掉時(滾動升級、多服務並存)。
⚙️ 怎麼用
- 顧好雙向相容:
- 向後相容:新程式能讀舊資料。
- 向前相容:舊程式能讀新資料(忽略不認得的欄位)。
兩者都要,才能新舊版本共存、滾動部署。- 選編碼格式:JSON/XML(人可讀、無 schema 演進保證)vs Protobuf/Avro/Thrift(緊湊、有明確的 schema 演進規則)。
- 加欄位用「可選 + 預設值」,別移除或改動既有欄位的標籤/型別。
★ 兩種相容性(方向很容易搞反)
向後相容 backward compatibility:★ 新程式能讀「舊」資料 → 往回相容過去。新版服務上線後,還讀得懂資料庫裡的舊資料。 向前相容 forward compatibility:★ 舊程式能讀「新」資料 → 往前相容未來。★ 這一條才是滾動部署的關鍵。★ 為什麼向前相容最難也最重要:
滾動部署期間,新舊版本同時在跑。新版寫進去的資料,舊版必須讀得懂(至少不能崩)。
而「舊程式」是已經部署出去的,你無法修改它 —— 只能靠格式本身的設計。★ 格式對照
格式 自描述 體積 演進規則 適合 JSON ✅ 大 ★ 靠約定,沒有強制 對外 API、設定、debug Protobuf ❌(要 .proto) 小 ★ 欄位編號是契約 內部 RPC、高吞吐 Avro ❌(要 schema) 最小 ★ 讀寫 schema 分離,靠欄位名匹配 大資料、Kafka MessagePack / CBOR ✅ 中 同 JSON 想要 JSON 語義但更小 ★ Protobuf 與 Avro 的差別值得知道:
- Protobuf 用「欄位編號」:改名字沒關係,改編號就毀了
- Avro 用「欄位名」+ 讀寫 schema 解析:★ 可以改編號(沒有編號),但需要 schema registry
→ Avro 特別適合「資料存很久、schema 一直演進」的資料湖場景★ Protobuf 的演進規則(照做就安全)
message User { string id = 1; string email = 2; // reserved 3; ★ 刪掉欄位時一定要 reserve,防止編號被重用 // reserved "old_name"; optional int32 age = 4; ★ 新欄位一律 optional(或有預設值) }
動作 安全嗎 加一個 optional 欄位 ✅ 舊程式忽略它 改欄位名稱 ✅(編號才是契約) ★ 改欄位編號 ❌ 災難 ★ 改欄位型別 ❌ 多數情況不安全(少數相容組合除外) 刪欄位 ⚠️ 要 reserved,且確認沒人在讀加 required(proto2) ❌ 舊資料沒有它 → 直接失敗。這就是 proto3 拿掉 required 的原因 ★ 資料庫 schema 的演進:擴張—收縮(expand-contract)
「改欄位名」不能一步到位,要分三次部署:
① 擴張:加新欄位(可為 null),★ 程式「同時寫兩邊、讀舊的」 ② 遷移:背景把舊資料補進新欄位;切換成「寫兩邊、讀新的」 ③ 收縮:確認無人讀舊欄位後,停止寫舊的,最後才 DROP★ 每一步之間都要能安全回滾。 一次做完 = 部署到一半就壞掉,而且回滾也壞掉。
這是零停機資料庫遷移的標準做法(見 工具-低風險發布模式)。★ 其他高風險的 DDL:
操作 風險 ALTER TABLE ADD COLUMN NOT NULL DEFAULT ...舊版 PG 會重寫整張表並鎖住(PG 11+ 已優化) CREATE INDEX★ 會鎖表 → 用 CREATE INDEX CONCURRENTLYALTER COLUMN TYPE通常重寫整張表 加外鍵 會驗證全表 → 先 NOT VALID再VALIDATE CONSTRAINT★ Schema 放哪裡
做法 評價 各服務各自複製一份 .proto❌ 一定會不同步 集中的 schema repo + CI 相容性檢查 ★ 推薦( buf breaking可以在 PR 時擋下破壞性改動)Schema Registry(Confluent 等) ★ Kafka/Avro 生態的標準做法,執行期強制相容性 ★ 關鍵不是「放哪裡」,是「有沒有自動化的相容性檢查」——
靠人記得不要改編號,遲早會出事。🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ JSON 沒有任何演進保證。它只是「舊程式傾向忽略不認得的欄位」——
但很多解析器預設會報錯(Go 的json.Decoder.DisallowUnknownFields、zod 的 strict mode)。
★ 對外 API 用 JSON 時,要明確在文件裡寫「客戶端必須忽略未知欄位」。- ★ 「忽略未知欄位」有一個陷阱:舊程式讀進來、改了一個欄位、寫回去 →
它不認得的欄位就被吃掉了。這是「讀-改-寫」場景下的靜默資料遺失。
→ Protobuf 有 unknown fields 保留機制;JSON 要自己處理(保留原始 map)。- ★ 資料存越久,演進規則越重要。
RPC 的資料活幾毫秒(雙方版本接近),資料庫與資料湖的資料活好幾年(要能被未來的程式讀懂)。
保存期限越長,越該用 Avro/Protobuf 這種有明確規則的格式。- 不要用語言內建的序列化(Java Serializable、Python pickle、Go gob 跨版本)——
綁死語言、有安全漏洞、演進規則不明。- 列舉(enum)的演進很麻煩:舊程式遇到新的 enum 值怎麼辦?一定要有
UNKNOWN = 0的預設值。- 加欄位是安全的,但「加一個必填欄位」不是。必填等於破壞相容性。
🔗 相關工具
- 工具-低風險發布模式 —— 擴張—收縮與零停機遷移
- 工具-共享庫版本與相容性 —— 同一套相容性思維用在程式碼上
- 工具-部署管線與持續交付 —— 相容性檢查該在 CI 裡跑
- 工具-服務邊界與演進式拆分 —— 服務邊界就是 schema 邊界
- 工具-型別推導還是解耦 —— 從 schema 推導型別(
z.infer、sqlc)- 命令模式 —— 序列化的命令需要版本相容
- 工具-Go嵌入機制 —— 嵌入對 JSON 序列化的影響
- 設計資料密集型應用
🧱 第一部:資料系統基礎
🎯 什麼情境該想到我
當你想讓資料庫高可用、或用多副本分攤讀取,卻遇到「讀到舊資料」的怪象時。
⚙️ 怎麼用(先選架構,再處理延遲)
- 選複製架構:
- 單主(Leader-based):寫主、讀從,最常見;簡單但主是寫入瓶頸/單點。
- 多主:跨資料中心可寫,但要處理寫衝突。
- 無主(Dynamo 式):靠 quorum 讀寫。
- 同步 vs 非同步:同步保證從庫有最新值但慢/易卡;非同步快但有延遲。
- 處理複製延遲的讀取異常:
- 讀自己的寫 → 讀己所寫一致性(read-your-writes)
- 時光倒流 → 單調讀(monotonic reads)
★ 三種複製拓撲
拓撲 怎麼運作 適合 ★ 主要問題 單主(leader-based) 所有寫入走主,非同步複製給從 ★ 絕大多數場景 主掛了要選新主;複製延遲 多主(multi-leader) 多個節點都能寫 多資料中心、離線編輯 ★ 寫入衝突,而且沒有通用解法 無主(leaderless) 客戶端寫多個節點,靠 quorum Dynamo、Cassandra 一致性弱,要處理讀修復 ★ 判準:能用單主就用單主。 多主與無主帶來的衝突解決成本,遠超過它們解決的可用性問題——
除非你真的有「跨洲寫入」或「離線優先」的需求。★ 同步 vs 非同步(這是可用性與持久性的取捨)
全同步:主等所有從都確認 → ★ 一個從掛了,「整個系統不能寫」 全非同步:主不等 → ★ 主掛了,「未複製的寫入永久遺失」 ★ 半同步:至少等一個從確認 → 實務上的預設(PG 的 synchronous_standby_names)★★ 複製延遲的三個經典問題(面試必考,實務必踩)
① 讀自己的寫(read-your-writes)
使用者送出留言 → 寫入主庫 → 重新整理 → ★ 讀到從庫(還沒複製到)→ 「我的留言不見了」解法:
- 寫入後的 N 秒內,該使用者的讀都走主庫(最簡單,最常用)
- 記下寫入時的 LSN/時間戳,讀的時候要求從庫至少追到那個位置
- 「自己的資料」一律讀主庫
② 單調讀(monotonic reads)
第一次讀 → 從庫 A(新)→ 看到留言 第二次讀 → 從庫 B(舊)→ ★ 留言消失了(時光倒流)解法:★ 同一個使用者永遠路由到同一個從庫(依 user_id 雜湊)。
③ 一致前綴讀(consistent prefix reads)
A: 「你午餐吃什麼?」 B: 「便當」 ★ 觀察者可能先看到「便當」再看到問題 —— 因果順序被打亂解法:有因果關係的寫入放進同一個分區,或用邏輯時鐘追蹤因果。
★ 這三個問題的共同點:它們不是「bug」,是「非同步複製的固有後果」。
你不能靠「加更多從庫」解決,只能靠路由策略或更強的一致性保證。★ 多主的衝突解決(沒有好答案)
策略 問題 LWW(last write wins) ★ 會靜默丟失資料,而且「last」依賴時鐘(不可靠) 記下所有版本讓應用解 複雜,但至少沒有靜默遺失 CRDT ★ 數學上保證收斂,但只適用於特定資料型別(計數器、集合、文字) 避免衝突 ★ 最實用:讓同一筆資料永遠只在同一個 leader 寫(依 user 路由) ★ 「避免衝突」才是實務上的正解。衝突解決演算法是最後手段,不是設計目標。
★ 故障轉移(failover)的三個坑
坑 說明 ★ 資料遺失 非同步複製下,舊主未複製的寫入在提升新主時就沒了。舊主回來後這些寫入還要被丟棄 ★ 腦裂(split brain) 舊主沒真的死(只是網路分區),兩個主同時接受寫入 → ★ 必須有 fencing(STONITH)機制 逾時設得太短 暫時的網路抖動觸發不必要的 failover → 在高負載時引發連鎖故障 ★ 自動 failover 不是免費的。很多成熟團隊選擇半自動(自動偵測 + 人工確認),
因為錯誤的 failover 比晚幾分鐘恢復更糟。🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 讀取副本不能解決寫入瓶頸。寫入永遠只有一個主——寫不夠就要分區(見 工具-資料分區)。
- ★ 複製延遲不是「偶爾」而是「總是」。設計時就要假設從庫落後,而不是「通常應該很快」。
→ 監控replication lag,並設告警。- ★ 「剛寫完就讀」的路徑要特別處理。這是使用者最容易察覺異常的場景(表單送出、下單、發文)。
- 「最終一致」的「最終」沒有上界。從庫卡住時可能落後數小時。要有落後過多就把它移出讀取池的機制。
- ★ 不要用 LWW 解決衝突,除非你能接受靜默的資料遺失(例如快取、遙測)。
- 跨區域複製的延遲是光速決定的(台北↔美西約 130ms RTT),不是調參數能解決的。
- 備份不等於複製。複製會把「刪除」也複製過去——誤刪的資料在所有副本上都沒了。 兩者都要做。
🔗 相關工具
- 工具-資料分區 —— 正交的另一半(複製管可用性,分區管容量)
- 工具-線性一致性與共識 —— 需要更強保證時
- 工具-故障偵測 —— failover 的前提
- 工具-交易與隔離等級 —— 單機內的一致性
- 工具-快取與資源優化 —— 快取也是一種複製,有同樣的失效問題
- 工具-反熵與Gossip傳播 —— 無主複製的修復機制
- 工具-服務容錯設計 —— failover 期間的降級
- 設計資料密集型應用
🎯 什麼情境該想到我
當單一機器裝不下資料/扛不住流量,需要把資料切分到多台機器(水平擴展)時。
⚙️ 怎麼用
- 選分區鍵與策略:
- 按範圍分區:範圍查詢方便,但易產生熱點(如全部照時間寫入同一分區)。
- 按雜湊分區:分佈均勻、避熱點,但失去範圍查詢能力。
- 處理熱點:熱鍵可加隨機前綴打散。
- 次級索引:本地索引(分區內,查詢要 scatter/gather)vs 全域索引(寫入要跨分區)。
- 再平衡(rebalancing):用固定數量分區等策略,避免資料大搬風。
★ 分區鍵的選擇(這一步決定成敗,而且極難反悔)
策略 做法 優點 ★ 致命弱點 按範圍 依 key 的區間切( a–f、g–m)★ 範圍查詢快(同一分區內) ★ 熱點:時間序列全部寫進最新的那一片 按雜湊 hash(key) % n決定分區★ 分佈均勻 ★ 失去範圍查詢; % n讓加節點要搬幾乎所有資料★ 一致性雜湊 雜湊到環上,節點也在環上 ★ 加減節點只搬 1/n 的資料 實作較複雜(見 工具-一致性雜湊) 複合鍵 (tenant_id, timestamp)★ 兼顧兩者:租戶內可範圍查詢,跨租戶均勻 要選對主序 ★ 實務上最常見的正確答案是「複合鍵」:
高基數的欄位當分區鍵(決定去哪一片),低基數/有序的當排序鍵(決定片內的順序)。
Cassandra 的PRIMARY KEY ((tenant_id), created_at)就是這個形狀。★ 熱點的三種成因與對策
成因 例子 對策 時間遞增的 key 用 created_at或自增 ID 當分區鍵★ 改用雜湊或複合鍵;或反轉時間戳的位元 ★ 名人問題(celebrity) 某個大客戶佔 40% 流量 ★ 加隨機前綴打散: key→{rand(0..9)}#{key},讀的時候要查 10 片再合併存取偏斜 少數熱門商品 快取擋在前面(見 工具-快取與資源優化)——比分區便宜得多 ★ 加隨機前綴是有代價的:寫入分散了,但每次讀都要 scatter-gather。
只對確定的熱鍵做,不要全表都加。★ 次級索引:兩種都有嚴重缺點
① 本地索引(document-partitioned) 每個分區維護「自己那份資料」的索引 ✅ 寫入只碰一個分區(快) ❌ ★ 查詢要問「所有」分區再合併(scatter-gather) → 延遲 = 最慢的那一片;分區越多尾延遲越糟 ② 全域索引(term-partitioned) 索引本身也被分區(依索引的 term 分) ✅ 查詢只問一個分區(快) ❌ ★ 寫入一筆資料要更新「多個」分區的索引 → 需要分散式交易,否則索引會與資料不一致(多半是非同步更新 → 最終一致)★ 這是分區系統無法迴避的取捨:寫快讀慢,或讀快寫慢。
多數系統選 ①(本地索引 + scatter-gather),因為寫入路徑的簡單性更重要。★ 再平衡:為什麼「固定數量的分區」是主流
❌ hash(key) % n n 從 3 變 4 → ★ 幾乎所有 key 的歸屬都變了 → 全量資料搬遷 ✅ 固定數量的邏輯分區(例如一開始就切 1024 片) 節點數變化時,只是「哪些分區歸哪個節點」改變 ★ 搬的是「整片」,數量可控,而且分區鍵的計算永遠不變★ 這是 Kafka(partition 數)、Elasticsearch(shard 數)、Riak 的做法。
代價:分區數一開始就要定好,之後很難改(Kafka 可以加但不能減,而且加了會破壞 key 的順序保證)。動態分區(HBase、MongoDB)則是「太大就分裂、太小就合併」——彈性好,但再平衡的時機不可控。
★ 請求路由的三種做法
做法 例子 客戶端自己算(胖客戶端) Cassandra 的 driver 中間的路由層 ★ ProxySQL、Vitess、mongos 任一節點都能轉發 Cassandra、Riak(gossip 同步拓撲,見 工具-反熵與Gossip傳播) 🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 先別分區。 單機 PostgreSQL 撐到幾 TB、幾萬 QPS 是常態。
分區帶來的複雜度(跨分區 JOIN、分散式交易、再平衡、運維)遠超過多數團隊的預期。
先做:加索引、加快取、加讀取副本、垂直擴展、歸檔舊資料。- ★ 分區鍵幾乎不可能改。改分區鍵 = 全量資料遷移 + 應用改寫。這是你會做的最難反悔的決定之一。
- ★ 跨分區的操作全部變貴:JOIN、交易、
ORDER BY+LIMIT、COUNT(*)、唯一性約束。
→ schema 設計要讓「一起查的資料落在同一分區」(這是 工具-限界上下文 與聚合設計的實際價值)。- 分區 ≠ 複製。分區解決「容量」,複製解決「可用性與讀取吞吐」——兩者正交,通常要一起做(見 工具-資料複製)。
- ★ 唯一性約束跨分區時會失效。全域唯一的 email 需要額外的機制(獨立的唯一性表、或把 email 當分區鍵)。
- 監控要 per-partition。整體平均看起來很好,但某一片已經爆了——熱點只有分片層級的指標看得出來。
- 再平衡不要全自動。自動再平衡在節點暫時不可用時可能觸發連鎖搬遷 → 雪崩。要有人工確認的閘門。
🔗 相關工具
- 工具-一致性雜湊 —— 再平衡時搬最少資料
- 工具-資料複製 —— 正交的另一半
- 工具-快取與資源優化 —— ★ 比分區便宜得多的第一選擇
- 工具-限界上下文 —— 讓「一起查的資料」落在同一分區
- 雜湊表 —— 分區與雜湊的共同基礎
- 工具-容量估算 —— 決定要不要分區的依據
- 工具-反熵與Gossip傳播 —— 節點怎麼知道拓撲
- 設計資料密集型應用
🎯 什麼情境該想到我
當多個請求同時讀寫同一批資料、出現「錯亂/覆蓋/看到中間狀態」的併發問題時。
⚙️ 怎麼用(依你要防的異常挑隔離等級)
- 讀已提交(Read Committed):防髒讀、髒寫。最低堪用等級。
- 快照隔離 / 可重複讀(Snapshot Isolation):整個交易看同一份快照(MVCC),防不可重複讀。
- 可序列化(Serializable):最強,行為等同交易一個個依序執行,防所有異常(含寫偏斜 write skew / 幻讀)。實作:真序列化、2PL、或 SSI。
步驟:先想清楚你要防哪種併發異常,再選剛好夠的等級(越強越慢)。
★ 四種異常與對應的防線(背這張表)
異常 長什麼樣 Read Committed Snapshot / RR Serializable 髒讀 讀到別人未提交的資料 ✅ 防 ✅ ✅ 不可重複讀 同一交易內讀兩次,值不同 ❌ ✅ 防 ✅ 幻讀 同一查詢,第二次多出幾列 ❌ ⚠️ 看實作 ✅ 防 ★ 寫偏斜 write skew 兩個交易各自讀了對方要改的東西,各自的決定都合法,合起來違反不變式 ❌ ❌ 防不了 ✅ 防 ★ 寫偏斜:最陰險的一種(快照隔離擋不住)
規則:任何時刻至少要有一位醫生值班。目前 Alice 和 Bob 都值班。 交易 A(Alice 請假) 交易 B(Bob 請假) SELECT count(*) → 2 ✅ SELECT count(*) → 2 ✅ 「還有 2 個,我可以走」 「還有 2 個,我可以走」 UPDATE 自己 off_duty UPDATE 自己 off_duty COMMIT COMMIT ★ 結果:0 個醫生值班★ 兩個交易改的是「不同的列」,所以沒有寫衝突——
快照隔離只偵測「寫同一列」,偵測不到「讀了一個會被對方改掉的集合」。三種修法:
方法 做法 ① 升到 Serializable PostgreSQL 的 SSI 會偵測到並讓其中一個失敗(★ 必須寫重試邏輯) ② 明確加鎖 SELECT ... FOR UPDATE把讀到的列鎖住(★ 但鎖不住「還不存在的列」)③ ★ 實體化衝突 建一張代表「衝突對象」的表(例如每個班次一列),把「集合的競爭」變成「單列的競爭」 ★ 實務上最重要的一件事:各家的命名不一致
標準 SQL PostgreSQL 實際行為 MySQL (InnoDB) READ COMMITTED✅ 一致(預設) 一致 REPEATABLE READ★ 實際上是 Snapshot Isolation(比標準強,防幻讀) ★ 預設;用 gap lock 防幻讀,但仍有寫偏斜 SERIALIZABLE★ SSI(樂觀,衝突時 abort) 2PL(悲觀,會鎖) READ UNCOMMITTED★ 等同 READ COMMITTED(PG 根本不實作髒讀) 真的允許髒讀 ★ 「我用的是 REPEATABLE READ」這句話在 PG 和 MySQL 是不同的意思。
換資料庫時這是最容易出事的地方——一定要查你那一家的文件,不要憑標準 SQL 的名字推論。★ 樂觀 vs 悲觀:怎麼選
-- 悲觀:先鎖住(適合衝突頻繁、交易短) BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- ★ 其他人要等 UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT; -- ★ 樂觀:不鎖,靠版本號偵測(適合衝突罕見、交易長) UPDATE accounts SET balance = ?, version = version + 1 WHERE id = 1 AND version = ?; -- ★ 一定要檢查 rows affected! -- rows affected = 0 → 被別人改過了 → 重試
悲觀鎖 ★ 樂觀鎖 適合 衝突頻繁、交易短 衝突罕見、交易長、跨請求 代價 阻塞、死鎖風險 要寫重試邏輯 跨 HTTP 請求 ❌ 不可行(不能握著交易等使用者) ★ 唯一可行的做法 → 完整說明見 樂觀離線鎖、工具-狀態變數的設計訣竅。
★ 單一 UPDATE 通常就夠了
-- ❌ 讀-改-寫:有競態 SELECT balance FROM accounts WHERE id=1; -- 100 UPDATE accounts SET balance = 90 WHERE id=1; -- ✅ 讓資料庫做運算:原子的,不需要任何隔離等級的幫忙 UPDATE accounts SET balance = balance - 10 WHERE id=1 AND balance >= 10;★ 能用一句原子的 UPDATE 解決,就不要用交易 + 鎖。 這是最便宜也最可靠的做法。
🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 隔離等級只管單一資料庫內部。跨服務、跨資料庫的一致性是完全不同的問題(見 工具-分散式事務的取捨)。
- ★ Serializable 一定要寫重試。PG 的 SSI 會回
40001 serialization_failure——
沒有重試邏輯就等於把錯誤丟給使用者。 重試要有上限與退避。- ★ 樂觀鎖一定要檢查 rows affected。
UPDATE ... WHERE version = ?影響 0 列代表衝突,
不檢查就是靜默地丟失更新——這是很難發現的資料錯誤。- 長交易是萬惡之源:持鎖久、阻塞別人、PG 裡會讓 vacuum 無法清理舊版本(表膨脹)。
交易裡不要有網路呼叫、不要等使用者輸入。- 死鎖是正常現象不是 bug。資料庫會偵測並殺掉其中一個——應用端必須能重試。
降低機率的做法:所有交易都用同樣的順序存取資源。- ORM 會隱藏交易邊界。要知道你的框架什麼時候開/關交易,以及 lazy loading 會不會在交易外觸發查詢。
- 隔離等級越強越慢,但**「慢」的形態不同**:悲觀鎖是阻塞,SSI 是 abort + 重試。要量的是 goodput 不是單次延遲。
🔗 相關工具
- 工具-分散式事務的取捨 —— 跨服務時怎麼辦
- 樂觀離線鎖、悲觀離線鎖 —— 跨請求的並行控制
- 工具-預寫日誌與崩潰復原 —— 交易的持久性怎麼實現
- 工具-儲存引擎B-Tree與LSM-Tree —— MVCC 與儲存層的關係
- 工具-狀態變數的設計訣竅 —— 用原子 UPDATE 取代讀-改-寫
- 狀態模式 —— 狀態轉換的並行安全
- 工具-Transactional-Outbox-Pattern —— 交易與副作用的原子性
- 設計資料密集型應用
分散式系統的麻煩 不可靠的網路與時鐘、部分失效、拜占庭故障——節點只能靠訊息推測他人狀態。
🎯 什麼情境該想到我
當你在分散式系統裡糾結「要不要強一致、可用性 vs 一致性怎麼取捨、多節點怎麼對同一件事達成一致」時。
⚙️ 怎麼用
- 線性一致性(Linearizability):讓多副本「看起來像只有一份資料」,讀到的一定是最新已寫入值。強、直覺,但成本高。
- CAP 取捨:網路分區(P)發生時,只能在一致性(C)與可用性(A)間擇一。分區無法避免 → 你在選 CP 還是 AP。
- 共識(Consensus):讓一群節點對某個值達成一致(選主、鎖、原子提交)。用 Raft/Paxos 這類演算法,或直接靠 ZooKeeper/etcd。
- 能不要強一致就別要:多數場景用因果一致性/最終一致性即可,成本低很多。
★ CAP 常被誤解,正確的用法是這樣
CAP 不是「三選二」,是「分區發生時,C 和 A 只能選一個」。
網路分區無法避免(光纖會斷、交換器會壞、GC 會停頓),所以 P 是前提不是選項。
★ 沒有分區的時候,你可以同時擁有 C 和 A —— 這是 CAP 最常被忽略的一半。更實用的框架是 PACELC:
if (Partition) then C or A ← CAP 講的 else L or C ← ★ 平時:Latency 或 Consistency★
else那一半才是你每天在付的成本:
強一致要跨節點確認 → 即使一切正常,延遲也更高。
多數系統選 AP/EL(可用 + 低延遲),這是刻意的,不是妥協。★ 一致性模型的光譜(越上面越強、越貴)
模型 保證 成本 嚴格可序列化 線性一致 + 可序列化 最高 線性一致(Linearizability) ★ 看起來像只有一份資料,讀到的一定是最新已提交值 高(要共識) 可序列化(Serializable) 交易像一個個依序執行(★ 但不保證「順序和真實時間一致」) 高 因果一致 ★ 有因果關係的操作順序一致,無關的可以亂序 中——CP 難以企及時的好折衷 最終一致 不再有寫入時,最終會收斂 低 ★ 「線性一致」和「可序列化」是不同的東西,常被混用:
線性一致是關於單一物件 + 真實時間順序(recency);
可序列化是關於多物件交易 + 某種等價順序(不保證是真實時間順序)。
兩個都要 = 嚴格可序列化(例如 Spanner)。★ 什麼時候真的需要線性一致(很少)
需要 不需要 ★ 選主 / leader election 社群動態、按讚數 ★ 唯一性約束(帳號名、座位、庫存扣減到 0) 推薦、搜尋 ★ 分散式鎖/柵欄 快取 跨通道的因果(付款完成 → 觸發出貨) 遙測、日誌 帳務餘額 「大約多少人在線」 ★ 判準:這件事「錯了會怎樣」?
錯了會造成重複扣款、超賣、兩個 leader → 需要強一致。
錯了只是「數字晚幾秒更新」→ 最終一致就好,而且便宜非常多。★★ 分散式鎖的陷阱(實務上最常出事的地方)
客戶端 A 取得鎖 → ★ GC 停頓 15 秒 → 鎖過期 → 客戶端 B 取得鎖 ↓ A 醒來,「以為」自己還持有鎖 → 兩個人同時寫 → ★ 資料毀損★ 鎖有過期時間 + 程序可能任意暫停 = 鎖永遠不安全。
SETNX+ TTL 的 Redis 鎖擋不住這個問題(Redlock 的爭議核心也在這裡)。唯一的正解:fencing token(柵欄令牌)
取得鎖時拿到一個「單調遞增的編號」 → 每次寫入都帶上它 ★ 儲存端「拒絕」編號比已見過的更小的寫入 → A 醒來用舊 token 寫入 → 被拒絕★ 關鍵:保護必須在「被保護的資源」那一端做,不能只靠客戶端自律。
這需要儲存端支援(或用資料庫的條件更新WHERE version = ?達成同樣效果,
見 工具-交易與隔離等級 的樂觀鎖)。★ 共識演算法:要知道什麼
演算法 特點 Paxos 理論基礎,★ 極難正確實作 Raft ★ 為「可理解性」設計:leader election + log replication + safety 三塊分開 ZAB ZooKeeper 用的 Viewstamped Replication 與 Raft 類似 共同性質:
- 需要多數(quorum):2f+1 個節點容忍 f 個故障 → ★ 所以節點數是奇數(3、5、7)
- ★ 節點越多越慢(每次都要多數確認),不是越多越好
- 只容忍「崩潰故障」,不容忍拜占庭故障(惡意節點)
★★ 實務結論:不要自己實作共識演算法。
用 etcd、ZooKeeper、Consul,或直接用資料庫的交易。
自己實作 Raft 是一個「看起來對、但在網路分區與時鐘異常時會靜默出錯」的經典陷阱。🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 不要自己實作共識(見上)。用成熟的元件。
- ★ 不要把「強一致」當預設。它的成本是每次操作的延遲 + 分區時的不可用。
先問「這裡錯了會怎樣」,多數答案是「沒怎樣」。- ★ 分散式鎖要有 fencing token,否則它只是「大部分時候有效的建議」。
- CAP 裡的 A 是「每個非故障節點都能回應」,不是「系統大致可用」。
實務上的可用性討論遠比 CAP 的形式定義寬鬆——不要用 CAP 當作架構決策的唯一依據。- 時鐘不可信。NTP 會跳、VM 會暫停、閏秒會出事。
★ 不要用 wall clock 判斷「誰比較新」(LWW 的根本問題,見 工具-資料複製)——
用邏輯時鐘(Lamport、vector clock)或單調時鐘。- 「網路分區」包含 GC 停頓與 VM 暫停。一個 stop-the-world GC 讓節點靜默 10 秒,
從外面看和「網路斷了」無法區分。- 共識系統的資料量要小。etcd/ZooKeeper 適合存「設定與協調資訊」,不是資料庫。
🔗 相關工具
- 工具-資料複製 —— 大多數系統選的那一側(最終一致)
- 工具-交易與隔離等級 —— 單機內的一致性與樂觀鎖
- 工具-分散式事務的取捨 —— 跨服務時的實務選擇
- 工具-故障偵測 —— 「掛了還是只是慢」的根本難題
- 工具-一致性雜湊 —— 無共識的資料分配
- 工具-服務容錯設計 —— 分區時的降級策略
- 設計資料密集型應用
🌐 第二部:分散式資料
批次處理 MapReduce 與資料流引擎:對有界資料集做離線轉換,產出衍生資料。
串流處理 事件流、變更資料擷取(CDC)、事件溯源:把批次的邊界拿掉,變成持續處理。
🔀 第三部:衍生資料
🧰 這本書給我的工具
- 工具-可靠可擴展可維護 — 評估/設計任何資料系統時的總框架
- 工具-資料複製 — 資料要放多份、做高可用/讀擴展時
- 工具-資料分區 — 單機裝不下、要水平擴展時
- 工具-交易與隔離等級 — 併發下資料正確性/一致性時
- 工具-線性一致性與共識 — 分散式下要不要強一致、CAP 取捨時
- 工具-資料編碼與演進 — schema/API 要能滾動升級時
✨ 關鍵重點(Layer 1–2)
- 三大目標:Reliability(容錯)、Scalability(用負載參數與百分位延遲描述)、Maintainability。
- 儲存引擎:LSM-Tree(寫優化)vs B-Tree(讀優化)的取捨。
- 複製:單主/多主/無主;同步 vs 非同步;複製延遲造成的讀取異常。
- 分區:按範圍或雜湊;再平衡;次級索引分區。
- 交易:ACID;隔離等級(讀已提交、快照隔離、可序列化)解決的併發異常。
- 分散式難題:不可靠的網路/時鐘、部分失效;線性一致性、CAP、共識(如 Raft/Paxos)。
- 批次 vs 串流處理:MapReduce 到事件流。
💬 金句原文(Layer 0)
- 「可靠性意味著:即使出錯,系統仍能繼續正確運作。」
- 「一個節點無法知道另一個節點的任何確切狀態——只能靠訊息推測。」
🔗 相關
- 領域驅動設計 —— 互補視角,本書談資料系統怎麼運作,DDD 談邊界與模型該怎麼切