為什麼分散式系統很難 基礎 高頻
單機程式的世界是「確定的」:函式呼叫要嘛回傳、要嘛丟例外;記憶體寫入後立刻可讀;所有事件有一個全域的先後順序(CPU 時鐘)。一旦把系統拆到多台機器上,這三個假設全部瓦解。分散式系統的本質難度可以濃縮成一句話:你無法區分「對方很慢」與「對方掛了」,而且你也無法知道「現在」到底是幾點。
分散式運算的八大謬誤(Fallacies of Distributed Computing)
由 Sun 的 L Peter Deutsch 等人整理,是面試時展現「工程直覺」的好素材。每一條都對應到一個實際設計決策:
| 謬誤 | 現實 | 設計上的對應 |
|---|---|---|
| 網路是可靠的 | 封包會遺失、重複、亂序;交換器會壞;雲端 AZ 之間會斷線 | timeout、retry、冪等、ack 機制 |
| 延遲為零 | 同機房 RTT 約 0.5ms;跨洲 RTT 常見 100–200ms 以上 | 批次化、就近部署、避免 chatty API |
| 頻寬無限 | 跨區流量昂貴且有上限 | 壓縮、只傳差異、資料在地化處理 |
| 網路是安全的 | 內網也會被滲透 | mTLS、零信任、最小權限 |
| 拓撲不會變 | 容器隨時被重新排程、IP 會變 | 服務發現、不要寫死 IP |
| 只有一個管理者 | 多團隊、多雲、第三方服務 | 明確的 SLA 與介面契約 |
| 傳輸成本為零 | 序列化、TLS、跨區 egress 都是成本 | 選擇二進位格式(Protobuf)、連線重用 |
| 網路是同質的 | 不同硬體、不同協定、不同 MTU | 標準化協定、相容性測試 |
部分失敗(Partial Failure)
單機系統通常是「全有或全無」:kernel panic 就全部停。分散式系統則常處於部分失敗:一台節點 GC 停頓 5 秒、一條網路連結單向斷線、一顆磁碟變得極慢(gray failure)。最糟的不是「掛掉」,而是「半死不活」——它還會回應 health check,但每個請求要 3 秒。
當你送出一個 RPC 而沒有收到回應,可能的情況有:
- 請求在網路上遺失,對方根本沒收到。
- 對方收到了,正在排隊(過載)。
- 對方處理到一半當機。
- 對方處理完了,但回應在網路上遺失。
- 對方處理完了,回應只是很慢(例如對方正在 stop-the-world GC)。
從呼叫端看,這五種情況完全無法區分。這就是為什麼「重試」一定要搭配「冪等」:第 4 種情況下重試會讓操作執行兩次。
不可靠的網路與不可靠的時鐘
- 非同步網路模型
- 訊息延遲沒有上界。實務上的網路(乙太網路 + TCP)就是非同步的:佇列壅塞、TCP 重傳都可能讓延遲任意變長。所以「timeout」只是猜測,不是事實。
- Network partition
- 節點群被切成彼此無法通訊的兩群(或多群),但每群內部運作正常。也可能是非對稱的:A 能送給 B,B 送不回 A。
- 時鐘漂移(clock drift)
- 每台機器的石英振盪器速度不同,Google 的假設是最多約 200 ppm(每秒偏 200 微秒,約每 30 秒偏 6ms)。NTP 同步後還會發生「時間往回跳」。
- Process pause
- GC、VM 被 hypervisor 暫停、記憶體 swap、SIGSTOP——行程可能在任意兩行程式碼之間停頓數秒,自己卻毫無察覺。這是分散式鎖最大的敵人(後面會講)。
系統模型通常這樣分類,面試中提到能加分:
| 面向 | 模型 | 說明 |
|---|---|---|
| 時間 | 同步 / 部分同步 / 非同步 | 部分同步(partially synchronous)最貼近現實:大部分時間行為良好,偶爾延遲爆炸。Raft、Paxos 都假設這個模型保證 liveness。 |
| 節點故障 | Crash-stop / Crash-recovery / Byzantine | 一般後端系統假設 crash-recovery(會當機會重啟,磁碟資料還在),不考慮拜占庭(惡意節點)。區塊鏈才需要 BFT。 |
| 演算法性質 | Safety / Liveness | Safety:壞事永遠不會發生(例如兩個 leader);Liveness:好事最終會發生(例如最終選出 leader)。分散式演算法通常保證「永遠 safe,在網路穩定時 live」。 |
被問「分散式系統最難的是什麼」時,不要只說「網路不可靠」。要具體講出:(1)無法區分慢與死,所以 failure detection 只能靠 timeout 推測;(2)沒有全域時鐘,所以事件順序要靠邏輯時鐘;(3)部分失敗讓「重試」必須搭配「冪等」;(4)因此所有設計都在 safety 與 liveness、一致性與可用性/延遲之間取捨。能講出 gray failure、GC pause 這類實際例子更好。
CAP 定理的正確理解與 PACELC 進階 高頻
CAP 是面試最常被問、也最常被答錯的題目。Eric Brewer 在 2000 年提出,2002 年由 Gilbert 與 Lynch 形式化證明。
- C — Consistency
- 這裡的 C 指的是 linearizability(線性一致性):每個讀取都能看到最近一次完成的寫入,整個系統看起來像只有一份資料。注意:和 ACID 的 C(滿足約束條件)完全不同。
- A — Availability
- 每個送到非故障節點的請求,最終都必須得到一個非錯誤的回應(不能回 error、不能無限等待)。這是很強的定義:「99.99% uptime」不等於 CAP 的 A。
- P — Partition tolerance
- 網路分區時(任意數量的訊息遺失),系統仍能繼續運作。
正確理解:P 不是選項
常見的「三選二」說法是誤導。在分散式系統中,網路分區一定會發生——你不能選擇「不容忍分區」,因為分區不是你能決定的。所以 CAP 真正的意思是:
當網路分區發生時,你必須在 Consistency 與 Availability 之間二選一。沒有分區時,C 和 A 可以同時擁有。
(但要在延遲與一致性間取捨 → PACELC 的 ELC)"] P -->|是| CHOOSE{"分區中收到寫入/讀取"} CHOOSE -->|拒絕或等待| CP["CP:保持一致
少數派分區回錯誤
例:etcd、ZooKeeper、HBase"] CHOOSE -->|照常服務| AP["AP:保持可用
各分區各自接受寫入,事後合併
例:Cassandra、DynamoDB 預設、Riak"]
常見誤解
誤解 1:「MySQL 是 CA 系統」
單機 MySQL 不是分散式系統,談不上 CAP。一旦加上 replica 並允許從 replica 讀取,在主從斷線時你就得選:replica 繼續回舊資料(A),或拒絕讀取(C)。非同步複製的 MySQL 讀 replica 其實連 linearizable 都不是。
誤解 2:「系統整體是 CP 或 AP」
CAP 是針對單一操作的性質。同一個系統可以讓不同操作有不同選擇,例如 Cassandra 的每個查詢都可設定 consistency level(ONE vs QUORUM vs ALL)。
誤解 3:「選了 AP 就沒有一致性」
AP 系統放棄的是 linearizability,不是所有一致性。它仍可以提供 causal consistency、read-your-writes 等較弱但有用的保證,並最終收斂。
誤解 4:「CAP 涵蓋了所有取捨」
CAP 只考慮一種故障(分區)與一種一致性(linearizability),完全沒提延遲。實務上,延遲才是天天面對的取捨,這就是 PACELC 要補的。
PACELC
Daniel Abadi 在 2010 年提出:if Partition, then Availability or Consistency; Else, Latency or Consistency。也就是「就算沒有分區,強一致性也要付出延遲代價」——因為寫入要等多數副本確認、讀取要跟 leader 確認。
| 系統 | 分區時(PA/PC) | 平時(EL/EC) | 說明 |
|---|---|---|---|
| DynamoDB、Cassandra、Riak | PA | EL | 預設偏可用與低延遲,可用 quorum 參數調整 |
| etcd、ZooKeeper、Spanner | PC | EC | 每次寫入都走共識,延遲較高但線性一致 |
| MongoDB(預設設定) | PC(少數派無法選出 primary) | EC/EL 依 read/write concern 而定 | readPreference=secondary 時偏 EL |
| PNUTS(Yahoo) | PC | EL | 經典的 PC/EL 例子 |
標準答法:「CAP 說的是在網路分區發生時,必須在線性一致性與可用性之間選擇;P 在分散式系統中不是可選的。而且 CAP 是針對操作而非整個系統。實務上我更常用 PACELC 思考,因為平時就要在延遲與一致性之間取捨。」接著舉例:「像廣告的預算扣款,我會偏 C(或至少有上限保護);而 user profile 的讀取,我會偏 A + 低延遲,因為讀到幾秒前的資料無傷大雅。」
以廣告平台常見做法為例:bidder 部署在多個區域(例如東京、新加坡、美西),每個 bid request 必須在約 100ms 內回應。如果每次出價都要跨洲確認一個全域一致的狀態(例如精確的剩餘預算),光是 RTT 就吃掉預算。所以 bidder 的讀取路徑幾乎一定是 PA/EL:用本地快取的 user profile、本地分配到的預算額度;跨區同步則是非同步的。代價是可能「超投一點點」,要用 pacing 與預算緩衝控制。
一致性模型光譜 進階 高頻
一致性模型是「系統對讀取結果的承諾」。越強的模型越容易推理,但越貴(延遲高、可用性低)。從強到弱:
線性一致"] --> S["Sequential
順序一致"] S --> C["Causal
因果一致"] C --> SG["Session 保證
Read-your-writes
Monotonic reads
Monotonic writes"] SG --> E["Eventual
最終一致"]
Linearizability(線性一致性)
最強的單物件一致性。定義:每個操作看起來在它的「呼叫」與「回應」之間的某個瞬間原子地生效,且這個順序與真實時間(wall-clock)一致。白話說:一旦有任何一個讀取看到了新值,之後所有的讀取都必須看到新值(或更新的值)。
例子:Alice 看到比賽比分更新為 2:1,跟坐在旁邊的 Bob 說,Bob 刷新卻看到 1:1。這違反了 linearizability(Bob 的讀取在 Alice 的讀取之後才開始,卻看到更舊的值)。
需要 linearizability 的場景:leader election(只能有一個 leader)、分散式鎖、唯一性約束(使用者名稱不重複)、帳戶餘額不可為負。實作方式:單 leader 且讀寫都走 leader(且 leader 要確認自己仍是 leader)、共識演算法(Raft/Paxos)。
Quorum 讀寫(W+R>N)不保證 linearizability。因為有時鐘偏差的 LWW、寫入到一半失敗、sloppy quorum 等狀況。要達成需要同步 read repair(讀取時先把新值寫回多數副本才回傳),Cassandra 的 LWT 則是用 Paxos 實作。
Sequential Consistency(順序一致性)
所有操作存在一個全域順序,且每個 process 自己的操作在這個順序中保持程式順序;但這個全域順序不需要與真實時間一致。差別在於:Linearizability 要求「如果 A 在 B 開始前完成,A 必須排在 B 之前」;Sequential 只要求每個 client 看到的順序相同且符合各自的程式順序。ZooKeeper 的讀取(不加 sync)提供的就是類似的保證:寫入是 linearizable,但讀取可能讀到 follower 的舊資料。
Causal Consistency(因果一致性)
有因果關係的操作,所有節點都必須以相同順序看到;沒有因果關係(並行)的操作,不同節點可以看到不同順序。
例子:Alice 發文「我丟了戒指」,接著發「找到了!」。Bob 回覆「太好了」。因果一致保證任何人看到 Bob 的回覆時,一定已經看到「找到了!」那篇。但另一個不相關的貼文,不同人可以看到不同順序。
重要性質:Causal consistency 是在不犧牲可用性(網路分區下仍可用)前提下能達到的最強一致性模型之一。實作通常需要 vector clock 或依賴追蹤。
Session 保證(Client-centric)
| 保證 | 意義 | 違反時的症狀 | 常見實作 |
|---|---|---|---|
| Read-your-writes | 自己寫的資料,自己之後一定讀得到 | 使用者改完名字刷新頁面卻看到舊名字 | 寫入後一段時間內讀 leader;或 client 帶上「最後寫入的版本/LSN」,replica 追上才回應 |
| Monotonic reads | 讀到新值後,不會再讀到更舊的值(時光倒流) | 第一次刷新看到留言,第二次刷新留言不見了(打到落後的 replica) | 同一使用者固定路由到同一 replica(以 user_id hash) |
| Monotonic writes | 同一 client 的寫入依序生效 | 先改密碼再改 email,結果順序顛倒 | 同 key 寫入走同一 partition/leader |
| Writes-follow-reads | 基於讀到的值所做的寫入,排在該值之後 | 回覆排在原文之前 | 帶上讀到的版本號 |
Eventual Consistency(最終一致性)
如果不再有新的寫入,所有副本最終會收斂到相同的值。這是非常弱的保證:沒說「多久」,也沒說「收斂前會讀到什麼」。實務上要搭配:收斂機制(read repair、anti-entropy)、衝突解決策略(LWW、CRDT),以及監控 replication lag。
DynamoDB 的讀取預設是 eventually consistent,可以指定 ConsistentRead=True 取得強一致讀(花費約兩倍的讀取容量)。
# 實作 read-your-writes:client 帶著自己最後寫入的版本號(例如 MySQL GTID / LSN)
import time
class ReplicaRouter:
def __init__(self, leader, replicas):
self.leader = leader
self.replicas = replicas
def write(self, session, key, value):
version = self.leader.put(key, value) # leader 回傳寫入後的 log position
session["last_write_version"] = version # 存在 cookie / session 中
return version
def read(self, session, key, wait_ms=50):
need = session.get("last_write_version", 0)
for r in self.replicas:
if r.applied_version() >= need: # replica 已追上此使用者最後寫入
return r.get(key)
# 全部落後:短暫等待一次,仍不行就讀 leader(犧牲一點 leader 負載換正確性)
time.sleep(wait_ms / 1000)
for r in self.replicas:
if r.applied_version() >= need:
return r.get(key)
return self.leader.get(key)
能分清楚 linearizability(真實時間 + 單一副本假象)、sequential(全域順序但不需符合真實時間)、causal(只保證有因果的順序)、eventual(只保證收斂)。最好能講「我的系統哪些資料需要哪個等級」,例如「預算扣款要防超支 → 強一致或有界誤差;使用者看自己的設定 → read-your-writes 就夠;報表數字 → eventual,幾分鐘延遲可接受」。
複製(Replication)、Quorum 與衝突解決 進階 高頻
複製的目的有三:高可用(一台掛了還有別台)、擴展讀取(讀分散到多台)、降低延遲(資料放在使用者附近)。難點全部來自「資料會變」——如何讓多個副本的變更保持一致。
三種複製架構
(東京)"] -- 雙向非同步複製 --- LB["Leader B
(美西)"] end subgraph LL["Leaderless (Dynamo)"] C2[Client] --> N1[Node1] C2 --> N2[Node2] C2 --> N3[Node3] end
| 架構 | 寫入路徑 | 優點 | 缺點 | 代表 |
|---|---|---|---|---|
| Single-leader | 所有寫入進 leader,透過 replication log 傳給 follower | 簡單、無寫入衝突、容易做 linearizable(讀 leader) | leader 是寫入瓶頸;failover 複雜(腦裂、資料遺失) | MySQL、PostgreSQL、MongoDB replica set、Kafka partition |
| Multi-leader | 每個資料中心各有 leader,leader 之間非同步互相複製 | 各區本地寫入低延遲;某區斷線不影響其他區 | 寫入衝突必須解決;拓撲(環狀、星狀、all-to-all)有順序問題 | MySQL 雙主、CouchDB、協作編輯、離線 App |
| Leaderless | client(或 coordinator)同時寫入多個節點,讀取也讀多個 | 無 failover、容忍個別節點慢或掛 | 只有最終一致;需要 read repair、anti-entropy、衝突解決 | Cassandra、Riak、DynamoDB(內部有 leader,但對外的設計源自 Dynamo 論文) |
同步 vs 非同步複製
- 同步:leader 等 follower 確認才回 client。保證 follower 有最新資料,但任一 follower 慢就全部慢、掛了就寫不進去。
- 非同步:leader 寫完本地就回。最快,但 leader 掛掉時尚未傳出的寫入會遺失(即使 client 已收到成功)。
- 半同步(semi-synchronous):至少一個 follower 同步確認,其他非同步。MySQL semi-sync replication 就是這樣,是常見的折衷。
Leader failover 的陷阱
- 判定 leader 死亡:只能靠 timeout。設太短 → 誤判(GC 停頓就換 leader),設太長 → 不可用時間長。
- 選新 leader:通常選資料最新的 follower;需要共識避免兩邊各自選出 leader。
- 舊 leader 復活:它還以為自己是 leader 並接受寫入 → 腦裂(split brain)。需要 fencing(epoch/term 號碼)讓舊 leader 的寫入被拒絕。
- 資料遺失:非同步複製下,新 leader 可能缺少舊 leader 最後幾筆寫入。GitHub 2012 年曾發生 MySQL failover 後新 leader 的 auto-increment 計數落後,重新分配了已被 Redis 引用過的主鍵,造成資料洩漏到錯誤使用者。
Quorum:W + R > N
Leaderless 系統中,每個 key 存在 N 個副本。寫入需 W 個副本確認才算成功,讀取需查詢 R 個副本。如果 W + R > N,則讀取的副本集合與寫入成功的副本集合至少重疊一個,所以讀取一定能拿到至少一個最新值(再用版本號挑出最新的)。
| 設定(N=3) | 特性 |
|---|---|
| W=2, R=2 | 常見平衡設定(QUORUM/QUORUM),可容忍 1 台故障 |
| W=3, R=1 | 讀取極快,但任一台掛了就無法寫 |
| W=1, R=3 | 寫入極快,讀取要全部回應 |
| W=1, R=1 | W+R ≤ N,不保證讀到最新值,延遲最低、可用性最高 |
即使 W+R>N 也可能讀到舊值:(1)sloppy quorum 時寫入落在「非原本的 N 個節點」;(2)兩個並行寫入,用 LWW 解決時因時鐘偏差丟掉了較新的寫入;(3)寫入只在少於 W 個節點成功(回報失敗),但成功的那些副本不會回滾,之後的讀取可能讀到也可能讀不到;(4)帶有新值的節點掛了,從舊副本恢復。所以 Dynamo 風格的 quorum 是「大致最新」,不是 linearizable。
Sloppy Quorum 與 Hinted Handoff
當 key 原本的 N 個「home 節點」有些連不上時,嚴格 quorum 會讓寫入失敗。Sloppy quorum 則允許把寫入暫時寫到 ring 上「下一個健康的節點」,湊滿 W 個確認。該節點會記下 hint(「這筆其實屬於節點 X」),等 X 恢復後把資料轉交回去,這叫 hinted handoff。
效果:寫入可用性大增(AP 取向);代價:即使 W+R>N,讀取也可能讀不到最新值,因為新值在「替身節點」上。Cassandra 的 hinted handoff 有時間視窗(預設 3 小時),超過就只能靠 repair。
Read Repair 與 Anti-entropy(Merkle Tree)
副本之間的差異靠兩種機制修復:
Read repair
讀取時查詢 R 個副本,發現某副本的版本較舊,就順便把新值寫回去。只修「常被讀到」的 key;冷資料永遠不會被修。
Anti-entropy
背景程序定期比對兩個副本的資料並同步差異。如果逐筆比較,網路成本是 O(資料量);用 Merkle tree 可以只比較 hash,快速定位不同的區段。
Merkle tree:葉節點是一段 key range 資料的 hash,父節點是子節點 hash 的 hash。兩個副本先比 root hash,相同就完全一致(一次比對搞定);不同就往下比子節點,只沿著不同的分支遞迴。差異很少時,比對成本約 O(差異數 × log n)。Cassandra 的 nodetool repair、DynamoDB 的原始 Dynamo 設計、Git、區塊鏈、BitTorrent 都用到 Merkle tree。
import hashlib
def h(b: bytes) -> bytes:
return hashlib.sha256(b).digest()
def build_merkle(leaves: list[bytes]) -> list[list[bytes]]:
"""回傳每一層的 hash,levels[0] 是葉子,levels[-1] 是 root。"""
level = [h(x) for x in leaves]
levels = [level]
while len(level) > 1:
if len(level) % 2: # 奇數個:複製最後一個
level = level + [level[-1]]
level = [h(level[i] + level[i + 1]) for i in range(0, len(level), 2)]
levels.append(level)
return levels
def diff_ranges(a, b, depth=None, idx=0):
"""從 root 往下比對,只遞迴進 hash 不同的子樹,回傳不同的葉子索引。"""
if depth is None:
depth = len(a) - 1
if a[depth][idx] == b[depth][idx]:
return []
if depth == 0:
return [idx]
out = []
for child in (2 * idx, 2 * idx + 1):
if child < len(a[depth - 1]):
out += diff_ranges(a, b, depth - 1, child)
return out
# 每個葉子代表一個 key range(例如 token range)的資料摘要
r1 = [f"range-{i}:v1".encode() for i in range(8)]
r2 = list(r1); r2[5] = b"range-5:v2"
print(diff_ranges(build_merkle(r1), build_merkle(r2))) # [5]:只需同步第 5 段
衝突解決:LWW、Vector Clock、CRDT
Multi-leader 與 leaderless 都會遇到「兩個副本對同一個 key 做了並行寫入」。關鍵概念是並行(concurrent):不是指時間上同時,而是「兩個操作互相不知道對方」,即沒有 happens-before 關係。
Last-Write-Wins(LWW)
每筆寫入帶時間戳,保留時間戳最大的。Cassandra 預設就是 LWW(cell 層級)。簡單,但會靜默丟資料:並行寫入只留一個;而且時鐘偏差可能讓「實際上較晚」的寫入因時間戳較小被丟掉。適用於「key 只寫一次、不可變」或「丟掉並行寫入可接受」的場景(例如快取、最新 GPS 位置)。
Version Vector / Vector Clock
每個副本維護一個向量 {node_id: counter}。比較兩個版本:若 A 的每個分量都 ≤ B,則 A happens-before B(B 可以覆蓋 A);若互有大小,則並行,兩者都要保留(稱為 siblings),交給應用程式合併。Riak 用的就是這個;原始 Dynamo 論文的購物車範例也是:並行的兩個購物車取聯集(代價是刪除的商品可能「復活」,需要 tombstone)。
CRDT(Conflict-free Replicated Data Type)
設計一種資料結構,讓合併操作滿足交換律、結合律、冪等律(數學上是 join-semilattice)。這樣無論副本以什麼順序、收到幾次重複的狀態,合併結果都相同——自動收斂,不需要協調。
| CRDT | 用途 | 做法 |
|---|---|---|
| G-Counter | 只增計數器(曝光數) | 每節點一個分量,只加自己的;merge 取各分量 max;值 = 總和 |
| PN-Counter | 可增可減 | 兩個 G-Counter(P 與 N),值 = sum(P) − sum(N) |
| G-Set / 2P-Set | 只增集合 / 可刪一次 | 聯集;2P-Set 用 tombstone 集合,刪了不能再加 |
| OR-Set | 可重複增刪的集合 | 每次 add 帶唯一 tag,remove 只移除看到的 tag(add-wins) |
| LWW-Register | 單值 | 帶時間戳,取最大(本質就是 LWW) |
| RGA / Yjs / Automerge | 協作文字編輯 | 序列 CRDT |
class GCounter:
"""Grow-only counter CRDT:每個節點只遞增自己的分量。"""
def __init__(self, node_id: str):
self.node_id = node_id
self.counts: dict[str, int] = {}
def incr(self, n: int = 1) -> None:
assert n >= 0
self.counts[self.node_id] = self.counts.get(self.node_id, 0) + n
def value(self) -> int:
return sum(self.counts.values())
def merge(self, other: "GCounter") -> None:
# 逐分量取 max:交換、結合、冪等 → 重複收到同一份狀態也不會重複計算
for node, c in other.counts.items():
self.counts[node] = max(self.counts.get(node, 0), c)
tokyo, sg = GCounter("tokyo"), GCounter("sg")
tokyo.incr(100); sg.incr(40)
tokyo.merge(sg); sg.merge(tokyo); tokyo.merge(sg) # 重複合併無害
assert tokyo.value() == sg.value() == 140
Redis Enterprise 的 Active-Active(多區雙寫)底層就是 CRDT;Riak 提供 counter、set、map 等 CRDT 型別。G-Counter 的狀態大小是 O(節點數),節點數多時要考慮 delta-state CRDT(只傳變化量)。
每個區域的 bidder 各自累計某 campaign 的曝光數與花費,用 G-Counter(花費用 PN-Counter 處理退款)週期性地跨區交換狀態。好處:各區永遠可以本地寫入、斷線也不影響投放、合併不會重複計數。缺點:任一時刻的全域值只是「近似」,延遲取決於同步週期——這正是預算 pacing 要處理的問題(見本章最後的情境題)。
時間與順序:物理時鐘、Lamport、Vector Clock、HLC、TrueTime 深入
「事件 A 比 B 先發生嗎?」在單機上看時間戳即可,在分散式系統中卻是最難的問題之一。
物理時鐘與 NTP
- Time-of-day clock(wall clock)
- 例如 Python 的
time.time()、Linux 的CLOCK_REALTIME。會被 NTP 調整,可能往回跳;閏秒也可能造成異常。不適合拿來量測時間長度。 - Monotonic clock
- 例如
time.monotonic()、CLOCK_MONOTONIC、Go 的time.Since(內部用 monotonic)。保證不倒退,適合量測 timeout 與耗時;但不同機器之間的值沒有可比性。 - NTP
- 透過與時間伺服器交換訊息、估算往返延遲來校正。精度受網路延遲影響,公網上通常是數毫秒到數十毫秒;同機房搭配好的時間源可以到次毫秒。NTP 可以 slew(慢慢調整頻率)或 step(直接跳),後者就是時間倒退的來源。AWS 提供 Amazon Time Sync Service,GCP 用 leap smearing 處理閏秒。
(1)用 wall clock 算 timeout:NTP 往回調時,timeout 會變超長或變負數。量測耗時一律用 monotonic clock。(2)用不同機器的時間戳排序事件(例如 LWW):時鐘偏差 10ms,就可能讓後寫的資料被丟掉。(3)Snowflake ID 依賴時鐘,時鐘回撥會產生重複 ID(見 ID 生成段落)。
Lamport Timestamp
Leslie Lamport 1978 年的經典論文定義了 happens-before(→)關係:同一 process 內先後的事件、送出訊息 → 收到該訊息、以及遞移關係。Lamport clock 規則:
- 每個節點維護計數器
c,每發生一個本地事件c += 1。 - 送訊息時附上
c。 - 收到訊息時
c = max(c, msg.c) + 1。 - 用
(c, node_id)排序,得到一個與因果一致的全序。
性質:若 A → B,則 L(A) < L(B)。但反過來不成立:L(A) < L(B) 不代表 A → B,可能只是並行。所以 Lamport clock 無法偵測並行(無法判斷衝突),只能給出一個一致的排序。
Vector Clock
每個節點維護長度為 N 的向量。本地事件時遞增自己的分量;送訊息附上整個向量;收到時逐分量取 max 後再遞增自己的分量。比較:
- V(A) ≤ V(B)(每個分量都 ≤,且至少一個 <)⇔ A → B
- 兩者互不 ≤ ⇔ A 與 B 並行(concurrent)
這是充分必要條件,所以 vector clock 可以精確偵測並行寫入。代價:大小 O(N),client 很多時會膨脹(Riak 用 dotted version vector 改善)。
from enum import Enum
class Order(Enum):
BEFORE = "before"; AFTER = "after"; EQUAL = "equal"; CONCURRENT = "concurrent"
class VectorClock:
def __init__(self, clock=None):
self.clock: dict[str, int] = dict(clock or {})
def tick(self, node: str) -> "VectorClock":
self.clock[node] = self.clock.get(node, 0) + 1
return self
def merge(self, other: "VectorClock", node: str) -> "VectorClock":
for k, v in other.clock.items():
self.clock[k] = max(self.clock.get(k, 0), v)
return self.tick(node) # 收到訊息也算一個事件
def compare(self, other: "VectorClock") -> Order:
keys = self.clock.keys() | other.clock.keys()
le = all(self.clock.get(k, 0) <= other.clock.get(k, 0) for k in keys)
ge = all(self.clock.get(k, 0) >= other.clock.get(k, 0) for k in keys)
if le and ge: return Order.EQUAL
if le: return Order.BEFORE
if ge: return Order.AFTER
return Order.CONCURRENT
a = VectorClock().tick("A") # {A:1}
b = VectorClock(a.clock).tick("B") # {A:1, B:1},B 看過 A 的寫入
c = VectorClock(a.clock).tick("C") # {A:1, C:1},C 也看過 A,但不知道 B
print(a.compare(b)) # Order.BEFORE
print(b.compare(c)) # Order.CONCURRENT → 衝突,需保留兩者或合併
Hybrid Logical Clock(HLC)
Lamport clock 跟真實時間無關,不方便做「查詢 5 分鐘前的快照」;物理時鐘又不保證因果。HLC(Kulkarni 等人 2014)結合兩者:時間戳 = (l, c),l 是「目前看過的最大物理時間」,c 是同一個 l 之下的邏輯計數器。
- 保證因果:A → B 則 HLC(A) < HLC(B)。
- 與物理時間很接近:
l與本地物理時間的差距受時鐘偏差上界所限制。 - 可以放進 64 bits(例如 48 bits 物理時間 + 16 bits 計數器),可直接取代原本的時間戳欄位。
CockroachDB、YugabyteDB、MongoDB(cluster time)都用 HLC 做 MVCC 版本。
import time
class HLC:
def __init__(self):
self.l = 0 # 看過的最大物理時間(ms)
self.c = 0 # 邏輯計數器
def _pt(self) -> int:
return int(time.time() * 1000)
def now(self) -> tuple[int, int]: # 本地事件 / 送出訊息
pt = self._pt()
if pt > self.l:
self.l, self.c = pt, 0
else:
self.c += 1 # 物理時鐘沒前進(或倒退)就靠計數器
return (self.l, self.c)
def update(self, ml: int, mc: int) -> tuple[int, int]: # 收到訊息
pt = self._pt()
new_l = max(self.l, ml, pt)
if new_l == self.l == ml:
self.c = max(self.c, mc) + 1
elif new_l == self.l:
self.c += 1
elif new_l == ml:
self.c = mc + 1
else:
self.c = 0
self.l = new_l
return (self.l, self.c)
Google TrueTime(Spanner)
Spanner 用 GPS 接收器與原子鐘提供 TrueTime API:TT.now() 回傳一個區間 [earliest, latest],保證真實時間落在其中,不確定度 ε 通常只有數毫秒。
Commit wait:交易提交時取時間戳 s = TT.now().latest,然後等待直到 TT.now().earliest > s 才釋放鎖並回應 client(約等 2ε)。這保證:任何在此之後開始的交易一定拿到更大的時間戳,於是時間戳順序 = 真實時間順序,達成全球規模的 external consistency(等同 linearizability 在交易上的版本)。核心洞見:把時鐘的不確定性顯式化,用「等待」換正確性。CockroachDB 沒有原子鐘,改用 HLC + 最大時鐘偏差設定 + 讀取時遇到不確定區間就重啟交易。
| 機制 | 大小 | 保證因果 | 偵測並行 | 接近真實時間 | 用途 |
|---|---|---|---|---|---|
| Wall clock | 8 bytes | 否 | 否 | 是 | 日誌、LWW(有風險) |
| Lamport | 8 bytes | 是(單向) | 否 | 否 | 全序廣播、Raft term 概念類似 |
| Vector clock | O(N) | 是(雙向) | 是 | 否 | 衝突偵測(Riak、Dynamo) |
| HLC | 8 bytes | 是(單向) | 否 | 是(有界) | MVCC 版本、快照讀 |
| TrueTime | 區間 | 是 | — | 是(有界不確定) | Spanner 外部一致性 |
被問「怎麼判斷兩個事件的先後」:先說分散式系統沒有可信的全域時鐘,接著區分「需要全序」(Lamport 即可)與「需要知道是否並行/衝突」(要 vector clock)。再加分:提到 HLC 兼顧可讀性與因果,以及 Spanner 用 TrueTime + commit wait 換取外部一致性。並提醒「量 timeout 一定用 monotonic clock」。
分區(Partitioning / Sharding)與 Consistent Hashing 進階 高頻
複製解決「可用性與讀取擴展」,分區解決「資料量與寫入量超過單機」。每筆資料屬於一個 partition(MongoDB 叫 chunk、Cassandra 叫 vnode/token range、Kafka 叫 partition),每個 partition 再各自複製。目標:資料與負載平均分散,避免熱點。
Range 分區 vs Hash 分區
| Range partitioning | Hash partitioning | |
|---|---|---|
| 做法 | 依 key 的連續範圍切(a–f、g–m…),範圍邊界可動態調整 | 對 key 做 hash,依 hash 值分配 |
| 範圍查詢 | 高效(相鄰 key 在同一 partition) | 需要 scatter-gather 到所有 partition |
| 熱點風險 | 高:以時間戳為 key 時,所有新寫入都打到最後一個 partition | 低:分佈均勻(但同一個熱門 key 仍是熱點) |
| 代表 | HBase、Bigtable、TiKV、MongoDB range sharding、Spanner | Cassandra、DynamoDB、Redis Cluster(16384 slots)、MongoDB hashed sharding |
折衷:複合鍵。Cassandra 的 primary key = (partition key, clustering columns):partition key 做 hash 決定位置,同一 partition 內依 clustering column 排序,於是「某使用者在時間範圍內的事件」可以高效查詢。
為什麼不用 hash(key) % N
最直覺的做法是 node = hash(key) % N。問題在於 N 改變時(擴容 10 → 11 台),幾乎所有 key 都要搬家:一個 key 留在原位的機率約只有 1/11。對快取叢集而言,這等於瞬間全部 cache miss,DB 直接被打爆。
Consistent Hashing 原理
Karger 等人 1997 年提出。把 hash 空間想像成一個環(例如 0 到 232−1),節點與 key 都 hash 到環上;每個 key 順時針找到的第一個節點就是它的歸屬。新增一個節點時,只有「新節點逆時針到前一個節點之間」的 key 會搬到新節點,平均只搬 K/N 個 key(K 是 key 總數)。
vnodes A#0..A#99"] --- B B --- C C --- A
Virtual nodes(虛擬節點):只有少數實體節點時,它們在環上的位置可能很不平均,某台負責的範圍可能是另一台的好幾倍。解法是每台實體節點在環上放 100–200 個虛擬節點(hash("A#0")、hash("A#1")…),讓負載更平均;也可以依機器規格給不同數量的 vnode(加權)。另一個好處:某台節點下線時,它的負載會分散到很多台,而不是全部壓到順時針的下一台。
import bisect
import hashlib
from collections import Counter
def _hash(s: str) -> int:
# 不要用 Python 內建 hash():每個 process 的 str hash 有隨機 salt(PYTHONHASHSEED)
return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], "big")
class ConsistentHashRing:
def __init__(self, vnodes: int = 160):
self.vnodes = vnodes
self._keys: list[int] = [] # 已排序的 vnode hash
self._owner: dict[int, str] = {} # vnode hash -> 實體節點
def add_node(self, node: str, weight: int = 1) -> None:
for i in range(self.vnodes * weight):
hv = _hash(f"{node}#{i}")
if hv in self._owner: # 極罕見的碰撞,跳過
continue
self._owner[hv] = node
bisect.insort(self._keys, hv)
def remove_node(self, node: str) -> None:
self._keys = [k for k in self._keys if self._owner[k] != node]
self._owner = {k: v for k, v in self._owner.items() if v != node}
def get_node(self, key: str) -> str:
if not self._keys:
raise RuntimeError("empty ring")
idx = bisect.bisect_right(self._keys, _hash(key)) % len(self._keys) # 環狀繞回
return self._owner[self._keys[idx]]
def get_nodes(self, key: str, n: int) -> list[str]:
"""取順時針 n 個不同的實體節點,作為複製的 preference list(Dynamo 風格)。"""
out, idx = [], bisect.bisect_right(self._keys, _hash(key))
for step in range(len(self._keys)):
node = self._owner[self._keys[(idx + step) % len(self._keys)]]
if node not in out:
out.append(node)
if len(out) == n:
break
return out
ring = ConsistentHashRing()
for n in ["cache-1", "cache-2", "cache-3", "cache-4"]:
ring.add_node(n)
keys = [f"user:{i}" for i in range(100_000)]
before = {k: ring.get_node(k) for k in keys}
print(Counter(before.values())) # 四台大致各 25%
ring.add_node("cache-5")
moved = sum(before[k] != ring.get_node(k) for k in keys)
print(f"moved {moved / len(keys):.1%}") # 約 20%(= 1/5),而非 mod N 的約 80%
Rendezvous Hashing(HRW, Highest Random Weight)
另一種一致性分配方法:對每個 key,計算它與每個節點的分數 score = hash(key, node),選分數最高的節點(要 k 個副本就取前 k 名)。
- 優點:不需要維護環與 vnode,實作極簡;天然均勻;節點移除時只有屬於它的 key 移動。
- 缺點:每次查詢 O(N)(N 為節點數)。節點數在數十到數百時完全可接受;非常多時可用 skeleton 分層版本。
def rendezvous(key: str, nodes: list[str], k: int = 1) -> list[str]:
return sorted(nodes, key=lambda n: _hash(f"{n}|{key}"), reverse=True)[:k]
另外還有 Google 的 Jump Consistent Hash(O(log N)、零記憶體,但只能在尾端增減 bucket)與 Maglev hashing(Google 的 L4 LB 使用,查表 O(1),分佈極均勻)。
Rebalancing 策略
| 策略 | 做法 | 代表 |
|---|---|---|
| 固定數量 partition | 一開始就建立遠多於節點數的 partition(如 16384 個 slot),擴容時整個 partition 搬到新節點,key → partition 對應永遠不變 | Redis Cluster、Elasticsearch(shard 數建立後不可改)、Kafka(只能增加 partition) |
| 動態分裂 | partition 太大就一分為二,太小就合併 | HBase region split、MongoDB chunk split、TiKV |
| 與節點數成比例 | 每節點固定數量的 partition(vnode),新節點加入時從既有 partition 中切一部分 | Cassandra(num_tokens) |
自動 rebalancing 與自動 failure detection 一起開,可能造成連鎖災難:某節點只是過載變慢 → 被判定死亡 → 開始搬資料 → 搬資料讓其他節點也過載 → 更多節點被判定死亡。很多系統讓 rebalance 需要人工確認,或限制搬遷速率(throttle)。
熱點(Hot Spot / Celebrity Problem)
Hash 分區無法解決「單一 key 本身很熱」:例如某個超大廣告主的 campaign_id、某個名人的 user_id。常見手法:
- Key salting / 分裂:寫入時在熱 key 後加隨機後綴
campaign:42#0..#9,分散到 10 個 partition;讀取時把 10 個合併。代價:讀取放大,需要額外記錄哪些 key 被分裂。 - 本地預聚合:在送出前先在 bidder 本地累加 1 秒,再送一筆彙總,寫入量降 1000 倍。
- 讀取熱點:加一層本地快取(in-process LRU)或 read replica。
- 時間序列 key:避免以純時間戳當 range key,改用
(hash(device_id) % 16, ts)之類的前綴。
常見追問:「consistent hashing 怎麼解決負載不均?」→ virtual nodes。「加一台節點會搬多少資料?」→ 約 1/N。「為什麼 Redis Cluster 不用 consistent hashing?」→ 它用固定 16384 個 hash slot(CRC16(key) mod 16384),slot 到節點的對應可手動/自動遷移,概念上等同「固定數量 partition」,好處是 slot 是明確的搬遷單位、可用 hash tag {user42} 讓多個 key 落在同 slot 以支援多 key 操作。
共識(Consensus):FLP、Paxos、Raft 深入 高頻
共識問題:讓多個節點對「某個值」(或一連串值,即一份 log)達成一致決定,且決定後不可改變。許多問題本質上都等價於共識:leader election、分散式鎖、原子提交、全序廣播(total order broadcast)、唯一性約束。
共識演算法的性質:Agreement(沒有兩個節點決定不同的值)、Integrity(每個節點只決定一次)、Validity(決定的值是某節點提議的)、Termination(沒當機的節點最終都會決定)。前三個是 safety,最後一個是 liveness。
FLP 不可能定理
Fischer、Lynch、Paterson(1985)證明:在完全非同步的系統中,只要可能有一個節點 crash,就不存在一個確定性演算法能保證共識一定會終止。直覺:你永遠無法區分「慢」與「死」,所以總有一種訊息延遲的排程能讓演算法永遠卡在猶豫中。
實務的繞道方法:(1)假設部分同步,用 timeout 做 failure detection;(2)引入隨機性(Raft 的隨機 election timeout)。結果是:Raft/Paxos 永遠保證 safety,但只在網路夠穩定時保證 liveness——這不違反 FLP。
Paxos 概念
Lamport 提出的經典演算法。角色:Proposer、Acceptor、Learner。Basic Paxos 決定單一值,分兩階段:
- Prepare / Promise:Proposer 選一個提案編號 n,送 Prepare(n) 給多數 Acceptor。Acceptor 若沒看過更大的編號,就承諾不再接受小於 n 的提案,並回報它已接受過的最大編號提案(若有)。
- Accept / Accepted:Proposer 收到多數 Promise 後,若有人回報已接受的值,必須沿用其中編號最大的那個值(這是 safety 的關鍵),否則用自己的值,送 Accept(n, v)。Acceptor 若沒承諾更大編號就接受。多數接受 → 值被選定。
Multi-Paxos 用一個穩定的 leader 跳過第一階段,連續決定 log 中每個位置的值。Paxos 以難懂著稱、論文留下許多實作細節未定義,這是 Raft 誕生的動機。Google Chubby、Spanner 使用 Paxos 變體;ZooKeeper 用的 ZAB 與 Paxos 相近但不同。
Raft 詳解
Raft(Ongaro & Ousterhout,2014)的設計目標是「可理解性」,把共識拆成三個子問題:leader election、log replication、safety。etcd、Consul、TiKV、CockroachDB、Kafka KRaft 都使用 Raft。
角色與 term
每個節點處於三種狀態之一:Follower、Candidate、Leader。時間被切成 term(任期),以單調遞增的整數表示,每個 term 最多一個 leader。term 扮演邏輯時鐘:任何 RPC 中若發現對方 term 比自己大,立刻更新 term 並退回 follower;收到比自己小的 term 的請求則拒絕。這就是 Raft 內建的 fencing 機制。
Leader Election
- Follower 在 election timeout(論文建議隨機 150–300ms)內沒收到 leader 的 heartbeat(空的 AppendEntries),就轉為 Candidate。
- Candidate 將 term + 1、投票給自己,平行送
RequestVote(term, candidateId, lastLogIndex, lastLogTerm)給其他節點。 - 每個節點在每個 term 只投一票(先到先得,且要持久化 votedFor),並且只投給 log 至少和自己一樣新的 candidate(先比最後一筆的 term,term 相同再比 index)。這條限制保證新 leader 一定擁有所有已 commit 的 entry。
- 拿到多數票即成為 leader,立刻送 heartbeat 宣示主權。
- 若分票(split vote)導致無人過半,各自等待隨機 timeout 後重選;隨機化讓分票很快解除。
Log Replication
- 每筆 entry 有
(index, term, command)。 - 一致性檢查:AppendEntries 帶
prevLogIndex與prevLogTerm;follower 在該位置沒有相符的 entry 就拒絕,leader 把 nextIndex 往回退再試,直到找到共同點,然後覆蓋 follower 之後衝突的 entry。這確保 Log Matching Property:兩份 log 若在某 index 的 term 相同,則該 index 之前全部相同。 - entry 被複製到多數節點後,leader 才將其標為 committed、套用到狀態機並回應 client。
Safety 的關鍵細節
- Election restriction
- 只有 log 夠新的節點能當選 → Leader Completeness:已 commit 的 entry 一定出現在所有未來 leader 的 log 中。
- 只能用當前 term 的 entry 推進 commit
- Leader 不能因為「舊 term 的 entry 已在多數節點上」就宣告它 committed(論文 Figure 8 的反例:這樣的 entry 仍可能被之後的 leader 覆蓋)。必須等當前 term 的某個 entry 被多數複製,才連帶 commit 之前的 entry。實作上新 leader 上任時會先寫一筆 no-op entry。
- 持久化
currentTerm、votedFor、log 必須在回應 RPC 前寫入穩定儲存(fsync),否則重啟後可能在同一 term 投兩次票。- 線性一致讀
- Leader 直接讀本地狀態不安全(它可能已被罷免而不自知)。做法:ReadIndex(記下 commitIndex,送一輪 heartbeat 確認自己仍是多數認可的 leader,再等狀態機套用到該 index 後讀取),或 lease read(依賴時鐘有界漂移,較快但有風險)。etcd 預設提供線性一致讀,可用 serializable 讀換取速度。
Membership Change 與 Log Compaction
直接把設定從舊集合切到新集合很危險:轉換期間可能同時存在「舊設定的多數」與「新設定的多數」兩個 leader。Raft 提供兩種方法:
- Joint consensus:先進入 Cold,new 過渡設定,任何決定都要同時取得舊集合與新集合各自的多數;確立後再切到 Cnew。
- 單節點變更:一次只增或減一個節點,此時新舊多數必定重疊,可以直接切換。etcd 就是一次一個成員,並有 learner(非投票成員)機制讓新節點先追上 log。
Log 不能無限增長,各節點定期把狀態機做 snapshot,丟掉之前的 log;落後太多的 follower 由 leader 用 InstallSnapshot RPC 直接傳快照。
Raft 叢集通常 3 或 5 台。3 台容忍 1 台故障、5 台容忍 2 台。偶數台不划算:4 台的多數是 3,仍只容忍 1 台故障,卻多了寫入延遲。節點越多,每次寫入要等的多數越多,寫入延遲與吞吐都變差——所以共識叢集只用來存「少量但關鍵」的 metadata,不是拿來存業務資料的主體。
etcd 與 ZooKeeper 的用途
| etcd | ZooKeeper | |
|---|---|---|
| 共識 | Raft | ZAB(ZooKeeper Atomic Broadcast) |
| 資料模型 | 扁平 key-value(以 prefix 模擬目錄),MVCC 多版本,有 revision | 樹狀 znode;persistent / ephemeral / sequential 節點 |
| 存活綁定 | Lease(TTL,client 定期 KeepAlive;lease 到期則綁定的 key 被刪) | Ephemeral znode(session 斷了自動刪除) |
| 通知 | Watch(可從某個 revision 開始,不會漏事件) | Watch(一次性觸發,需重新註冊) |
| 代表使用者 | Kubernetes(所有叢集狀態)、CoreDNS、Vitess | 舊版 Kafka(controller 選舉與 metadata)、HBase、Hadoop HA、Solr |
典型用途:服務發現、設定中心、leader election、分散式鎖、叢集成員管理。共同原則:寫入量要小(etcd 預設單一請求上限 1.5MB、建議總資料量在數 GB 以內)。
Raft 被問到時,至少要能講:三種角色與 term、隨機 election timeout 解決分票、投票的「log 夠新」限制、AppendEntries 的 prevLogIndex/prevLogTerm 一致性檢查、多數複製才 commit。加分點:Figure 8(只能 commit 當前 term 的 entry)、ReadIndex 線性一致讀、單節點成員變更、為什麼 5 台比 4 台好。最後連到 FLP:Raft 犧牲的是 liveness 而非 safety。
Leader Election、分散式鎖與 Fencing Token 深入
許多系統需要「同一時間只有一個實例做某件事」:只有一個 scheduler 觸發排程、只有一個 worker 處理某個帳戶的扣款、只有一個 consumer 寫入某個檔案。這需要 leader election 或分散式鎖——兩者本質相同:都是「取得一個有時效的獨占權」。
Lease(租約)
分散式鎖幾乎都必須有過期時間,否則持鎖者當機,鎖就永遠不會被釋放。有過期時間的鎖就是 lease:「你在接下來 10 秒內擁有此資源」,持有者需在到期前續約。
為什麼分散式鎖很難:GC Pause 問題
Martin Kleppmann 的經典反例:
問題的根源:持鎖者無法可靠地知道自己的 lease 是否還有效。檢查「lease 還剩幾秒」和「執行寫入」之間可能發生任意長的停頓(GC、page fault、VM 遷移、網路延遲讓寫入請求在路上多走了 10 秒)。
Fencing Token
解法:鎖服務每次授予鎖時,同時發一個單調遞增的 token(例如 etcd 的 revision、ZooKeeper 的 zxid/czxid、Raft 的 term)。Client 每次寫入儲存時帶上 token,儲存端記住看過的最大 token,拒絕更小的。
- A 拿到 token 33,停頓;B 拿到 token 34 並寫入,儲存端記錄 max=34。
- A 醒來帶 token 33 寫入 → 被拒絕。
關鍵:安全性由資源端保證,而不是持鎖者自律。如果儲存端無法檢查 token(例如第三方 API),可以用條件寫入代替,例如 SQL 的 UPDATE ... WHERE version = ?(樂觀鎖)、S3 的條件寫入、DynamoDB 的 ConditionExpression。
-- 資源端實作 fencing:只接受比上次更大的 token
UPDATE job_state
SET payload = :payload, fence_token = :token
WHERE job_id = :job_id
AND fence_token < :token;
-- 影響列數為 0 → 代表自己的鎖已過期被別人取代,應中止
Redis 分散式鎖與 Redlock 爭議
單一 Redis 實例的鎖(效率用途)是常見做法:
import uuid
import redis
r = redis.Redis()
RELEASE_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
release_script = r.register_script(RELEASE_LUA)
def acquire(name: str, ttl_ms: int = 10_000) -> str | None:
token = uuid.uuid4().hex # 唯一值,用來確認是自己的鎖
ok = r.set(f"lock:{name}", token, nx=True, px=ttl_ms) # 原子地「不存在才設定 + 過期」
return token if ok else None
def release(name: str, token: str) -> bool:
# 必須用 Lua 原子地「比對再刪除」:否則 GET 後鎖剛好過期被別人拿走,DEL 會刪掉別人的鎖
return release_script(keys=[f"lock:{name}"], args=[token]) == 1
token = acquire("daily-report")
if token:
try:
... # 做事;耗時接近 TTL 時需要續約(watchdog)或改用 fencing token
finally:
release("daily-report", token)
(1)SETNX 後再 EXPIRE 是兩個指令,中間當機會留下永不過期的鎖,要用 SET key val NX PX ttl。(2)釋放時不比對 token。(3)Redis 主從非同步複製:master 寫入鎖後還沒同步就掛了,replica 升格後鎖不見了,另一個 client 也能拿到。Redlock(向 5 個獨立 Redis 取得多數鎖)試圖解決 (3),但 Kleppmann 指出它依賴時鐘與有界停頓的假設,且沒有 fencing token,不適合「正確性」用途;antirez 則反駁了部分論點。實務結論:為了效率(避免重複做事,偶爾重複也無妨)用單一 Redis 鎖即可;為了正確性(重複會損毀資料)用 etcd/ZooKeeper 並搭配 fencing token,或直接在資料庫用交易/條件寫入。
用 etcd 做 Leader Election
// 使用 go.etcd.io/etcd/client/v3/concurrency
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"etcd:2379"}})
sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(10)) // 綁定 lease,process 死掉 10 秒後自動釋放
defer sess.Close()
e := concurrency.NewElection(sess, "/scheduler/leader")
if err := e.Campaign(ctx, hostname); err != nil { // 阻塞直到當選
log.Fatal(err)
}
// 當選後:e.Rev() 可當 fencing token(建立 key 時的 revision,單調遞增)
fence := e.Rev()
go func() {
<-sess.Done() // lease 失效(例如網路斷太久)→ 立刻停止 leader 工作
os.Exit(1)
}()
runSchedulerLoop(ctx, fence)
「怎麼實作分散式鎖?」好的回答層次:先問用途是效率還是正確性 → 效率用 Redis SET NX PX + token 比對釋放 → 正確性要考慮 GC pause 與時鐘,鎖本身不夠,需要 fencing token 由資源端驗證 → 或乾脆不要鎖,改用資料庫條件更新/樂觀鎖、或依 key 分區讓同一資源只由單一 consumer 處理(例如 Kafka partition)。最後一點常常是最好的答案:用分區消除共享,比用鎖協調共享更可靠。
分散式交易:2PC、3PC、Saga、TCC、Outbox 深入 高頻
單一資料庫的 ACID 交易很成熟。但當一個業務操作跨越多個資料庫或多個微服務(例如「建立訂單 + 扣庫存 + 扣款」),就需要分散式交易。核心取捨:強一致的原子提交(2PC)會阻塞、降低可用性;最終一致的補償式交易(Saga)可用性高,但要處理中間狀態與補償。
Two-Phase Commit(2PC)
- Prepare:coordinator 問所有 participant「你能提交嗎?」participant 把交易資料持久化、保留鎖,回 YES 就代表「無論發生什麼,我承諾之後一定能提交」,從此失去單方面中止的權利。
- Commit / Abort:全部 YES → coordinator 先把 COMMIT 決定寫入自己的 log(不可反悔的點),再通知所有人提交;任一 NO 或逾時 → 通知全部 abort。Participant 必須重試直到收到決定。
阻塞問題:participant 投完 YES 後,若 coordinator 當機,participant 處於 in-doubt(不確定)狀態:不能自行 commit(可能別人投了 NO),也不能 abort(coordinator 可能已決定 commit 並通知了別人)。只能持續持有鎖等待 coordinator 恢復——這期間相關的資料列全部被鎖住,其他交易被卡住。所以 2PC 被稱為「阻塞式原子提交協定」。
其他問題:同步往返多、延遲高;coordinator 是單點;所有參與者都要支援 XA 之類的協定。實例:MySQL XA、PostgreSQL PREPARE TRANSACTION、Java JTA。在資料庫內部的分散式交易(Spanner、CockroachDB)會把 coordinator 狀態也用 Paxos/Raft 複製,解決 coordinator 單點的問題,因此可用。
Three-Phase Commit(3PC)
在 prepare 與 commit 之間加一個 pre-commit 階段,並引入 timeout:參與者在 pre-commit 後若等不到 coordinator,可以安全地自行提交。這讓它在「同步網路 + 有界延遲」假設下是非阻塞的。但在真實的非同步網路中,網路分區會讓兩邊做出不同決定,破壞一致性,因此實務上幾乎不用。面試只需知道:「3PC 試圖解決 2PC 的阻塞,但假設有界延遲,實際上不可靠。」
Saga
1987 年提出的長交易模式:把大交易拆成一串本地交易 T1, T2, …, Tn,每一步各自提交;若第 k 步失敗,就反向執行補償交易 C(k−1), …, C1 來語意上撤銷。
- 沒有全域鎖,可用性與吞吐高。
- 代價:沒有隔離性(isolation),其他交易可能看到中間狀態(已扣庫存但未付款)。需要語意鎖(狀態欄位 PENDING)、交換律設計等對策。
- 補償不一定能完全還原(已寄出的 email 無法收回,只能再寄一封更正信)。
- 每個步驟與補償都必須冪等,因為會被重試。
Orchestration(編排)
由中央 orchestrator(狀態機)依序呼叫各服務、決定何時補償。優點:流程集中、容易理解與監控、易於加入 timeout;缺點:orchestrator 變成邏輯集中點,可能變成「上帝服務」。工具:Temporal、AWS Step Functions、Camunda、Netflix Conductor。
Choreography(協作)
沒有中央控制,各服務訂閱事件、做完自己的事再發出事件(OrderCreated → InventoryReserved → PaymentCompleted)。優點:鬆耦合;缺點:流程分散在各服務,難以追蹤「整個流程現在在哪」、容易出現循環依賴。適合步驟少的流程。
# 極簡 Saga orchestrator:每步定義 action 與 compensation,失敗時反向補償
from dataclasses import dataclass
from typing import Callable
@dataclass
class Step:
name: str
action: Callable[[dict], None]
compensate: Callable[[dict], None]
def run_saga(steps: list[Step], ctx: dict, log) -> bool:
done: list[Step] = []
for step in steps:
try:
log.record(ctx["saga_id"], step.name, "START") # 持久化進度,crash 後可從此恢復
step.action(ctx) # action 本身必須冪等
log.record(ctx["saga_id"], step.name, "DONE")
done.append(step)
except Exception as e:
log.record(ctx["saga_id"], step.name, f"FAILED: {e}")
for s in reversed(done):
retry_forever(lambda: s.compensate(ctx)) # 補償必須最終成功(冪等 + 重試)
log.record(ctx["saga_id"], s.name, "COMPENSATED")
return False
return True
# steps = [Step("reserve_budget", reserve, release), Step("create_campaign", create, archive),
# Step("notify_ad_server", push_config, revoke_config)]
TCC(Try-Confirm-Cancel)
可視為「應用層的 2PC」。每個服務提供三個介面:
- Try:檢查並預留資源(凍結 100 元,而不是直接扣)。
- Confirm:真正使用預留的資源(把凍結的 100 元扣掉)。
- Cancel:釋放預留。
比 Saga 隔離性好(預留的資源別人用不到),但侵入性高(每個服務都要實作三套邏輯)。要處理的經典問題:空回滾(Try 沒執行到就收到 Cancel)、懸掛(Cancel 先到、Try 後到,Try 不應再預留)、冪等(Confirm/Cancel 被重試)。通常用一張交易紀錄表記錄每個全域交易 id 在本服務的狀態來解決。
Outbox Pattern:解決 Dual Write
常見錯誤:「先寫 DB,再發訊息到 Kafka」。若寫 DB 成功、發訊息失敗(或 process 在兩者之間當機),下游永遠收不到事件;反過來先發訊息再寫 DB 也有對稱的問題。這叫 dual write problem——兩個系統之間沒有共同的交易。
Outbox:在同一個本地 DB 交易中,同時寫入業務表與 outbox 表;再由獨立的 relay(輪詢 outbox 表,或用 CDC 如 Debezium 讀 binlog)把 outbox 事件送到 MQ,送成功後標記已發送。保證「業務變更與事件」要嘛都發生、要嘛都沒發生;relay 可能重送,所以下游要冪等(at-least-once)。詳細實作見 第 11 章。
BEGIN;
UPDATE campaigns SET status = 'ACTIVE' WHERE id = 42;
INSERT INTO outbox (id, aggregate_id, event_type, payload, created_at)
VALUES ('0192f6c4-...', 42, 'CampaignActivated', '{"campaign_id":42}', NOW());
COMMIT;
-- relay: SELECT ... FROM outbox WHERE sent_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED;
| 方案 | 一致性 | 隔離 | 可用性/效能 | 實作成本 | 適用 |
|---|---|---|---|---|---|
| 2PC / XA | 強(原子) | 有(持鎖) | 低,coordinator 故障時阻塞 | 中(依賴 DB 支援) | 少數同質資料庫、短交易 |
| Saga | 最終一致 | 無 | 高 | 中(補償邏輯) | 跨微服務長流程 |
| TCC | 最終一致(預留期間近似強) | 部分(資源預留) | 中高 | 高(三套介面) | 金流、庫存、額度 |
| Outbox + 事件 | 最終一致 | 無 | 高 | 低中 | 「DB 變更 + 通知下游」 |
能畫出 2PC 流程並指出「participant 投 YES 後 coordinator 當機 → in-doubt 持鎖阻塞」。能說明為何微服務多用 Saga(避免跨服務持鎖),並主動提到 Saga 缺乏隔離、補償需冪等。被問「寫 DB 後發 Kafka 怎麼保證一致?」→ 直接回答 outbox + CDC,並指出消費端需冪等。
分散式 ID 生成 進階 高頻
單機 DB 的 AUTO_INCREMENT 在分庫分表、多區域寫入時不再可用。分散式 ID 的需求通常是:全域唯一、高可用、高效能(本地產生)、大致按時間遞增(對 B+tree 索引友善),有時還要求不可預測(不洩漏業務量)。
UUID v4 與 v7
- UUID v4:122 bits 隨機。無需協調,碰撞機率可忽略。缺點:128 bits 較大;完全隨機,作為 InnoDB 主鍵時插入位置隨機,造成 page split 與 buffer pool 命中率下降,寫入效能明顯變差。
- UUID v7(RFC 9562,2024):前 48 bits 是 Unix 毫秒時間戳,其後是版本位元與隨機位元(可選用部分作為單調計數器)。時間有序 + 無需協調,是新系統取代 v4 當主鍵的推薦選擇。PostgreSQL 18 起內建
uuidv7(),Python 3.14 起標準庫uuid也提供uuid7()。
Snowflake
Twitter 2010 年提出的 64-bit ID(剛好放進 BIGINT):
| bits | 1 | 41 | 10 | 12 |
|---|---|---|---|---|
| 欄位 | 符號位(固定 0) | 毫秒時間戳(相對自訂 epoch,可用約 69 年) | 機器 ID(1024 台;常拆成 5 bit datacenter + 5 bit worker) | 序號(每毫秒每台 4096 個) |
單機理論上限約每秒 409.6 萬個。機器 ID 的分配通常靠 ZooKeeper/etcd 註冊、K8s StatefulSet 序號,或啟動時從 DB 取得。
時鐘回撥(clock moving backwards)是 Snowflake 最大的坑:NTP 把時間往回調,就可能產生與之前重複的 ID。對策:
- 記錄上次產生 ID 的時間戳
last_ts;若now < last_ts:回撥很小(例如 ≤ 5ms)就等待追上;回撥大則拒絕服務並告警。 - 不跟著時鐘走:用
max(now, last_ts),序號用完就「借用」下一毫秒(百度 UidGenerator 的做法),讓 ID 時間戳與真實時間略微脫鉤。 - NTP 設定成只 slew 不 step;或預留 1–2 bits 作為「回撥次數」,回撥時遞增以區隔。
import threading
import time
class Snowflake:
EPOCH = 1_704_067_200_000 # 2024-01-01 UTC,自訂 epoch 延長可用年限
WORKER_BITS, SEQ_BITS = 10, 12
MAX_WORKER = (1 << WORKER_BITS) - 1
MAX_SEQ = (1 << SEQ_BITS) - 1
MAX_BACKWARD_MS = 5
def __init__(self, worker_id: int):
if not 0 <= worker_id <= self.MAX_WORKER:
raise ValueError("worker_id out of range")
self.worker_id = worker_id
self.last_ts = -1
self.seq = 0
self.lock = threading.Lock()
@staticmethod
def _now() -> int:
return time.time_ns() // 1_000_000
def next_id(self) -> int:
with self.lock:
ts = self._now()
if ts < self.last_ts: # 時鐘回撥
back = self.last_ts - ts
if back > self.MAX_BACKWARD_MS:
raise RuntimeError(f"clock moved backwards {back}ms")
time.sleep(back / 1000)
ts = self._now()
if ts < self.last_ts:
raise RuntimeError("clock still behind")
if ts == self.last_ts:
self.seq = (self.seq + 1) & self.MAX_SEQ
if self.seq == 0: # 本毫秒序號用完 → 等下一毫秒
while ts <= self.last_ts:
ts = self._now()
else:
self.seq = 0
self.last_ts = ts
return ((ts - self.EPOCH) << (self.WORKER_BITS + self.SEQ_BITS)) \
| (self.worker_id << self.SEQ_BITS) | self.seq
@classmethod
def parse(cls, id_: int) -> dict:
return {
"ts_ms": (id_ >> (cls.WORKER_BITS + cls.SEQ_BITS)) + cls.EPOCH,
"worker": (id_ >> cls.SEQ_BITS) & cls.MAX_WORKER,
"seq": id_ & cls.MAX_SEQ,
}
Snowflake ID 傳給前端 JavaScript 時要轉成字串:JS 的 Number 是 IEEE 754 double,只能精確表示到 253−1,超過的 64-bit 整數會被捨入成錯誤的值。
DB Segment 模式(美團 Leaf-segment)
用 DB 發號,但不是每次取一個,而是一次取一段(segment):
CREATE TABLE id_alloc (
biz_tag VARCHAR(64) PRIMARY KEY,
max_id BIGINT NOT NULL,
step INT NOT NULL
);
-- 服務取號段:原子地往前推 step,拿到 (max_id - step, max_id] 這段在記憶體中發放
UPDATE id_alloc SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM id_alloc WHERE biz_tag = 'order';
每次 DB 存取可發出 step 個 ID,DB 壓力降 step 倍。雙 buffer:目前號段用到一定比例(如 10%)時,非同步預取下一段,避免號段用完時的等待尖峰。優點:ID 是緊湊的遞增整數、不依賴時鐘;缺點:依賴 DB 可用性(號段可撐一段時間)、多台服務取的號段交錯,所以只是「趨勢遞增」而非嚴格遞增;ID 連續會洩漏業務量。Leaf 同時提供 snowflake 模式(以 ZooKeeper 分配 worker id 並檢查時鐘)。
ULID
128 bits = 48 bits 毫秒時間戳 + 80 bits 隨機,以 Crockford Base32 編碼成 26 字元(如 01ARZ3NDEKTSV4RRFFQ69G5FAV)。字典序 = 時間序,URL 安全、可讀性比 UUID 好;同毫秒內可用 monotonic 模式(隨機部分 +1)保證遞增。概念上和 UUID v7 非常接近,v7 標準化後新專案可直接用 v7(能存進原生 UUID 欄位)。
| 方案 | 大小 | 有序 | 需協調 | 依賴時鐘 | 效能 | 缺點 |
|---|---|---|---|---|---|---|
| DB auto-increment | 64 bits | 嚴格遞增 | 是(單點) | 否 | 受限於 DB | 單點、難以分庫、洩漏業務量 |
| UUID v4 | 128 bits | 否 | 否 | 否 | 極高 | 大、索引不友善 |
| UUID v7 / ULID | 128 bits | 時間有序(毫秒內依實作) | 否 | 是(輕度) | 極高 | 比 64-bit 大;時間戳可被解出 |
| Snowflake | 64 bits | 趨勢遞增 | 只在分配 worker id 時 | 是(回撥問題) | 極高 | 時鐘回撥、worker id 管理 |
| Segment(Leaf) | 64 bits | 趨勢遞增 | 是(DB,低頻) | 否 | 高 | 依賴 DB、可推測業務量 |
| Redis INCR | 64 bits | 嚴格遞增 | 是 | 否 | 高 | 持久化與 failover 可能重號 |
每個 bid request 與曝光事件都需要一個 ID 以便後續串接點擊與轉換(attribution),每秒量級很大且分散在多區域產生。這類 ID 最適合無協調、本地產生的 UUID v7 / ULID / Snowflake:時間有序讓下游以時間分區儲存時更有效率,也方便除錯時從 ID 直接看出產生時間。若用 Snowflake,datacenter bits 可直接編碼區域。
故障處理、韌性模式與冪等 進階 高頻
分散式系統中故障是常態。韌性(resilience)模式的共同目標:讓局部故障停留在局部,不要擴散成全面癱瘓(cascading failure)。
Timeout
- 一定要設:很多 HTTP client 預設沒有 timeout(Python
requests預設無限等待)。沒有 timeout 的呼叫會讓 thread/connection 被耗盡。 - 依據下游的 p99/p999 延遲設定,而不是平均值;分成 connect timeout 與 read timeout。
- Deadline propagation:上游總預算 100ms,已花 60ms,呼叫下游時只給剩下的 40ms(gRPC 的 deadline 會自動傳遞)。RTB 場景這點至關重要。
Retry、Exponential Backoff 與 Jitter
只重試暫時性錯誤(timeout、503、連線重設),不重試 4xx(除了 429);只重試冪等操作或帶 idempotency key 的操作。
立刻重試會讓已經過載的下游雪上加霜(retry storm:每層重試 3 次,5 層呼叫鏈最壞放大 35=243 倍)。所以:
- Exponential backoff:等待 base × 2attempt,設上限 cap。
- Jitter:加入隨機,避免大量 client 同時重試形成同步尖峰(thundering herd)。AWS Architecture Blog 的分析顯示 full jitter(在 0 到 backoff 之間均勻隨機)在總工作量上表現很好。
- Retry budget:重試請求不超過正常請求的某個比例(例如 10%),超過就不重試(gRPC、Envoy、Finagle 都支援)。
- 只在呼叫鏈的一層重試,通常是最靠近使用者的那層或最底層,不要每層都重試。
import random
import time
class RetryableError(Exception): ...
def retry_with_backoff(fn, *, max_attempts=5, base=0.1, cap=5.0, deadline=None):
"""Full jitter: sleep = random(0, min(cap, base * 2**attempt))"""
start = time.monotonic() # 量時間用 monotonic clock
for attempt in range(max_attempts):
try:
return fn()
except RetryableError:
if attempt == max_attempts - 1:
raise
sleep = random.uniform(0, min(cap, base * (2 ** attempt)))
if deadline is not None and time.monotonic() - start + sleep > deadline:
raise # 超過整體預算就放棄,不要無止盡重試
time.sleep(sleep)
Circuit Breaker(斷路器)
當下游持續失敗時,繼續呼叫只是浪費資源並拖慢自己。斷路器在失敗率超過閾值時「跳脫」,直接快速失敗(或回傳 fallback),給下游喘息時間。
import threading
import time
from collections import deque
class CircuitOpenError(Exception): ...
class CircuitBreaker:
def __init__(self, failure_ratio=0.5, min_calls=20, window_s=10.0, cooldown_s=30.0, half_open_max=3):
self.failure_ratio, self.min_calls = failure_ratio, min_calls
self.window_s, self.cooldown_s, self.half_open_max = window_s, cooldown_s, half_open_max
self.state = "CLOSED"
self.events: deque[tuple[float, bool]] = deque() # (時間, 是否成功) 滑動視窗
self.opened_at = 0.0
self.half_open_inflight = 0
self.lock = threading.Lock()
def _trim(self, now):
while self.events and now - self.events[0][0] > self.window_s:
self.events.popleft()
def call(self, fn, fallback=None):
with self.lock:
now = time.monotonic()
if self.state == "OPEN":
if now - self.opened_at >= self.cooldown_s:
self.state, self.half_open_inflight = "HALF_OPEN", 0
else:
return self._reject(fallback)
if self.state == "HALF_OPEN":
if self.half_open_inflight >= self.half_open_max:
return self._reject(fallback)
self.half_open_inflight += 1
try:
result = fn()
except Exception:
self._record(False)
raise
self._record(True)
return result
def _record(self, ok: bool):
with self.lock:
now = time.monotonic()
if self.state == "HALF_OPEN":
self.half_open_inflight -= 1
if ok:
self.state = "CLOSED"; self.events.clear()
else:
self.state, self.opened_at = "OPEN", now
return
self.events.append((now, ok)); self._trim(now)
fails = sum(1 for _, s in self.events if not s)
if len(self.events) >= self.min_calls and fails / len(self.events) >= self.failure_ratio:
self.state, self.opened_at = "OPEN", now
def _reject(self, fallback):
if fallback is not None:
return fallback()
raise CircuitOpenError()
Bulkhead(艙壁隔離)
名稱來自船艙的隔間:一個艙進水不會讓整艘船沉。做法是為不同下游或不同類型的請求分配獨立的資源池(thread pool、connection pool、semaphore、甚至獨立的服務實例/叢集)。例如:呼叫「推薦模型服務」最多用 50 個並行,呼叫「報表服務」最多用 10 個;報表服務卡住時,只會耗盡它自己的 10 個名額,不會拖垮出價主流程。K8s 中用獨立 deployment、resource limit 也是 bulkhead 的實現。
Rate Limiting
| 演算法 | 原理 | 特性 |
|---|---|---|
| Fixed window | 每個時間窗計數 | 簡單;窗口邊界可能瞬間放行 2 倍流量 |
| Sliding window log | 記錄每個請求時間戳 | 精確但記憶體 O(請求數) |
| Sliding window counter | 目前窗 + 前一窗 × 重疊比例 | 近似精確、O(1) 記憶體,實務常用 |
| Token bucket | 以固定速率補 token,桶有容量上限,請求取 token | 允許突發(burst),最常用(Nginx 的 burst、AWS API Gateway) |
| Leaky bucket | 請求入佇列,以固定速率流出 | 輸出平滑,不允許突發 |
import time
class TokenBucket:
def __init__(self, rate: float, capacity: float):
self.rate, self.capacity = rate, capacity # 每秒補 rate 個,最多存 capacity 個
self.tokens = capacity
self.ts = time.monotonic()
def allow(self, cost: float = 1.0) -> bool:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate) # 惰性補充
self.ts = now
if self.tokens >= cost:
self.tokens -= cost
return True
return False
分散式限流:多台 API server 共用額度時,常把 token bucket 放在 Redis 並用 Lua 腳本原子化;或每台分配「總額度 / 台數」的本地額度(不精確但零延遲)。
Load Shedding、Backpressure 與 Graceful Degradation
- Load shedding(卸載)
- 伺服器自己判斷過載時(佇列長度、CPU、in-flight 請求數、請求在佇列中已等待過久),主動拒絕部分請求(回 503/429),保住其餘請求的延遲。原則:越早拒絕越便宜;優先丟棄低優先級流量;請求在佇列中等太久(已超過 client deadline)就直接丟掉,因為 client 早已放棄了。
- Backpressure(背壓)
- 下游處理不過來時,把「慢一點」的訊號傳回上游,而不是無限緩衝。例如有界佇列滿了就阻塞生產者、TCP 流量控制(receive window)、Reactive Streams 的 request(n)、Kafka consumer 自然地以自己的速度 poll。無界佇列是隱藏的炸彈:它只是把 OOM 延後,並讓延遲無限增長。
- Graceful degradation(優雅降級)
- 部分功能故障時,提供「較差但可用」的結果。例如:個人化推薦服務掛了,回傳熱門商品;user profile 讀不到,就用 contextual 資訊(地區、裝置)出價或放棄出價;非核心功能(如即時報表)直接關閉。通常搭配 feature flag 與斷路器 fallback。
以廣告平台常見做法為例:Ad exchange 給 bidder 的回應時限通常約 100ms,逾時等於放棄出價。因此 bidder 內部每個依賴(user profile 查詢、CTR 模型推論、預算檢查)都要有嚴格的子 timeout(例如 10–20ms),超時就走降級路徑(沒 profile 就用 contextual 特徵、模型超時就用簡單規則或不出價)。過載時直接回 204 No Bid 是最便宜的 load shedding——對 exchange 來說「不出價」是完全合法的回應,比逾時好得多。各依賴之間用 bulkhead 隔離,避免報表或 logging 卡住影響出價主路徑。
冪等(Idempotency)與 Exactly-once 的實際含義
重試是韌性的基礎,而重試安全的前提是冪等:同一操作執行一次或多次,系統最終狀態相同。HTTP 語意上 GET/PUT/DELETE 是冪等的,POST 不是。SET balance = 100 冪等,balance += 10 不冪等。
Exactly-once delivery 在一般網路上是不可能的(兩將軍問題:發送方永遠無法確定對方是否收到最後一個確認)。實務上說的 exactly-once 其實是 effectively-once / exactly-once processing:
Exactly-once 效果 = At-least-once 傳遞(會重送)+ 冪等/去重的處理。訊息可能被送達多次,但對狀態的影響只發生一次。
實作冪等的常見手法:
- Idempotency key:client 為每個邏輯操作產生唯一 key(如 UUID),放在 header(例如 Stripe 的
Idempotency-Key)。伺服器記錄 key → 結果;重複請求直接回傳先前的結果。 - 唯一約束:以業務 ID(event_id、order_id)建 unique index,重複插入會失敗,捕捉錯誤當成功即可(
INSERT ... ON DUPLICATE KEY/ON CONFLICT DO NOTHING)。 - 條件更新 / 版本號:
UPDATE ... WHERE version = 7,重放的請求因版本不符而無效。 - 把操作轉為絕對值:與其送「+10」,不如送「事件 X 的金額是 10」,接收方以事件 ID 去重後再累加。
- 結果與去重記錄在同一個交易中寫入:否則「處理成功但去重記錄寫入失敗」又會重複處理。
# 以 DB 唯一約束實作 idempotency key(PostgreSQL;psycopg 3)
# CREATE TABLE idempotency (key TEXT PRIMARY KEY, status TEXT, response JSONB, created_at TIMESTAMPTZ DEFAULT now());
import json
def handle_charge(conn, idem_key: str, req: dict) -> dict:
with conn.transaction():
cur = conn.execute(
"INSERT INTO idempotency (key, status) VALUES (%s, 'IN_PROGRESS') "
"ON CONFLICT (key) DO NOTHING RETURNING key", (idem_key,))
if cur.fetchone() is None: # 已存在:重複請求
row = conn.execute("SELECT status, response FROM idempotency WHERE key = %s",
(idem_key,)).fetchone()
if row[0] == "DONE":
return row[1] # 回傳第一次的結果
raise RuntimeError("409 Conflict: request in progress")
result = do_charge(conn, req) # 業務寫入與去重紀錄在同一交易
conn.execute("UPDATE idempotency SET status = 'DONE', response = %s WHERE key = %s",
(json.dumps(result), idem_key))
return result
(1)去重表無限增長:要設 TTL(例如保留 24 小時或 7 天,需大於最大重試視窗)。(2)只檢查「是否存在」再寫入(check-then-act)有競態,必須靠唯一約束或原子操作。(3)Kafka 的 exactly-once 只涵蓋「從 Kafka 讀、寫回 Kafka」;寫到外部 DB 或呼叫外部 API 時,仍需要冪等的 sink 或把 offset 與結果存在同一個交易中。
服務發現與 Service Mesh
在容器環境中,服務實例的 IP 隨時在變,呼叫端需要動態找到健康的實例。
Client-side discovery
Client 查詢 registry(Consul、etcd、Eureka)取得實例列表,自己做負載平衡。延遲低、可做智慧路由,但每種語言都要實作 client library。
Server-side discovery
Client 打到固定的 LB / DNS 名稱,由 LB 查 registry 轉發。K8s Service(kube-proxy + CoreDNS)、AWS ALB 皆屬此類。對 client 透明,但多一跳。
注意 K8s Service 對長連線(gRPC/HTTP2)的負載平衡是連線層級的:同一條 HTTP/2 連線上的所有請求都會打到同一個 pod,容易不平均。解法是 client-side LB(gRPC 搭配 headless service)或 service mesh。
Service mesh(Istio、Linkerd):在每個 pod 旁放一個 sidecar proxy(Envoy),所有進出流量都經過它,由 control plane 統一下發設定。把 retry、timeout、circuit breaking、mTLS、流量切分(canary)、分散式追蹤從應用程式碼中抽離到基礎設施層。代價:每跳多一點延遲與 CPU/記憶體、維運複雜度高。新趨勢是 sidecar-less(Istio ambient mode、Cilium 以 eBPF 實作)以降低開銷。
被問「下游服務變慢怎麼辦」時,有層次地列出:timeout(含 deadline propagation)→ 有限次數 + exponential backoff + jitter 的重試(只對冪等操作,並有 retry budget)→ circuit breaker 快速失敗 → bulkhead 隔離 → load shedding 保護自己 → fallback 降級。並強調「無界佇列與無限重試是造成 cascading failure 的兩大元兇」。
分散式與機率型資料結構 深入 高頻
職缺特別寫到「分散式演算法與資料結構」。在海量資料下,「精確」往往太貴:記住 10 億個 user id 的集合要數十 GB,而用 HyperLogLog 估計 unique 數只要 12KB、誤差約 1%。這些結構的共同特點:用可控的誤差換取數量級的空間與時間節省,而且通常可合併(mergeable)——後者讓它們天生適合分散式:各節點各自計算,最後合併。
| 結構 | 回答的問題 | 誤差型態 | 空間 | 可合併 | 典型用途 |
|---|---|---|---|---|---|
| Bloom filter | x 在集合中嗎? | 可能偽陽性,絕無偽陰性 | 1% 誤判率約每元素 9.6 bits | 是(OR) | LSM-tree 跳過 SSTable、快取穿透防護、爬蟲已訪 URL |
| Count-Min Sketch | x 出現幾次? | 只會高估 | O((1/ε)·log(1/δ)) | 是(逐格相加) | heavy hitters、熱點 key 偵測、近似頻次上限 |
| HyperLogLog | 有幾個不重複元素? | 相對標準誤差 1.04/√m | Redis 實作 12KB,誤差 0.81% | 是(逐暫存器取 max) | UV、unique reach、distinct count |
| Merkle tree | 兩份資料哪裡不同? | 精確(hash 碰撞可忽略) | O(n) hash | — | anti-entropy、Git、區塊鏈 |
| Skip list | 有序集合的查詢/範圍 | 精確(機率性平衡) | O(n) | — | Redis ZSET、LevelDB/RocksDB memtable |
| Consistent hashing | key 屬於哪個節點? | — | O(節點 × vnode) | — | 快取叢集、Dynamo/Cassandra 分區 |
Bloom Filter
一個長度 m 的 bit array 與 k 個獨立 hash 函式。加入元素:把 k 個 hash 位置設為 1。查詢:k 個位置全為 1 才回答「可能存在」,任一為 0 則「一定不存在」。
- 插入 n 個元素後偽陽性率 ≈ (1 − e−kn/m)k。
- 最佳 k = (m/n)·ln 2;給定目標誤判率 p,所需 m = −n·ln p / (ln 2)2,約每元素 1.44·log2(1/p) bits:p=1% 約 9.6 bits、k≈7。
- 不能刪除(清掉某 bit 會影響其他元素)。需要刪除用 Counting Bloom filter(每格改成小計數器)或 Cuckoo filter(支援刪除,低誤判率時更省空間)。
- 無法擴容:元素超過設計容量時誤判率急升,可用 Scalable Bloom filter(滿了就疊加新的一層)。
import hashlib
import math
class BloomFilter:
def __init__(self, capacity: int, error_rate: float = 0.01):
self.m = math.ceil(-capacity * math.log(error_rate) / (math.log(2) ** 2))
self.k = max(1, round(self.m / capacity * math.log(2)))
self.bits = bytearray((self.m + 7) // 8)
def _positions(self, item: str):
# Kirsch-Mitzenmacher:用兩個 hash 模擬 k 個 → g_i(x) = h1 + i*h2
d = hashlib.blake2b(item.encode(), digest_size=16).digest()
h1 = int.from_bytes(d[:8], "little")
h2 = int.from_bytes(d[8:], "little") | 1
return ((h1 + i * h2) % self.m for i in range(self.k))
def add(self, item: str) -> None:
for p in self._positions(item):
self.bits[p >> 3] |= 1 << (p & 7)
def __contains__(self, item: str) -> bool:
return all(self.bits[p >> 3] & (1 << (p & 7)) for p in self._positions(item))
bf = BloomFilter(capacity=1_000_000, error_rate=0.01)
print(bf.m // 8 // 1024, "KiB,", bf.k, "hashes") # 約 1170 KiB, 7 hashes
bf.add("user:42")
print("user:42" in bf, "user:43" in bf) # True, (極可能) False
攻擊者大量查詢不存在的 id,每次都 cache miss 打到 DB。把所有合法 id 放進 Bloom filter,查詢前先檢查:「一定不存在」就直接回 404。Redis 有 RedisBloom 模組(BF.ADD/BF.EXISTS)。另一招是把「不存在」也快取起來(negative caching,設短 TTL)。
Count-Min Sketch
d 列 × w 欄的計數器矩陣,每列一個獨立 hash。加入 x:每列在 h_i(x) % w 的格子 +1。查詢:取 d 個格子的最小值(因為碰撞只會讓計數變大,最小值最接近真值)。
誤差保證:估計值 ≤ 真值 + ε·N 的機率至少 1 − δ(N 是總計數),其中 w = ⌈e/ε⌉、d = ⌈ln(1/δ)⌉。例如 ε=0.001、δ=0.01 → w=2719、d=5,約 1.4 萬個計數器,就能追蹤任意多個 key 的近似頻次。搭配一個小的 min-heap 即可求 Top-K heavy hitters。
import hashlib
import heapq
import math
class CountMinSketch:
def __init__(self, epsilon=0.001, delta=0.01):
self.w = math.ceil(math.e / epsilon)
self.d = math.ceil(math.log(1 / delta))
self.table = [[0] * self.w for _ in range(self.d)]
def _idx(self, key: str, row: int) -> int:
h = hashlib.blake2b(key.encode(), digest_size=8, salt=row.to_bytes(16, "little")).digest()
return int.from_bytes(h, "little") % self.w
def add(self, key: str, n: int = 1) -> None:
for r in range(self.d):
self.table[r][self._idx(key, r)] += n
def estimate(self, key: str) -> int:
return min(self.table[r][self._idx(key, r)] for r in range(self.d))
def merge(self, other: "CountMinSketch") -> None: # 尺寸與 hash 相同才能合併
for r in range(self.d):
for c in range(self.w):
self.table[r][c] += other.table[r][c]
def top_k(stream, k=10):
cms, heap, in_heap = CountMinSketch(), [], {}
for key in stream:
cms.add(key)
est = cms.estimate(key)
if key in in_heap:
in_heap[key] = est
elif len(in_heap) < k:
in_heap[key] = est
else:
heap = [(c, x) for x, c in in_heap.items()]; heapq.heapify(heap)
if est > heap[0][0]:
del in_heap[heap[0][1]]; in_heap[key] = est
return sorted(in_heap.items(), key=lambda kv: -kv[1])
HyperLogLog
直覺:把元素 hash 成均勻隨機的 bit 串。看到「開頭連續 ρ 個 0」的機率是 2−ρ,所以若觀察到的最大前導零數是 ρ,基數大約是 2ρ。單一估計變異太大,HLL 用 hash 的前 p bits 把元素分到 m = 2p 個暫存器(bucket),每個暫存器記錄自己看過的最大 ρ,最後用調和平均合併估計(抑制離群值):
E = α_m · m² / Σ 2^(−M[j]),並在小基數時改用 linear counting(m · ln(m / 空暫存器數))修正。
- 相對標準誤差 ≈ 1.04 / √m。Redis 用 p=14(16384 個暫存器、每個 6 bits)= 12KB,誤差 0.81%,可估到 264。
- 可合併:兩個 HLL 逐暫存器取 max = 兩個集合「聯集」的 HLL。所以可以各區、各小時分別計算,再任意組合出「全球、整週」的 unique 數。
- 交集不可直接算:只能用排容原理 |A∩B| = |A| + |B| − |A∪B| 估計,當交集很小時相對誤差極大。需要交集時考慮 MinHash / Theta sketch(Apache DataSketches)。
- Redis 指令:
PFADD、PFCOUNT(可多 key,回傳聯集基數)、PFMERGE。BigQuery 的APPROX_COUNT_DISTINCT、Presto/Trino 的approx_distinct、Spark 的approx_count_distinct都是 HLL 家族(HLL++)。
import hashlib
import math
class HyperLogLog:
def __init__(self, p: int = 14):
self.p, self.m = p, 1 << p
self.reg = bytearray(self.m)
self.alpha = 0.7213 / (1 + 1.079 / self.m) # m >= 128 時的修正常數
def add(self, item: str) -> None:
x = int.from_bytes(hashlib.blake2b(item.encode(), digest_size=8).digest(), "big") # 64-bit
idx = x >> (64 - self.p) # 前 p bits 決定暫存器
w = x & ((1 << (64 - self.p)) - 1) # 剩下 64-p bits
rank = (64 - self.p) - w.bit_length() + 1 # 前導零個數 + 1
if rank > self.reg[idx]:
self.reg[idx] = rank
def count(self) -> int:
z = sum(2.0 ** -r for r in self.reg)
est = self.alpha * self.m * self.m / z
zeros = self.reg.count(0)
if est <= 2.5 * self.m and zeros: # 小基數修正:linear counting
est = self.m * math.log(self.m / zeros)
return round(est)
def merge(self, other: "HyperLogLog") -> "HyperLogLog":
out = HyperLogLog(self.p)
out.reg = bytearray(max(a, b) for a, b in zip(self.reg, other.reg))
return out
tokyo, sg = HyperLogLog(), HyperLogLog()
for i in range(600_000): tokyo.add(f"user{i}")
for i in range(400_000, 1_000_000): sg.add(f"user{i}") # 與東京重疊 20 萬人
print(tokyo.count(), sg.count(), tokyo.merge(sg).count()) # 約 600k、600k、1,000k(誤差約 1%)
Skip List
多層的有序鏈結串列:最底層包含所有元素,每個元素以機率 p(通常 1/2 或 1/4)晉升到上一層。查詢從最高層開始往右走,遇到比目標大的就下降一層,期望時間 O(log n)。
- 為什麼 Redis ZSET 用 skip list 而不是紅黑樹:實作簡單、範圍查詢(
ZRANGEBYSCORE)只需找到起點後沿底層走、記憶體與平衡樹相當。Redis 在每個節點加上 span 欄位以 O(log n) 支援ZRANK。ZSET 實際是 dict(member→score,O(1) 查 score)+ skip list(依 score 排序)。小資料量時用 listpack 節省記憶體。 - 並行友善:插入只影響局部指標,容易做出 lock-free 版本(Java 的
ConcurrentSkipListMap),所以 LevelDB/RocksDB 的 memtable 用 skip list。
Gossip Protocol
又稱 epidemic protocol:每個節點每隔一段時間(例如 1 秒)隨機挑選少數幾個節點交換狀態(membership、心跳計數、metadata 版本)。資訊像傳染病一樣擴散,約 O(log N) 輪後傳遍整個叢集。
- 模式:push(主動告知)、pull(主動詢問)、push-pull(交換,收斂最快)。
- 優點:無中心、容錯(部分節點掛掉不影響傳播)、每節點負載固定、可擴展到數千節點。
- 缺點:最終一致、收斂時間是機率性的、訊息有冗餘。
- 使用者:Cassandra(membership 與 schema 版本)、Consul/Serf(memberlist)、Redis Cluster bus、Amazon Dynamo、Bitcoin 交易廣播。
SWIM 失效偵測
傳統的全對全心跳是 O(N²) 訊息,無法擴展。SWIM(Scalable Weakly-consistent Infection-style process group Membership,2002)把失效偵測與成員資訊傳播分開:
- 每個週期,節點隨機 ping 一個成員。
- 逾時未回應,請 k 個其他成員代為 ping(間接探測),避免因 A 與 B 之間單一路徑壅塞而誤判。
- 仍失敗則標記 suspect(而非直接宣告死亡),給 B 反駁的機會;B 用遞增的 incarnation number 宣告自己 alive。
- 成員變更資訊附帶(piggyback)在 ping/ack 訊息上傳播,不需額外訊息。
結果:每節點的訊息負載為常數、偵測時間期望為常數、誤判率低。HashiCorp memberlist(Consul、Nomad、Serf)就是 SWIM 加上 Lifeguard 改良。另一種常見的失效偵測器是 Phi Accrual Failure Detector(Cassandra、Akka):不輸出「死/活」二元結果,而是根據心跳間隔的歷史分佈輸出一個懷疑程度 φ,由應用自行設定閾值。
被問「怎麼算 10 億使用者的 UV」或「怎麼判斷某 user 是否看過這個廣告」時,主動提出機率型結構並說清楚誤差的方向:Bloom filter 只有偽陽性(說看過可能沒看過,說沒看過一定沒看過);CMS 只高估;HLL 有約 1% 的雙向誤差。再說明為何可接受(報表 vs 計費),以及「可合併」如何讓它們在分散式環境中成立。
Appier 情境題:多區 Bidder、預算 Pacing、Unique Reach 深入
以下以廣告平台常見做法為例,把本章概念串起來。這類題目沒有標準答案,重點是講清楚取捨與誤差上界。
全球多區 Bidder 架構
Redis / Aerospike")] B1 --> BG1["Budget agent
(本地配額)"] end subgraph US["區域:美西"] EX2["Ad Exchange"] --> LB2["LB"] --> B2["Bidder pods"] B2 --> PC2[("Profile cache")] B2 --> BG2["Budget agent"] end BG1 -->|"花費回報 / 申請配額"| BC["全域 Budget Controller
(強一致儲存)"] BG2 -->|"花費回報 / 申請配額"| BC B1 -->|"bid / win / imp 事件"| K1["Kafka (區域)"] B2 --> K2["Kafka (區域)"] K1 --> MM["跨區複製"] --> DW[("中央資料倉儲")] K2 --> MM
- 讀取路徑全部在地化(PA/EL):user profile、模型、campaign 設定都複製到各區,出價時不跨區。
- Campaign 設定:在中央以強一致方式寫入(single-leader),透過事件/CDC 非同步推到各區,各區接受秒級延遲。暫停 campaign 這類「停損」操作要特別快:可以用專門的高優先級通道推播。
- 事件流:各區先寫本地 Kafka(低延遲、跨區斷線也不影響出價),再非同步複製(MirrorMaker 2 之類)到中央做報表與計費。
- Profile 更新:多個區域都可能更新同一使用者的特徵 → 屬於 multi-leader,用 LWW(大多數特徵)或 CRDT(計數類特徵)合併。
預算 Pacing 的跨節點計數:近似 vs 精確
問題:一個 campaign 日預算 1000 美元,全球數百個 bidder pod 同時在花這筆錢,每秒上萬次出價。如何不超支、又能平均地花完?
| 方案 | 做法 | 一致性 | 延遲影響 | 超支上界 | 風險 |
|---|---|---|---|---|---|
| A. 中央計數器 | 每次出價前到 Redis/DB 原子扣款(DECRBY 或 Lua 檢查餘額) | 強(單點) | 每次出價多一個 RTT;跨區不可行 | 近 0(但贏標價格要事後才知道) | 熱 key、單點故障、跨區延遲 |
| B. 配額下放(lease/token) | 中央把預算切成小額度(例如每次 5 美元)發給各 pod,pod 本地扣,快用完再申請 | 有界誤差 | 幾乎無(本地操作) | ≈ 所有 pod 手上未用完的配額 + 在途的贏標 | 配額太大 → 超支;太小 → 中央壓力大 |
| C. 本地計數 + 週期同步(CRDT) | 各 pod 本地累計花費,每秒彙總到中央,中央算出全域花費後下發「節流係數」 | 最終一致 | 無 | ≈ 花費速率 × 同步延遲 | 同步延遲變大時超支擴大 |
實務常見組合:B + C。中央 controller 依照每小時目標花費曲線(pacing,例如依流量預測平滑分配)計算每個區域、每個 pod 的配額,pod 在本地扣減;花費事件同時回報給中央校正。快接近日預算時縮小配額粒度(例如剩 5% 時配額從 5 美元降為 0.5 美元),把超支上界壓得更低。另外,因為出價時只知道出價,贏標價格(second price 或 first price)要等 win notice 回來才確定,所以計算可用額度時要把「已出價未知結果」的在途金額保守地預扣。
import threading
class LocalBudget:
"""Pod 本地的預算配額:出價路徑只做記憶體操作,背景向中央補貨。"""
def __init__(self, campaign_id, controller, chunk=5.0, low_watermark=0.3):
self.campaign_id, self.controller = campaign_id, controller
self.chunk, self.low_watermark = chunk, low_watermark
self.available = 0.0 # 已向中央取得、尚未使用的配額
self.inflight = 0.0 # 已出價、尚未收到 win/loss 結果的金額(保守預扣)
self.lock = threading.Lock()
self.refilling = False
def try_reserve(self, bid_price: float) -> bool:
with self.lock:
if self.available - self.inflight < bid_price:
self._maybe_refill()
return False # 配額不足:本次不出價(安全地降級)
self.inflight += bid_price
if self.available - self.inflight < self.chunk * self.low_watermark:
self._maybe_refill()
return True
def settle(self, bid_price: float, win_price: float | None) -> None:
with self.lock:
self.inflight -= bid_price
if win_price is not None:
self.available -= win_price
def _maybe_refill(self):
if self.refilling:
return
self.refilling = True
def work():
granted = self.controller.grant(self.campaign_id, self.chunk) # 中央原子扣減全域餘額
with self.lock:
self.available += granted
self.refilling = False
threading.Thread(target=work, daemon=True).start()
好的回答會給出誤差上界而不是「不會」:「超支最多是所有 pod 手上的未用配額加上在途贏標金額,以 200 個 pod × 0.5 美元的尾段配額計算約 100 美元;若業務要求更嚴,可以在最後 1% 預算切換為中央強一致扣款(犧牲延遲,但此時流量本來就該被節流),或接受超支並以合約中的容許範圍處理。」這展現了你知道 CAP/PACELC 在實際業務中的意義。
用 HyperLogLog 算 Unique Reach
Reach(觸及人數)是廣告主最在意的指標之一:「這個 campaign 本週觸及了多少不重複使用者?」精確計算需要保存所有 user id 的集合:1 億使用者 × 每個 id 約 16 bytes 以上的 set 開銷,單一 campaign 就要數 GB,乘上數萬個 campaign × 各種日期區間組合,完全不可行。
- Stream processor(Flink/Spark Streaming)消費曝光事件,為每個
(campaign_id, 區域, 小時)維護一個 HLL(約 12KB)。 - 每小時把 HLL sketch 以二進位寫入儲存(Redis、或資料倉儲的 BYTES 欄位)。
- 查詢「某 campaign 過去 7 天全球 reach」時,取出 7×24×區域數個 sketch,逐暫存器取 max 合併後估計——因為合併是聯集,跨天重複的使用者自動去重。
- 誤差約 1%,報表場景完全可接受;若用於計費(例如按 reach 收費),則以離線批次在資料倉儲精確計算
COUNT(DISTINCT)作為帳單依據。
import redis
r = redis.Redis()
def on_impression(campaign_id: int, user_id: str, region: str, hour: str):
r.pfadd(f"reach:{campaign_id}:{region}:{hour}", user_id)
def weekly_reach(campaign_id: int, hours: list[str], regions: list[str]) -> int:
keys = [f"reach:{campaign_id}:{rg}:{h}" for h in hours for rg in regions]
return r.pfcount(*keys) # PFCOUNT 多個 key = 聯集基數,不需先 PFMERGE
(1)不能把每天的 UV 相加得到每週 UV(會重複計算同一人),必須合併 sketch。(2)兩個 campaign 的「共同觸及」是交集,HLL 排容估計誤差大,需要 Theta sketch。(3)不同系統的 HLL 格式不相容(Redis、BigQuery、Druid 的 sketch 不能互相合併),pipeline 中要統一實作。
延伸:Frequency Capping
「同一使用者一天最多看到這個廣告 3 次」。精確做法是以 (user_id, campaign_id, day) 為 key 的計數器存在低延遲 KV(Redis/Aerospike),出價時讀取、曝光時遞增,並以 user_id 做分區讓同一使用者的計數落在同一節點。跨區時,使用者通常會被路由到最近的區域,所以以本地計數為主、跨區非同步合併(G-Counter 語意)通常足夠;偶爾多投一次的代價遠小於每次出價跨區查詢的延遲。這又是一次 PA/EL 的取捨。
面試自測題
請解釋 CAP 定理。為什麼說「三選二」是誤導?
CAP 指出在網路分區(P)發生時,系統無法同時保證線性一致性(C)與可用性(A,每個非故障節點都要回非錯誤回應)。分散式系統中分區必然會發生,不是能選擇放棄的,所以真正的選擇是「分區時要 C 還是 A」:CP 系統(etcd、ZooKeeper)讓少數派拒絕服務;AP 系統(Cassandra、DynamoDB 預設)各自接受寫入、事後合併。另外 CAP 是針對單一操作的性質,同一系統可以對不同操作有不同選擇。追問方向:PACELC——平時沒有分區時仍要在延遲與一致性之間取捨;以及 CAP 的 C 是 linearizability,不是 ACID 的 C。
Linearizability 與 Serializability 有什麼差別?
Linearizability 是單一物件、單一操作的新近性保證:操作看起來在呼叫與回應之間某個瞬間原子生效,且順序符合真實時間——讀取一定看到最新完成的寫入。Serializability 是交易(多物件、多操作)的隔離保證:交易的執行結果等同於某個序列執行順序,但該順序不需要符合真實時間。兩者都滿足稱為 strict serializability(Spanner 的 external consistency)。例如可序列化的資料庫若用 snapshot 從過期副本讀取,就不是 linearizable。
Quorum 讀寫 W+R>N 為什麼能讀到最新值?它保證 linearizability 嗎?
因為寫入成功的 W 個副本與讀取的 R 個副本至少重疊一個,讀取時用版本號挑最新的即可。但它不保證 linearizability:sloppy quorum 讓寫入落在替身節點;並行寫入用 LWW 時時鐘偏差會丟資料;寫入只在部分副本成功時不會回滾,導致之後的讀取時而新時而舊(違反「讀到新值後不能再讀到舊值」);含新值的節點故障後從舊副本恢復等。要達到 linearizable 需要讀取時同步 read repair(先寫回多數再回傳),或直接用共識(如 Cassandra LWT 用 Paxos)。
新增一台快取節點時,hash(key) % N 與 consistent hashing 各會讓多少 key 移動?virtual node 的作用是什麼?
mod N 從 N 變成 N+1 時,key 留在原位的機率只有約 1/(N+1),幾乎全部搬家,快取命中率瞬間歸零、DB 被打爆。Consistent hashing 只有新節點在環上「接管」的那段範圍會移動,平均約 K/(N+1)。Virtual node 讓每台實體節點在環上有上百個位置:(1)負載更平均,標準差隨 vnode 數下降;(2)節點下線時其負載分散給多台而非只給順時針下一台;(3)可以依機器規格給不同 vnode 數做加權。追問:Redis Cluster 用 16384 固定 slot 而非 consistent hashing,概念上是「固定數量 partition」。
請描述 Raft 的 leader election 過程,以及如何避免沒有完整 log 的節點當選。
Follower 在隨機 election timeout(如 150–300ms)內沒收到 heartbeat 就變成 candidate,term+1、投給自己、平行送 RequestVote。每個節點每 term 只投一票且持久化 votedFor。投票限制:只投給 log「至少和自己一樣新」的 candidate(比較最後一筆 entry 的 term,相同再比 index)。由於已 commit 的 entry 存在於多數節點,而當選需要多數票,兩個多數必有交集,所以當選者一定擁有所有已 commit 的 entry(Leader Completeness)。分票時靠隨機 timeout 重選解開。追問:leader 不能用舊 term entry 的多數複製來推進 commit(Figure 8),所以新 leader 上任會先寫 no-op。
為什麼共識叢集通常是 3 或 5 台,而不是 4 台或 9 台?
容錯數是 ⌊(N−1)/2⌋:3 台容忍 1、4 台也只容忍 1(多數是 3)、5 台容忍 2。偶數台沒有增加容錯,卻增加了每次寫入需等待的確認數與分票機率。節點越多,寫入要等越多節點的 fsync 與網路往返,延遲與吞吐變差;所以 9 台通常不划算,若要擴展讀取可以加非投票的 learner/observer。共識叢集只存少量關鍵 metadata。
用 Redis 實作分散式鎖要注意什麼?什麼情況下 Redis 鎖不夠安全?
取鎖用 SET key token NX PX ttl(原子地設定與過期,避免 SETNX+EXPIRE 中間當機),token 用唯一值;釋放用 Lua 比對 token 再 DEL,避免刪到別人的鎖;長任務需要 watchdog 續約。不安全之處:(1)持鎖者 GC 停頓或網路延遲超過 TTL,鎖過期被別人取得後,原持鎖者醒來仍繼續寫;(2)主從非同步複製,master 掛掉時鎖遺失。Redlock 依賴時鐘假設且無 fencing token,有爭議。結論:效率用途用 Redis 即可;正確性用途要用 etcd/ZooKeeper 並在資源端檢查 fencing token,或用 DB 條件更新/分區消除競爭。
什麼是 fencing token?為什麼它能解決 GC pause 造成的雙重持鎖?
鎖服務每次授予鎖時發一個單調遞增的數字(如 etcd revision、ZooKeeper zxid)。Client 寫入資源時帶上 token,資源端記住看過的最大值並拒絕更小的 token。A 拿 token 33 後停頓、B 拿 34 寫入,A 醒來用 33 寫會被拒絕。關鍵在於正確性由資源端強制,而不是依賴持鎖者判斷自己的 lease 是否仍有效(它無法可靠判斷)。若資源不支援 token 檢查,可用 UPDATE ... WHERE fence < :token 或版本號樂觀鎖達到同效果。
2PC 的流程與主要缺點是什麼?微服務為什麼多改用 Saga?
Phase 1 coordinator 送 PREPARE,參與者持久化並鎖住資源後投票;Phase 2 全部 YES 則 coordinator 先寫下 COMMIT 決定再通知全體提交,否則 abort。缺點:參與者投 YES 後若 coordinator 當機,參與者處於 in-doubt 狀態,只能持鎖等待(阻塞);同步往返多、延遲高;coordinator 單點;異質系統未必支援 XA。微服務改用 Saga:拆成一串本地交易,失敗時執行補償交易,沒有跨服務的鎖,可用性高;代價是沒有隔離性(中間狀態可見)、補償需冪等且必須最終成功。可依流程複雜度選 orchestration(Temporal、Step Functions)或 choreography(事件驅動)。
「先寫 DB 再送 Kafka 訊息」有什麼問題?怎麼解?
這是 dual write:兩個系統沒有共同交易。寫 DB 成功、送訊息前 process 當機,下游永遠收不到;先送訊息再寫 DB 失敗則下游收到不存在的事件。即使都成功,兩個並行請求也可能以不同順序寫入兩邊。解法是 Transactional Outbox:在同一個 DB 交易中寫業務表與 outbox 表,再由 relay(輪詢或 Debezium CDC 讀 binlog)送到 Kafka。relay 可能重送,因此下游必須冪等(以 event_id 去重)。另一做法是 event sourcing:以事件 log 為唯一真相來源,DB 狀態由事件推導。
Snowflake ID 的結構是什麼?時鐘回撥怎麼處理?
64 bits:1 bit 符號 + 41 bits 毫秒時間戳(自訂 epoch,約 69 年)+ 10 bits 機器 id + 12 bits 序號(每毫秒 4096 個)。趨勢遞增、無需每次協調、適合當 BIGINT 主鍵。時鐘回撥時可能產生重複:小幅回撥(數毫秒)就等待追上;大幅回撥則拒絕發號並告警,或以 max(now, last_ts) 繼續(序號用完就借下一毫秒);NTP 設定為 slew 模式;或保留 bits 記錄回撥次數。機器 id 透過 ZooKeeper/etcd 或 StatefulSet 序號分配,避免兩台用同一 id。另外傳給 JavaScript 要轉字串(超過 2^53 精度)。
為什麼 UUID v4 當 MySQL 主鍵效能差?有什麼替代方案?
InnoDB 是聚簇索引(clustered index),資料依主鍵順序存在 B+tree 葉節點中。v4 完全隨機,每次插入落在隨機位置,造成頻繁 page split、頁面填充率低、buffer pool 中需要常駐的頁面變多(工作集等於整棵樹),寫入放大與快取命中率都變差;而且 16 bytes 比 BIGINT 大,所有二級索引都會帶上主鍵,空間也放大。替代:UUID v7 或 ULID(時間有序,插入接近 append)、Snowflake(64 bits)、或內部用自增 BIGINT 主鍵、對外暴露 UUID 欄位。
Lamport timestamp 與 vector clock 差在哪?什麼時候需要 vector clock?
Lamport clock 是單一整數,保證 A happens-before B 則 L(A)<L(B),但反之不成立,所以只能產生與因果一致的全序,無法判斷兩事件是否並行。Vector clock 每個節點一個分量,V(A)<V(B) 若且唯若 A→B,互不可比則為並行,因此能偵測衝突寫入。需要偵測並保留衝突(如 Riak siblings、購物車合併)時用 vector clock;只需要一致排序(如全序廣播)時 Lamport 即可。HLC 則兼具因果與接近物理時間,適合 MVCC 版本。
Retry 為什麼要加 exponential backoff 與 jitter?還要注意什麼?
下游過載時立即重試只會加重負載;多層呼叫鏈每層都重試會指數放大流量(retry storm)。Exponential backoff 讓重試間隔指數增長給下游恢復時間;jitter 打散大量 client 的重試時間點,避免同步尖峰(thundering herd),full jitter(0 到 backoff 均勻隨機)效果好。還要注意:只重試暫時性錯誤與冪等操作(或帶 idempotency key);設最大次數與整體 deadline;設 retry budget(重試佔比上限);只在一層重試;搭配 circuit breaker,下游明顯故障時直接快速失敗。
請說明 circuit breaker 的三種狀態與轉換。
Closed:正常呼叫並統計失敗率(滑動視窗),失敗率超過閾值且樣本數足夠時轉 Open。Open:不呼叫下游,直接回錯誤或 fallback,經過冷卻時間後轉 Half-open。Half-open:放行少量試探請求,成功則回到 Closed,失敗則回到 Open 並重新計時。目的是快速失敗以保護自己(不讓 thread/連線卡在必然失敗的呼叫上)並給下游恢復空間。追問:閾值要有最小請求數避免低流量誤判;fallback 本身要輕量且不能依賴同一個下游;與 bulkhead、timeout 一起使用。
Exactly-once 真的做得到嗎?實務上怎麼達成?
在不可靠網路上 exactly-once delivery 做不到(兩將軍問題:送方無法確知對方收到)。實務的 exactly-once 是 processing 語意:at-least-once 傳遞(失敗就重送)+ 冪等或去重的處理,使效果只發生一次。做法:idempotency key + 唯一約束、將處理結果與「已處理標記/offset」寫在同一個交易、使用天然冪等的操作(upsert、set 而非 increment)。Kafka 的 EOS(idempotent producer + transactions)只保證 Kafka 內部 read-process-write 的原子性,寫外部系統仍需冪等 sink。
HyperLogLog 的原理是什麼?為什麼適合分散式計算 UV?
對元素做均勻 hash,前 p bits 選擇 m=2^p 個暫存器之一,剩餘 bits 的前導零數 +1 若大於暫存器現值就更新。前導零最大值 ρ 暗示基數約 2^ρ,用調和平均合併所有暫存器的估計並做小基數修正。相對誤差約 1.04/√m,Redis 用 12KB 達 0.81%。適合分散式是因為可合併:逐暫存器取 max 即等於聯集,各節點、各時段、各區域分開計算再組合,且重複元素自動去重。限制:交集只能排容估計(誤差大,改用 Theta sketch);不能列出元素;不同實作格式不相容。
Bloom filter 的誤判是哪一種?如何決定大小?可以刪除元素嗎?
只有偽陽性(false positive):說「可能存在」可能其實不在;說「不存在」一定不在。給定 n 個元素與目標誤判率 p,m = −n·ln p/(ln2)²,k = (m/n)·ln2;1% 約每元素 9.6 bits、7 個 hash。標準 Bloom filter 無法刪除(清 bit 會誤傷其他元素),需要 Counting Bloom filter 或 Cuckoo filter。超過設計容量誤判率會急升,可用 scalable Bloom filter。用途:LSM-tree 查詢跳過不含該 key 的 SSTable、快取穿透防護、推薦去重(已推薦過的內容)。
全球數百個 bidder 共用一個 campaign 的日預算,你怎麼設計才不會嚴重超支,又不拖慢出價?
出價路徑不能每次跨區查詢中央計數器(延遲與熱 key)。做法:中央 budget controller 以強一致儲存全域餘額,依 pacing 曲線把預算切成小配額發給各區/各 pod;pod 在記憶體內扣減,低於水位時非同步補貨;出價時預扣在途金額,win notice 回來再結算實際價格。超支上界 ≈ 所有 pod 未用配額 + 在途贏標,接近預算尾聲時縮小配額粒度或切換為中央強一致扣款。花費事件經 Kafka 回報給中央校正,各區斷線時 pod 用完配額即停止出價(fail-safe)。這是 PACELC 中 EL 的取捨,加上有界誤差的工程控制。
SWIM 如何改善傳統心跳式的失效偵測?
全對全心跳是 O(N²) 訊息。SWIM 每週期每節點只隨機 ping 一個成員,失敗時請 k 個成員間接 ping,避免單一路徑問題造成誤判;仍失敗先標為 suspect,給對方以更高 incarnation number 反駁的機會,逾時才宣告死亡;成員變更資訊 piggyback 在 ping/ack 上以 gossip 散播。每節點負載為常數、偵測時間期望為常數、誤判率低。HashiCorp memberlist(Consul、Serf)是代表實作。也可提 Phi accrual failure detector 輸出連續的懷疑程度。