前面的模式談的是分發請求以擴展「每秒請求數」「服務的狀態量」「處理時間」;本章(多節點服務模式的最後一章)談的是如何擴展指派(assignment)

  • 許多系統中存在**所有權(ownership)**的概念:特定行程擁有特定任務——例如分片與熱分片系統中,特定實例擁有分片鍵空間的特定區段。
  • 單一伺服器內的所有權很簡單:只有一個應用在建立所有權,用成熟的行程內鎖(in-process lock)就能確保單一分片或情境只有一個擁有者。但把所有權限縮在單一應用,會犧牲可擴展性(任務無法複製)與可靠性(任務失敗就有一段不可用的時間)。
  • 因此當系統需要所有權時,就需要一套分散式的所有權建立機制。典型運作:三個副本都可能成為主節點(master);起初副本一是主節點,它故障後副本三接手;副本一復原歸隊後,副本三仍維持主節點身分。

圖 9-1:主節點選舉協定的運作——起初選出第一個主節點,當它故障時由第三個主節點接手

建立分散式所有權,往往是設計可靠分散式系統時最複雜也最重要的部分。

先確定你真的需要主節點選舉#

  • 最簡單的所有權形式是單一副本(singleton):一次只有一個實例在跑,它隱含地擁有一切,不需要選舉。應用與部署都簡單,代價是停機時間與可靠性。
  • 在 Kubernetes 這類容器編排系統中跑 singleton,你有這些保證:容器崩潰會自動重啟;容器卡死(有實作健康檢查時)會自動重啟;機器故障時容器會被移到別台機器。
  • 算一算這樣的正常運行時間(uptime)其實「相當不錯」:
    • 行程崩潰或卡死:幾秒內重啟。就算容器每天崩潰一次,也約有三到四個九(每天約 2 秒停機 ≈ 99.99%)。
    • 機器故障:Kubernetes 判定機器故障並搬移約需 5 分鐘。就算叢集每台機器每天都故障,也還有兩個九——而真的如此的話,你的問題遠比這個服務的 uptime 大。
  • 但停機不只來自故障——發版也是:singleton 無法新舊版本並行,升級期間必須下線舊版。若映像檔大、升級要 2 分鐘且每天部署,就只剩兩個九;每小時部署連一個九都不到。(可用預拉映像檔把部署壓到幾秒,但那又增加了當初想避免的複雜度。)

許多應用(例如背景非同步處理)用簡單性換這樣的 SLA 完全可以接受。設計分散式系統的關鍵之一,就是判斷「分散式」這部分是否其實是不必要的複雜。 只有在高可用(四個九以上)是應用的關鍵需求時,才需要多副本、其中一個是指定擁有者的設計。

主節點選舉的基礎#

設服務 Foo 有三個副本 Foo-1、Foo-2、Foo-3,物件 Bar 一次只能被其中一個副本「擁有」——這個副本常被稱為主節點(master),選出主節點、以及主節點故障時選出新主節點的過程就是主節點選舉(master election)

實作有兩條路:

  • 自己實作分散式共識演算法(Paxos、RAFT):複雜度超出本書範圍、也不值得做——就像在組合語言的 compare-and-swap 指令上自己實作鎖,適合大學部作業,實務上一般不值得。
  • 用現成的分散式鍵值儲存:etcd、ZooKeeper、Consul 等已替你實作了共識演算法,提供複寫的可靠資料儲存,以及在其上建構更複雜鎖與選舉抽象所需的基本操作(primitives)。

這些系統提供的基本操作是對特定鍵做 compare-and-swap(比較並交換)——一個原子操作:現值符合預期就寫入新值;不符合回傳 false;鍵不存在且 currentValue 非空則回傳錯誤。此外還能為鍵設存活時間(TTL, time-to-live):TTL 到期後鍵被清空。這兩個功能加起來,就足以實作各式各樣的分散式同步原語。

動手做:部署 etcd

etcd 是 CoreOS 開發的分散式鎖伺服器,健壯且經過大規模正式環境驗證(Kubernetes 也用它)。部署可借助兩個開源專案:Helm(Kubernetes 套件管理器)與 CoreOS 開發的 etcd operator

安裝 helm 工具後:

# Initialize helm
helm init

# Install the etcd operator
helm install stable/etcd-operator

Operator 安裝後會建立代表 etcd 叢集的自訂 Kubernetes 資源。建立叢集需要宣告式設定:

apiVersion: "etcd.coreos.com/v1beta1"
kind: "Cluster"
metadata:
  # Whatever name you want here
  name: "my-etcd-cluster"
spec:
  # 1, 3, 5 are the options for size
  size: 3
  # The version of etcd to install
  version: "3.1.0"

存成 etcd-cluster.yamlkubectl create -f etcd-cluster.yaml,operator 會為 etcd 叢集副本建立 Pod(kubectl get pods 可見)。三個副本都跑起來後取得端點:

export ETCD_ENDPOINTS=kubectl get endpoints example-etcd-cluster
"-o=jsonpath={.subsets[*].addresses[*].ip}:2379,"

寫入測試:

kubectl exec my-etcd-cluster-0000 -- sh -c "ETCD_API=3 etcdctl
--endpoints=${ETCD_ENDPOINTS} set foo bar"

實作鎖#

  • 最簡單的同步形式是互斥鎖(mutual exclusion lock,Mutex)。單機並行程式設計的鎖概念可以直接搬到分散式副本——只是底層從本地記憶體與組合語言指令,換成前述的分散式鍵值儲存。

  • 取得鎖:用 compare-and-swap 把 "0" 換成 "1";鎖還不存在(我們是第一個宣告者)時,改以「前值為不存在」寫入 "1"

    func (Lock l) simpleLock() boolean {
      // compare and swap "1" for "0"
      locked, error = compareAndSwap(l.lockName, "1", "0")
      // lock doesn't exist, try to write "1" with a previous value of
      // non-existent
      if error != nil {
        locked, _ = compareAndSwap(l.lockName, "1", nil)
      }
      return locked
    }
  • 傳統鎖會阻塞到取得為止。用輪詢(while (!l.simpleLock()) { sleep(2) })的問題是鎖釋放後至少要再等一秒才拿得到;幸好許多鍵值儲存支援監看(watch)變化,可改為 waitForChanges(l.lockName)

  • 解鎖就是把 "1" 換回 "0"

TTL:處理持鎖者故障#

  • 分散式系統中,行程可能在持鎖期間故障——沒有人能釋放鎖,系統就卡死了。解法:simpleLock 一律帶 TTL 寫入,在期限內沒解鎖就自動解鎖。

使用分散式鎖時,務必確保受保護的處理不會超過鎖的 TTL。好的做法是在取得鎖時設一個看門狗計時器(watchdog timer),其中包含一個斷言:若 TTL 在你呼叫 unlock 前到期,就讓程式崩潰。

  • 但加了 TTL 後,unlock 反而引入了一個 bug。情境:

    • Process-1 以 TTL t 取得鎖 → 不知何故跑得超慢、超過 t → 鎖過期 → Process-2 取得鎖 → Process-1 跑完呼叫 unlock(解掉的是 Process-2 的鎖)→ Process-3 取得鎖 → Process-2 與 Process-3 同時以為自己持鎖,鬧劇上演。
  • 解法:鍵值儲存為每次寫入提供資源版本(resource version)。lock 函式記下版本,compare-and-swap 擴充為「值符合資源版本與上鎖當時相同」才成功——確保只有 TTL 未過期時才解得了鎖:

    func (Lock l) simpleLock() boolean {
      // compare and swap "1" for "0"
      locked, l.version, error = compareAndSwap(l.lockName, "1", "0", l.ttl)
      // lock doesn't exist, try to write "1" with a previous value of
      // non-existent
      if error != null {
        locked, l.version, _ = compareAndSwap(l.lockName, "1", null, l.ttl)
      }
      return locked
    }
    
    func (Lock l) unlock() {
      compareAndSwap(l.lockName, "0", "1", l.version)
    }
動手做:在 etcd 實作鎖

用鍵作為鎖名、以前置條件(precondition)寫入確保一次只有一個持鎖者。為求簡單用 etcdctl 命令列示範(實務上會用程式語言,主流語言都有 etcd 客戶端)。

建立名為 my-lock 的鎖,初始值 unlocked

kubectl exec my-etcd-cluster-0000 -- sh -c \
  "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} set my-lock unlocked"

Alice 與 Bob 都想取得鎖,各自嘗試以「值為 unlocked」為前置條件寫入自己的名字。Alice 先執行、取得鎖:

kubectl exec my-etcd-cluster-0000 -- sh -c \
  "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
      set --swap-with-value unlocked my-lock alice"

Bob 再嘗試就會失敗(Alice 目前持鎖):

kubectl exec my-etcd-cluster-0000 -- sh -c \
  "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
      set --swap-with-value unlocked my-lock bob"
# Error: 101: Compare failed ([unlocked != alice]) [6]

解鎖:Alice 以前置條件 alice 寫回 unlocked

實作所有權#

  • 鎖適合建立暫時的所有權;有時你要的是元件運行期間持續的所有權。例如高可用部署的 Kubernetes 有多個 scheduler 副本,但只有一個在實際做排程決策——而且一旦成為作用中的 scheduler,就持續到該行程故障為止。

  • 把 TTL 拉到很長(一週以上)是個做法,但缺點嚴重:現任擁有者故障後,要等一週 TTL 到期才能選出新擁有者。

  • 正解:可續約的鎖(renewable lock)——擁有者定期續約,就能保有任意長的時間:

    func (Lock l) renew() boolean {
      locked, _ = compareAndSwap(l.lockName, "1", "1", l.version, ttl)
      return locked
    }
  • 在獨立執行緒中每 ttl/2 秒續約一次(降低因時序細節意外過期的風險):

    for {
      if !l.renew() {
        handleLockLost()
      }
      sleep(ttl/2)
    }
動手做:在 etcd 實作租約(lease)

在先前的鎖範例加上 --ttl=<seconds> 旗標。因為鎖在 TTL 到期後會消失,改以「鎖不存在=未上鎖」為約定,用 mk 指令(僅在鍵不存在時成功)取代 set 建鎖。

Alice 建立一個 10 秒的租約鎖:

kubectl exec my-etcd-cluster-0000 -- \
    sh -c "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
        --ttl=10 mk my-lock alice"

要繼續持有,Alice 得反覆執行:

kubectl exec my-etcd-cluster-0000 -- \
    sh -c "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
        set --ttl=10 --swap-with-value alice my-lock alice"

Alice 不斷把自己的名字重寫進鎖看似奇怪,但這正是把租約延長超過 10 秒 TTL 的方式。若 TTL 到期,更新會失敗,Alice 得回頭用 mk 重新建鎖——Bob 也可能用 mk 搶到鎖,同樣得每 10 秒續約以維持所有權。

處理並行資料操作#

  • 即使有上述所有機制,仍可能出現兩個副本短暫同時自認持鎖的情況。例:原持鎖者的機器嚴重超載,處理器一停就是數分鐘——鎖逾時、別的副本接手;之後處理器恢復,原持鎖者在 handleLockLost() 被呼叫前,有一小段時間仍以為自己持鎖。這種事件不太可能發生,但系統必須對它強健。

  • **第一步:行動前複查(double-check)**鎖是否仍在手上:

    func (Lock l) isLocked() boolean {
      return l.locked && l.lockTime + 0.75 * l.ttl > now()
    }

    在受鎖保護的程式碼前執行它,可大幅降低雙主節點的機率——但無法完全消除:檢查與受保護程式碼執行之間,鎖仍可能逾時。

  • 第二步:被呼叫方驗證。被副本呼叫的系統要驗證「發請求的副本真的還是主節點」:鍵值儲存除了鎖的狀態,也存持鎖副本的主機名稱,讓其他系統能複查自稱主節點者是否屬實。工作節點收到請求時先向鎖伺服器確認請求者是現任擁有者,不是就拒絕。

圖 9-2:工作節點複查——驗證送出訊息的請求者確實是該分片的現任擁有者

還有最後一個刁鑽的皺褶:所有權可能被取得、失去、又重新取得,導致本該被拒絕的請求成功。書中的事件序列:

  1. Shard-1 取得所有權成為主節點。
  2. Shard-1 在時間 T1 以主節點身分送出請求 R1。
  3. 網路打嗝,R1 的遞送被延遲。
  4. Shard-1 因網路問題續約失敗,鎖落到 Shard-2 手上。
  5. Shard-2 成為主節點,在 T2 送出請求 R2。
  6. R2 被接收並處理。
  7. Shard-2 崩潰,所有權回到 Shard-1。
  8. R1 終於抵達——Shard-1 是現任主節點,請求被接受。但 R2 已經處理過了,這是錯的。

這種序列看似天方夜譚,但在任何大型系統中都以令人不安的頻率發生。解法與前面相同:資源版本。除了在 etcd 存現任擁有者名稱,每個請求也帶上資源版本(R1 變成 (R1, Version1));接收端複查「現任擁有者」與「請求的資源版本」,任一不符就拒絕