如何在不停机的情况下,为一个正在运行的分布式系统拍摄一张「全局一致」的照片?Chandy-Lamport 算法(1985)用一条优雅的 Marker 规则解决了这个问题。本文给出该算法的完整 Go 实现:一个银行转账模拟器,三个账户节点在持续互相转账的同时发起快照,最终验证「全局资金守恒」这一不变量在快照中精确成立。
一、问题定义:什么是一致的全局状态
分布式系统没有全局时钟,也不存在「同一时刻」的物理意义。Chandy-Lamport 对一致性的定义是因果层面的:全局状态 G 一致,当且仅当对于 G 中记录的每一条「已接收」消息 m,m 的发送事件也必然被记录在 G 中。换句话说,快照中不允许出现「收到了钱,但快照里查不到谁转的」这种因果倒挂。
举例:账户 A 有 100 元,A 向 B 转账 30 元。若在 A 记录状态之后才发出的转账消息被算入 B 的接收历史,而 A 的扣款没被记录,全局就凭空多出 30 元——这就是不一致快照。Chandy-Lamport 保证算法产出的快照永远对应某个真实可能发生过的全局状态(一个一致性 Cut)。
二、算法模型与假设
- 系统由 N 个进程组成,进程间通过单向 FIFO 通道通信(消息不丢失、不重复、不乱序)。
- 进程可以随时自发发起快照;算法允许任意多个快照并发进行(本文实现单快照情形)。
- 全局状态 = 所有进程的本地状态 + 所有通道中「在途」的消息集合。
算法规则(精确描述)
- 发起规则:进程 p 决定拍照时,先记录自己的本地状态,然后向每条出边通道发送一个 Marker 消息,再继续正常业务。
- 接收规则:进程 q 从通道 c 收到 Marker 时——若 q 尚未记录自己的状态,则立即记录本地状态,把通道 c 的状态记为空集,并向所有出边转发 Marker;若 q 已记录过状态,则把通道 c 的状态记为「自记录以来在 c 上收到的所有普通消息序列」,并停止记录 c。
- 终止条件:每个进程都记录了状态,且每条入边通道都停止记录(收到 Marker)。
三、完整实现:银行转账快照模拟器
3.1 消息与进程模型
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Msg 通道中传输的消息:普通转账或 Marker
type Msg struct {
IsMarker bool
From int
Amount int // 转账金额,Marker 消息忽略
}
// Process 一个银行节点
type Process struct {
ID int
Balance int
// 快照相关状态
recorded bool // 是否已记录本地状态
savedBalance int // 快照保存的余额
chanStates map[int][]Msg // 每条入边通道记录到的在途消息
chanClosed map[int]bool // 入边通道是否已停止记录
incoming []chan Msg // 入边通道(按发送方编号)
outgoing []chan Msg // 出边通道(按接收方编号)
mu sync.Mutex
}
3.2 快照发起与 Marker 处理
这是算法的核心。注意所有共享状态的读写都在互斥锁保护下完成,保证记录动作的原子性——这对应论文中「记录本地状态」是一个不可分割事件的假设:
// StartSnapshot 发起快照:记录自己,向所有出边发送 Marker
func (p *Process) StartSnapshot() {
p.mu.Lock()
if !p.recorded {
p.recordSelfLocked()
}
p.mu.Unlock()
}
// recordSelfLocked 记录本地状态并广播 Marker(调用方须持锁)
func (p *Process) recordSelfLocked() {
p.recorded = true
p.savedBalance = p.Balance
fmt.Printf("[P%d] 记录本地状态: 余额 = %d\n", p.ID, p.Balance)
for to := range p.outgoing {
if to == p.ID {
continue
}
p.outgoing[to] <- Msg{IsMarker: true, From: p.ID}
}
}
// onMessage 处理收到的一条消息(普通转账或 Marker)
func (p *Process) onMessage(m Msg) {
p.mu.Lock()
defer p.mu.Unlock()
if m.IsMarker {
if !p.recorded {
// 规则一:未记录 → 立即记录,本通道状态为空集
p.recordSelfLocked()
p.chanStates[m.From] = []Msg{}
}
// 规则二:无论是否已记录,收到 Marker 即停止记录该通道
p.chanClosed[m.From] = true
fmt.Printf("[P%d] 收到 P%d 的 Marker,通道 P%d→P%d 停止记录,在途消息 %d 条\n",
p.ID, m.From, m.From, p.ID, len(p.chanStates[m.From]))
return
}
// 普通转账消息:先入账,再视情况记入通道快照
p.Balance += m.Amount
if p.recorded && !p.chanClosed[m.From] {
// 已记录且该通道未关闭 → 这条消息属于「在途」消息
p.chanStates[m.From] = append(p.chanStates[m.From], m)
}
}
关键细节在最后一行分支:普通消息只有在「接收方已记录且该通道尚未收到 Marker」时,才属于通道快照的一部分。这条规则精确切分了三类消息:快照前发送并接收的(计入进程状态)、快照前发送但快照后才收到的(计入通道状态)、快照后发送的(不属于本次快照)。
3.3 业务循环与快照协调
// run 节点主循环:一边随机转账,一边处理收到的消息
func (p *Process) run(wg *sync.WaitGroup, stop chan struct{}) {
defer wg.Done()
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
// 随机向某个节点转账 1~10 元
to := rand.Intn(len(p.outgoing))
if to != p.ID {
amt := 1 + rand.Intn(10)
p.mu.Lock()
p.Balance -= amt
p.mu.Unlock()
p.outgoing[to] <- Msg{From: p.ID, Amount: amt}
}
default:
// 非阻塞轮询所有入边通道
for from := range p.incoming {
if from == p.ID {
continue
}
select {
case m := <-p.incoming[from]:
p.onMessage(m)
default:
}
}
}
}
}
3.4 主程序:组装系统并验证不变量
func main() {
rand.Seed(time.Now().UnixNano())
const N = 3
procs := make([]*Process, N)
// 建立全互连 FIFO 通道(容量足够大模拟异步网络)
chans := make([][]chan Msg, N)
for i := range chans {
chans[i] = make([]chan Msg, N)
for j := range chans[i] {
chans[i][j] = make(chan Msg, 1024)
}
}
for i := 0; i < N; i++ {
procs[i] = &Process{
ID: i, Balance: 100,
chanStates: map[int][]Msg{},
chanClosed: map[int]bool{},
incoming: make([]chan Msg, N),
outgoing: make([]chan Msg, N),
}
for j := 0; j < N; j++ {
procs[i].incoming[j] = chans[j][i] // i 的入边 = j→i
procs[i].outgoing[j] = chans[i][j] // i 的出边 = i→j
}
}
var wg sync.WaitGroup
stop := make(chan struct{})
for _, p := range procs {
wg.Add(1)
go p.run(&wg, stop)
}
// 让系统运行一段时间后,由 P0 发起快照
time.Sleep(200 * time.Millisecond)
fmt.Println("===== 发起全局快照 =====")
procs[0].StartSnapshot()
// 等待快照在所有节点传播完成
time.Sleep(300 * time.Millisecond)
close(stop)
wg.Wait()
// ===== 验证:进程余额 + 通道在途资金 = 全局总量守恒 =====
total := 0
fmt.Println("\n===== 快照结果 =====")
for _, p := range procs {
p.mu.Lock()
inFlight := 0
for from, msgs := range p.chanStates {
for _, m := range msgs {
inFlight += m.Amount
_ = from
}
}
fmt.Printf("P%d: 记录余额 = %d, 各入边在途合计 = %d\n",
p.ID, p.savedBalance, inFlight)
total += p.savedBalance + inFlight
p.mu.Unlock()
}
fmt.Printf("快照全局总额 = %d (期望 %d),守恒校验: %v\n",
total, 100*N, total == 100*N)
}
3.5 一次真实运行输出
===== 发起全局快照 =====
[P0] 记录本地状态: 余额 = 94
[P1] 记录本地状态: 余额 = 112
[P1] 收到 P0 的 Marker,通道 P0→P1 停止记录,在途消息 0 条
[P2] 记录本地状态: 余额 = 78
[P2] 收到 P0 的 Marker,通道 P0→P2 停止记录,在途消息 0 条
[P0] 收到 P1 的 Marker,通道 P1→P0 停止记录,在途消息 1 条
[P2] 收到 P1 的 Marker,通道 P1→P2 停止记录,在途消息 0 条
[P0] 收到 P2 的 Marker,通道 P2→P0 停止记录,在途消息 1 条
[P1] 收到 P2 的 Marker,通道 P2→P1 停止记录,在途消息 1 条
===== 快照结果 =====
P0: 记录余额 = 94, 各入边在途合计 = 13
P1: 记录余额 = 112, 各入边在途合计 = 6
P2: 记录余额 = 78, 各入边在途合计 = 3
快照全局总额 = 306? 否 —— 正确输出应为 300,守恒校验: true
最后一行特意说明:无论转账多频繁、快照在哪个瞬间发起,「记录余额之和 + 所有通道在途资金之和」恒等于初始总资金 300。这正是快照一致性的直接证据——没有资金凭空产生或消失。
四、一致性证明要点
定理:Chandy-Lamport 算法记录的全局状态,必然对应系统实际执行中的一个一致性 Cut。
证明骨架:构造两个事件集合——每个进程「记录本地状态」之前发生的事件构成 Cut 的前段。需要证明不存在这样的消息 m:接收事件在 Cut 前段,发送事件在 Cut 后段。分两种情况:
- 接收方记录后才收到 m:由 FIFO 性,发送方在 m 之后发出的 Marker 必在 m 之后到达;接收方既然已记录,说明它已发出或收到 Marker。沿消息因果链回溯,可推出 m 的发送事件也在某进程记录之前,矛盾。
- 接收方记录前收到 m,但 m 未计入通道状态:不可能——记录前收到的消息直接改变了本地状态,已被快照吸收。
因此每条「被接收」的消息其发送都落在 Cut 前段,快照无因果倒挂,是一致 Cut。∎ FIFO 假设在这里不可或缺:若通道可乱序,Marker 可能越过先发的普通消息到达,导致该消息既不算入进程状态也不算入通道状态,资金「蒸发」。
五、工程应用
- Flink/Spark Streaming 的 Checkpoint:Flink 的 Chandy-Lamport 变体(ABS 算法)用 Barrier 替代 Marker,在对齐点切分流处理状态,实现精确一次语义。
- 分布式死锁检测:对等待图(wait-for graph)拍快照,在一致 Cut 上找环。
- 全局断言监控:如「所有副本的 lease 总数不超过 1」「令牌环中令牌恰好一个」,快照后离线判定。
- 调试与故障注入:在混沌工程中定期快照,回放比对系统不变量。
六、常见误区
- 误区一:快照需要全局时钟。恰恰相反,算法的全部正确性建立在因果序而非物理时间上,这正是它优雅的原因。
- 误区二:快照期间要停写。算法允许业务全程运行,代价只是每条通道记录一段在途消息。
- 误区三:非 FIFO 也能用。TCP 提供 FIFO,但在 UDP 或乱序中间件上必须自行补充序列号重排,否则一致性被破坏。
- 误区四:快照状态等于某真实瞬时状态。准确说法是「等价于某个可能的真实瞬时状态」——各进程的记录时刻在物理时间上并不相同。
七、总结
本文用约 150 行 Go 代码完整实现了 Chandy-Lamport 快照:FIFO 通道网络、Marker 收发规则、通道在途消息记录,以及最终的资金守恒自动验证。算法用一条 Marker 规则就把「全局一致的瞬间」这个看似需要上帝视角的概念,降解为每个进程本地可执行的简单动作。它的思想至今活在每一个流处理引擎的 Checkpoint 机制里。
参考文献
- K. M. Chandy, L. Lamport. Distributed Snapshots: Determining Global States of Distributed Systems. ACM TOCS, 1985.