前言

分區、分片與事件流解決的是吞吐量與資料一致性的一大半問題,但大型交易所真正容易出事故的地方,往往發生在故障切換與協調。例如同一個 symbol 的撮合工作不能同時有兩個節點接手,同一組清算任務不能被兩個 worker 重複執行,週期性對帳也不能在多個節點上同時跑到互相打架。

這時候你就會遇到 Leader Election、lease、heartbeat、fencing token、split brain 這些名詞。它們看起來像分散式系統課本內容,但在交易所場景裡非常務實,因為只要選主做錯,後果不是單純多跑一個 job,而可能是重複清算、重複扣款、重複對帳,甚至同一資源被雙寫

本文會把 Leader Election 放回交易所真實使用場景來解釋,並補上 MySQL / PostgreSQL 在協調鎖與選主上的差異。你也會看到一個很重要的觀念:不是所有服務都要選主,但凡是「同一時間只能有一個節點負責」的工作,都必須先想清楚協調模型。

系列文章導航

  1. 撮合引擎、In-Memory、Ring Buffer 與批次處理
  2. Event Sourcing、Outbox Pattern、Message Queue 與一致性
  3. Partition、Sharding 與 MySQL / PostgreSQL 擴展策略
  4. Leader Election、高可用切換與跨服務協調(本篇)
  5. MySQL、PostgreSQL 的擴展、調校與效能優化

哪些交易所元件真的需要 Leader Election

不是每個服務都需要 leader。像純讀取型 API、無狀態 Web 節點,通常只要 load balancing 就能解決。但以下這些場景經常需要「同一時間只能有一個主責節點」:

  • 某個 symbol 的撮合主節點
  • 某組 liquidation scanner 的工作協調者
  • outbox relay 的單一分區送出者
  • 每日對帳與結算任務的主執行者
  • 風控快照或狀態同步的主控節點

不要把所有事情都做成 leader-based

如果一個工作本來就可以透過 partition assignment 自然分配,例如 Kafka consumer group 依 partition 消費,那你未必要額外套一層 leader election。

所以第一個判斷原則是:

  • 如果工作可以天然切分,就切分。
  • 如果工作不能同時被兩個節點執行,就選主。

Leader Election、Distributed Lock、Partition Assignment 不一樣

這三個詞很常被混用,但在設計上其實不同。

Leader Election

目標是從多個候選節點中選出一個 leader,讓它暫時負責某個角色,例如 matching-leader-BTCUSDT

Distributed Lock

目標是保護某段臨界區,例如「同一時間只能有一個 worker 執行每日結算」。它未必包含完整的 leader 身分與持續性租約語意。

Partition Assignment

目標是把多個工作分配給多個消費者,例如 32 個 topic partitions 分配給 8 個 consumer。這更像負載分配,不一定需要單一 leader。

理解這三者差異很重要,因為大型交易所常常三種都會用,但不能亂套。


交易所最怕的不是沒 leader,而是 split brain

Split Brain 指的是兩個節點都以為自己是 leader。對交易所來說,這種情況非常危險。

可能造成的後果

  • 同一 symbol 出現雙撮合者
  • 同一批 outbox 事件被重複發送
  • 同一筆清算工作被做兩次
  • 同一份風控狀態被兩邊覆寫

所以真正重要的不是「能不能選出 leader」,而是:

  1. 舊 leader 失去資格時,是否能被可靠剝奪權限
  2. 新 leader 接手前,是否能確定自己拿到的是最新可用狀態

這就會引出另一個重要概念:Fencing Token

Fencing Token 是什麼

你可以把它想成每次當選 leader 時拿到的一張遞增序號門票。只有持有最新門票的節點,才被允許對外寫入。

即使舊 leader 因為網路抖動一度以為自己還活著,只要它手上的 token 已經過期,下游就應該拒絕它的寫入。


Go 示例:用 etcd lease 做簡化版 leader election

下列程式碼展示的是常見思路,而不是完整生產級樣板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"context"
"log"

clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)

func Campaign(ctx context.Context, cli *clientv3.Client, electionKey string, nodeID string) error {
session, err := concurrency.NewSession(cli, concurrency.WithTTL(5))
if err != nil {
return err
}
defer session.Close()

election := concurrency.NewElection(session, electionKey)
if err := election.Campaign(ctx, nodeID); err != nil {
return err
}

log.Printf("node %s became leader", nodeID)

<-session.Done()
log.Printf("node %s lost leadership", nodeID)
return nil
}

這段程式碼背後的重點包括:

  • leader 身分是綁在 lease 上,不是永遠有效。
  • 如果節點失去 lease,領導權就應該失效。
  • 失去 leadership 後,工作必須停止寫入或立刻進入降級狀態。

為什麼交易所常用 etcd / ZooKeeper / Consul 這類系統

因為它們本來就是為了協調、租約、觀察變更、避免 split brain 這類問題設計的。把這類職責交給專門的協調系統,通常比把它硬塞進主資料庫更合理。


故障切換不是切過去就好,還要能安全恢復

一個比較健康的 failover 流程通常像這樣:

flowchart TD
    A[Leader 正常運作] --> B[Leader 失去 lease 或故障]
    B --> C[Standby 競選 leadership]
    C --> D[取得新 token]
    D --> E[載入 snapshot]
    E --> F[replay 未處理事件]
    F --> G[對外恢復服務]

這張圖有兩個關鍵:

  1. 先取得合法領導權,再接手工作
  2. 先恢復最新狀態,再對外服務

如果少了第二步,你雖然切過去了,但可能拿舊狀態對外處理新請求,一樣會出事。


對交易所來說,順序通常比時鐘更重要

很多人在設計故障切換時會很在意時間戳,但對交易所而言,更重要的常常是同一分區內的事件順序

為什麼不能只相信 clock time

  • 不同節點時間可能有些微漂移。
  • 網路延遲會讓接收時間不等於發生時間。
  • 相同毫秒內可能有大量訂單進來。

因此在核心鏈路上,更常見的做法是:

  • 對每個 symbol 或每個 partition 產生單調遞增 sequence
  • 用 sequence 決定 replay 與接手位置
  • 把時間戳當輔助資訊,而不是唯一排序依據

這也是為什麼前幾篇談到的 event log、snapshot、replay,會在 failover 時再次出現。


MySQL / PostgreSQL 可做輕量互斥,但不適合當核心選主控制平面

可以,但要知道界線在哪裡。

更精確地說,GET_LOCK() 與 advisory lock 比較像 lightweight distributed lock,適合做某些互斥任務;但如果你真正要的是帶有 lease、觀察變更、fencing token、split brain 防護的核心 control plane,那就不該把責任全部壓在主資料庫上。

MySQL 常見做法

MySQL 有 GET_LOCK() 這類能力,可以在某些輕量工作上做分散式互斥,例如:

  • 某個週期性 job 同時只允許一個節點執行
  • 某個小型維護任務避免重複跑

但如果你把核心 HA 協調完全壓在主 MySQL 上,就會遇到幾個問題:

  • 選主系統和業務主庫耦合太深
  • 主庫 failover 時,鎖語意與觀察延遲要重新驗證
  • 連線中斷是否等於權限立即失效,需要嚴格測試

PostgreSQL 常見做法

PostgreSQL 有 advisory lock,也能處理一些輕量協調需求,例如:

  • 單一批次任務互斥執行
  • 特定 maintenance job 避免重入

但與 MySQL 類似,如果你把它當成大型 HA control plane,也會有相同的邊界:它是資料庫,不是專門的共識協調系統。

實務建議

  • 輕量互斥:可以考慮 MySQL GET_LOCK() 或 PostgreSQL advisory lock。
  • 核心高可用協調:優先考慮 etcd、ZooKeeper、Consul 或 Kubernetes Lease。
  • 真正重要的切換:搭配 fencing token、snapshot、replay,不只做鎖本身。

Reservation 超時回收、補償與 Replayer 也需要協調

只要你導入 Redis Shift Left、outbox、DLQ、reconciliation,你就不只是在協調 leader,還是在協調誰有資格執行補償與回收工作

幾個典型場景

  1. Redis 已成功扣留資金,但 PostgreSQL 的訂單與帳本資料沒有成功耐久化。
  2. 訂單逾時或撤單後,需要把 held 資金釋放回 available
  3. DLQ 中的事件要重放,但同一批訊息不能被兩個 replayer 同時處理。
  4. 對帳 job 發現不一致,需要啟動補償流程。

這些工作如果沒有明確的擁有者,就很容易出現:

  • 重複釋放資金
  • 重複補償
  • 重複重放事件
  • 補償與正常流程互相覆蓋

所以在大型交易所裡,除了撮合主節點之外,下列 worker 也常需要清楚的 partition ownership 或 leader / lease:

  • reservation sweeper
  • order expiration worker
  • reconciliation worker
  • dead-letter replayer
  • outbox relay

換句話說,HA 協調的範圍不只限於「誰來對外服務」,也包括「誰來做善後」。


Backpressure、Consumer Group 與選主常常一起出現

在交易所裡,leader election 不只出現在主流程,也常和 MQ 消費模型一起出現。

例如:

  • 一個 topic 的某些 partitions 只應由某些 consumer 接手
  • 一組 liquidation workers 需要避免重複掃描相同帳戶
  • 一個 outbox relay 只能有一個活躍送出者,但下游慢時又要能限流

因此在真實系統中,你會看到下面幾個概念一起出現:

  • Lease:領導權有效期限
  • Heartbeat:持續宣告自己還活著
  • Backpressure:下游慢時控制上游速度
  • Circuit Breaker:下游持續異常時先保護主流程
  • Dead Letter Queue:不能處理的事件先隔離

這些機制共同作用,才構成真正可用的 HA 系統。


總結

Leader Election 在大型交易所裡不是教科書裝飾,而是避免雙寫、重複執行與 split brain 的必要機制。真正要掌握的重點有四個:

  1. 不是所有服務都需要 leader,能切分就先切分。
  2. 需要單一主責節點的工作,必須有明確 lease 與失效機制。
  3. 只會選主還不夠,還要能安全恢復狀態與順序。
  4. MySQL / PostgreSQL 可以做輕量互斥,但核心 HA 協調通常更適合交給專門系統。

下一篇我們會把鏡頭拉回資料庫層,具體整理在超大交易量之下,MySQL 與 PostgreSQL 各自有哪些擴展、調教與優化策略,以及什麼場景更適合選哪一種。

系列文章導航

  1. 撮合引擎、In-Memory、Ring Buffer 與批次處理
  2. Event Sourcing、Outbox Pattern、Message Queue 與一致性
  3. Partition、Sharding 與 MySQL / PostgreSQL 擴展策略
  4. Leader Election、高可用切換與跨服務協調(本篇)
  5. MySQL、PostgreSQL 的擴展、調校與效能優化