【從一台 Server 到分散式架構】第 18 篇:多間廚房誰是值日生?——分散式協調與領導者選舉

WebSocket 上線後,小明他們的通知系統終於讓人滿意。學生交作業,老師的頁面會跳出提示;老師回覆,學生也能立刻看到。

但某天早上,客服群組傳來一段對話截圖:

「我買課後收到三封確認信,是系統出 bug 嗎?」

小明打開後台查了一下,發現那個時間點剛好有一次背景任務的調度——系統每天早上八點會跑一支「寄送每日學習提醒信」的定時腳本。

問題是,他們的後端現在有三台機器,而這三台機器同時都在執行同一支腳本


問題的根源:多台機器,各自為政

在只有一台機器的時候,「每天八點執行一次」是個簡單的問題——開一個 cron job,設定排程,搞定。

有了多台機器之後,事情就不一樣了。

每台機器上都跑著同一份程式,每台都看得到同一份設定,每台都在八點整同時觸發。

結果:

  • 三台機器各自查出「今天還沒發信的用戶列表」
  • 三台機器各自把這份列表走完,各自發了一次信
  • 每個用戶收到三封信

這不是 bug,是分散式系統的預設行為——你沒有明確告訴它「只讓一台做」,它就讓全部都做了。


生活化的比喻:掃地值日生的混亂

想像一間教室,老師說:「今天請一個人掃地。」

教室裡有 30 個學生。如果老師沒有明確指定,可能發生:

  • 沒人去掃(大家都以為別人會去)
  • 三個人同時去搶同一把掃把,重複打掃同一個區域

多台伺服器的處境很像這樣。分散式協調要解決的,就是:

如何讓「多個競爭者」可靠地決定「誰來做這件事」,而且只有一個人做。


解法一:分散式鎖(Distributed Lock)

最直覺的思路:在任務開始前先「搶鎖」,搶到的人才能做,沒搶到的跳過。

概念:

任務開始前:
  嘗試 SET lock:daily-email "server-1" NX EX 300
  → 成功(拿到鎖):執行任務,結束後釋放鎖
  → 失敗(鎖被別人拿走):這台機器跳過,不做

用 Redis 的 SET key value NX EX seconds 可以很簡單地做到分散式鎖:

  • NX:只有 key 不存在時才設定(搶鎖)
  • EX seconds:設定過期時間(避免拿到鎖的機器掛掉後鎖永遠不釋放)

這樣,三台機器同時來搶,只有一台搶到,另外兩台發現鎖已存在,直接跳過。

小明看完之後說:「這跟第 05 篇的 Redis 差不多?」

小傑點頭:「Redis 已經是你們系統裡的工具了。能用現有工具解決,就先用現有工具。」


分散式鎖的陷阱:設了還是不夠安心

小明很快就發現,用 Redis 做分散式鎖有一些邊界情況需要注意:

1) 鎖的過期時間要設多長?

太短:任務還沒跑完,鎖就過期了,另一台機器以為沒人在做,又搶進來 → 還是重複執行。

太長:持鎖的機器如果當機,後面的調度都在等鎖釋放 → 系統卡住。

沒有完美答案,通常的做法是:把鎖的過期時間設成任務預期執行時間的 2~3 倍,配合任務結束後主動釋放鎖

2) 釋放鎖時要確認是「自己的鎖」

這一點看起來很「龜毛」,但它在防一個很常見、也很致命的情境:A 以為自己還握有鎖,但其實鎖早就過期,被 B 拿走了

用一個時間線例子就清楚了:

  1. 10:00:00:Server A 搶到鎖
    lock:daily_report = "A-uuid" (TTL=10s)
  2. 10:00:08:A 還在跑(比預期久一點)
  3. 10:00:10:鎖過期(TTL 到了),Redis 自動刪掉這把鎖
  4. 10:00:11:Server B 來搶,搶到了
    lock:daily_report = "B-uuid" (TTL=10s)
  5. 10:00:12:A 跑完了,順手 DEL lock:daily_report
    結果:A 刪掉的是 B 的鎖 → B 以為自己安全,下一台又可能進來重複執行

所以「釋放鎖」不能只做 DEL key,而是要做:

  • 只有當 value 還是自己的 uuid 時,才刪除

而且這個「確認 value + 刪除」必須是原子操作(不能先 GETDEL,因為中間可能被別人搶走)。

最常見的做法是用 Redis Lua script(概念是 compare-and-delete):

-- if redis.get(key) == my_uuid then redis.del(key) end
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end

3) Redis 本身掛了怎麼辦?

如果你只用一台 Redis,它一掛,分散式鎖也跟著失效。

這個問題可以用 Redis 的 Redlock 演算法(對多台 Redis 節點同時搶鎖,超過半數成功才算搶到)來緩解,但實作複雜度也隨之上升。

小明的結論:分散式鎖能解決大部分的「重複執行」問題,但要用得對,細節不少。


解法二:領導者選舉(Leader Election)

分散式鎖是「每次任務臨時搶」的做法。有另一種思路是:

直接選出一台機器當「老大(Leader)」,只有 Leader 負責排程工作;其他機器都是「跟班(Follower)」,等 Leader 掛了再重新選。

這就是領導者選舉(Leader Election)

比喻:

就像一班選出一個班長,班長負責統籌協調;其他同學不用各自行動、重複做事。如果班長轉學了,就重新選一個班長。

在系統層面:

Leader 掛了之後:

這種機制的核心是:Leader 要持續向協調服務「打卡(heartbeat)」,表示自己還活著;一旦斷卡,協調服務就把 Leader 的位置釋放出來,讓其他機器競爭。


為什麼需要專門的協調服務?

小明問:「分散式鎖和領導者選舉,用 Redis 不就夠了?」

小傑說:「單純的鎖用 Redis 沒問題。但協調本身涉及的問題更微妙——你怎麼確定 Redis 知道的『Leader 是誰』是正確的?

這引出了分散式系統裡一個很根本的困難:

多台機器對「同一份狀態」的看法,可能不一致。

舉個例子:

  • Leader 機器跑得很慢,heartbeat 超時了
  • etcd / Zookeeper 認為 Leader 掛了,選出了新 Leader
  • 但舊 Leader 其實還活著,還在執行任務

此時系統裡有兩個 Leader同時在跑,這叫「腦裂(split-brain)」。

Zookeeper 和 etcd 這類協調服務,花了大量工程力在處理這類問題:

  • Paxos(Zookeeper 的 ZAB 協議)或 Raft(etcd)保證分散式共識
  • 只要超過半數節點(quorum)同意,寫入才算成功
  • 保證任一時刻「誰是 Leader」這件事,全叢集的答案是一致的

這些協議的細節超出這篇的範圍,但直覺是:它們用「多數決 + 嚴格的一致性協議」讓「協調這件事本身」是可信的。


Zookeeper vs etcd:選哪個?

兩者都是「分散式協調服務」,核心能力類似:

Zookeeperetcd
來自Apache / YahooCoreOS(現為 CNCF 項目)
共識協議ZAB(類 Paxos)Raft
介面類檔案系統(ZNode)簡單的 key-value
常見搭配Kafka、Hadoop 生態系Kubernetes、現代雲原生
學習曲線較高較低

現代新系統選型,通常傾向用 etcd(設定簡單、介面直覺、K8s 已內建)。如果你的技術棧已經有 Kafka,那 Zookeeper 就已經在了(Kafka 2.8 以前強依賴 Zookeeper;新版 Kafka 已支援 KRaft 模式,可以不再依賴 Zookeeper)。

小明的原則還是那一句:不要為了引入新工具而引入新工具。


小明的落地版本:先用 Redis 鎖,有需要再評估 etcd

小明他們做了分層評估:

第一層(現在):
  用 Redis 分散式鎖解決「定時任務重複執行」的問題。
  代價:Redis 故障時鎖失效,任務可能跑多次。
  可接受嗎?→ 定時報表或提醒信,偶爾多一次可以接受。

第二層(之後,如果必要):
  如果有「絕對只能執行一次」的核心業務邏輯(例如:訂單結帳扣款)
  → 靠應用層的冪等設計(idempotency key)來保證,而不是只靠鎖。

第三層(系統規模更大時):
  如果有跨服務的協調需求、需要可靠的 Leader Election
  → 評估引入 etcd。

這個思路提醒了小明一件事:

分散式鎖能降低「重複執行」的機率,但不能消除它。真正的防護是讓任務本身具備冪等性(idempotent)——就算執行兩次,結果也跟執行一次一樣。

比如發信任務,在發信前先記錄「這個 user 這個任務今天已經發過」,每次都先查這份記錄,就算鎖失敗、任務跑了兩次,第二次也會跳過。


架構全貌

🏗️ 本篇結束後的架構 — 排程協調層的角色


小結與預告

這篇小明學到的重點是:

  1. 多台機器預設是「各自為政」的:你沒明確協調,大家就各做各的,重複執行是正常行為,不是 bug。
  2. 分散式鎖解決「臨時搶工作」的問題:用 Redis SET NX EX 可以快速落地,但要注意過期時間與正確釋放。
  3. 領導者選舉解決「誰來統籌調度」的問題:選一台 Leader 統一負責排程,掛掉就重選;Zookeeper 和 etcd 是保證這件事可靠的協調服務。
  4. 鎖是概率防護,冪等才是根本保障:真正重要的任務,應該設計成「就算跑兩次也安全」,而不是只靠鎖。

到這裡,小明他們的系統已經長得相當複雜:多台後端、多層快取、訊息佇列、WebSocket、協調服務……

有一天,半夜三點,小明的手機響了。告警通知說系統回應異常。他開著筆電,想查是哪個環節出了問題——卻發現自己完全不知道從哪裡開始看。

下一篇,我們來談讓複雜系統「可以被看見」的關鍵——監控、指標與告警(Prometheus / Grafana 的直覺)

相關推薦

2026-04-07
【從一台 Server 到分散式架構】第 31 篇:從面試題反推架構——用本系列思維拆解系統設計考題
「設計一個 URL 短網址服務」「設計一個訊息系統」——系統設計面試問的不是背答案,而是思考方式。這篇整理了一套可以重複使用的面試框架,並用兩道例題示範如何從需求出發,一步步推導出架構、說清楚取捨。
2026-04-06
【從一台 Server 到分散式架構】第 30 篇:限流、排隊與降級實戰——開賣與直播場景
平常流量是 1000 QPS,但「開賣」的那一刻,瞬間湧入 50 萬個請求——系統要怎麼活下來?這篇用課程平台的限量課程開賣和直播開播場景,把第 13 篇學到的限流、降級、熔斷,落地到一個真實的高流量設計,走過每個防護層是怎麼工作的。
2026-04-05
【從一台 Server 到分散式架構】第 29 篇:用同樣的思維看 ChatGPT——AI 聊天系統架構
ChatGPT 看起來像一個聊天視窗,背後卻有幾個特殊的設計挑戰:回應是串流的、推理非常耗資源、每輪對話要記住上下文、系統要支援幾千萬用戶同時使用。這篇用熟悉的架構思維,拆解 AI 聊天系統的關鍵設計。
2026-04-04
【從一台 Server 到分散式架構】第 28 篇:用同樣的思維看 Twitter——社群動態與時序設計
Twitter 的核心功能看起來很簡單:發推文、看動態、按讚留言。但「動態牆」背後藏著一個棘手的設計問題:你追蹤 200 個人,每個人都可能隨時發文——你打開 App 時,那條時序動態要怎麼快速組出來?這篇來看社群動態系統的兩種策略:推(Fan-out on Write)與拉(Fan-out on Read)。