算法的故事 / 全书目录0 / 30 章已读

第 26 章

分布式快照——1985年,为什么不暂停整个网络,也能拍下一张全局一致的照片?

正在加载交互实验…

第一季 · 人类如何驯服数据历史主线:1978—2015年 · Chandy、Lamport、全局状态、一致性切割、Marker 消息与分布式检查点1985年2月。两位计算机科学家在 ACM Transactions on Computer Systems 上发表了一篇论文:Distributed Snapshots: Determining Global States of Distributed Systems《分布式快照:确定分布式系统的全局状态》。作者是:K. Mani Chandy 和 Leslie Lamport。论文只有13页,却解决了一个在分布式计算中非常困难的问题:当不同计算机没有统一时钟,而且无法同时暂停时,怎样记录整个系统的一份正确状态?这就是著名的 Chandy–Lamport 分布式快照算法。论文正式发表于1985年2月,第3卷第1期,第63—75页。[APICe][Microsoft Research]它最有趣的地方是:拍快照的时候,计算机仍然可以继续工作。网络仍然传输消息。各个进程也不需要在同一毫秒保存状态。但最后收集起来的结果,依然能够构成一份逻辑一致的全局快照。听起来像魔术。其实,它依赖的是一种非常严谨的因果关系设计。

第一幕:100元究竟消失到哪里去了?

假设我们有两台服务器。服务器 A 管理账户 A,余额1000元。服务器 B 管理账户 B,余额500元。
服务器 A

¥1,000

服务器 B

¥500

系统总金额:1000+500=15001000+500=1500现在,A 向 B 发送100元。我们用一个简化的消息系统来表示这个过程:A 先从本地账户扣除100元,然后向 B 发送一条转账消息。B 收到消息后,再增加100元。正常完成后:900+600=1500900+600=1500但假设消息还没有到达。此时:A 已经扣款,余额900元。B 尚未收款,余额500元。如果我们恰好在这一刻分别读取两台服务器的账户余额,就会看到:900+500=1400900+500=1400怎么少了100元?钱真的丢了吗?没有。它还在网络上。
消息正在传输中的系统
服务器 A¥900服务器 B¥500转账¥100A → B 通道中有一条尚未接收的消息
查看 SVG 配图原件
示意图原件 26-003.svg
26-003.svg · 点击查看大图
如果把网络中尚未到达的100元也算进去:900+100+500=1500\boxed{900+100+500=1500}总金额完全正确。这便引出了本章最重要的一句话:一个分布式系统的全局状态,不只是所有计算机的本地状态,还包括通信通道中尚未被接收的消息。在1985年的原始论文中,Chandy 和 Lamport 就用类似的守恒系统说明这个问题。他们使用的例子是一枚在进程之间传递的令牌,而不是银行账户中的100元。[research.cs.wisc.edu]我们的转账例子,是对原论文数学思想的一种现代化解释,并不代表该算法本身可以代替真实银行的事务提交与资金清算机制。

第二幕:这项算法的诞生,真的和一顿晚餐有关

K. Mani Chandy论文发表时任教于得克萨斯大学奥斯汀分校
Leslie Lamport论文署名机构为 Stanford Research Institute
这次,我们有一个由论文作者本人讲述的真实故事。Lamport 后来回忆,自己曾经去得克萨斯大学奥斯汀分校拜访 Chandy。一次晚餐时,Chandy 向他提出了分布式快照问题。但两人当晚喝了不少葡萄酒,没有继续认真推导。第二天早晨,Lamport 在洗澡时想出了答案。可等他来到 Chandy 的办公室,却发现 Chandy 也已经独立想到了同样的办法。这是 Lamport 在自己的论文回顾页面上亲自记载的往事。[lamport.org]从数学角度看,这套算法的关键思想,确实与 Lamport 早在1978年提出的 Happened-Before 因果关系有紧密联系。如果你刚学完第二十五章《向量时钟》,现在就会明白:这不是巧合。向量时钟让我们判断:哪些事件之间存在因果先后关系?而 Chandy–Lamport 算法进一步追问:能否沿着这种因果关系,把正在发生的分布式计算切成“已经进入快照的历史”和“尚未进入快照的未来”,同时保证这个切割在逻辑上成立?这就是我们接下来要研究的主题。

第三幕:为什么分别保存所有服务器的数据,还不等于一张正确的快照?

我们先看看最直观的方法。系统有三台服务器:A、B、C。管理员发出指令:“请大家立刻保存自己的状态。”A 在10:00:00保存。B 在10:00:02保存。C 在10:00:05保存。听起来只差几秒,似乎没什么大问题。但如果这几秒内服务器不断收发消息呢?我们就可能把不同时刻的状态拼在一起,得到一个从未可能成立的结果。

第一种错误:把钱算丢了

A 发出100元的转账消息,并扣除了自己的余额。随后记录 A:A=900A=900B 尚未收到消息,就记录:B=500B=500如果仅拼接本地余额:900+500=1400900+500=1400丢了100元。但这两个本地状态其实可以属于同一个合法的因果切割:A 已经发送,B 尚未接收。问题是我们遗漏了通道中正在传输的100元。如果记录了通道状态,整体结果就正确。

第二种错误:把钱算多了

这次假设 A 很早就拍照。那时 A 尚未发出转账:A=1000A=1000随后 A 扣款并发送100元。B 收到100元之后才拍照:B=600B=600管理员把这两张照片拼起来:1000+600=16001000+600=1600系统凭空多出了100元。这比前一种情况更加严重。因为 B 的照片包含了接收转账的效果。但 A 的照片却尚未包含发送转账的事件。这样一来,全局快照中出现了:有结果,却没有产生这个结果的原因。这是违反因果一致性的快照。

正确快照的两个合法例子

合法快照 A:转账尚未发出
账户 A

1000

通道

0

账户 B

500

总额1500元,发送与接收事件都尚未发生。
合法快照 B:100元正在传输
账户 A

900

通道

100

账户 B

500

总额仍然是1500元。发送事件已经发生,接收事件尚未发生。
论文原文专门使用单令牌守恒系统分析了两种反例:重复计算令牌,以及遗漏正在传输的令牌。[research.cs.wisc.edu]这说明分布式快照必须同时满足两种要求:记录的事件不能违背因果先后关系。记录的通道状态必须与各进程记录状态时的发送、接收边界对应。

第四幕:一致性切割——怎样把分布式历史分成过去和未来?

这就需要一个新概念:Consistent Cut,一致性切割。我们在上一章学习了 Happened-Before:e1→e2e_1\rightarrow e_2表示事件 e1e_1 在因果上先于事件 e2e_2。现在把整个分布式执行历史看成一张事件图。一张快照相当于在各个进程的事件序列上选取一个边界。边界之前的事件属于快照中的历史。边界之后的事件不属于快照中的历史。如果这个边界是正确的,就必须满足:e∈C ∧ e′→e ⟹ e′∈C\boxed{ e\in C\ \land\ e'\rightarrow e \ \Longrightarrow\ e'\in C }其中 CC 表示已经纳入快照的事件集合。这句话的意思是:如果快照包含某个事件,那么它也必须包含这个事件的所有因果前驱。也可以说:不能记录接收消息,却不记录对应发送消息。但是,反过来允许。快照可以包含发送事件,而不包含对应接收事件。因为那条消息可以处在通信通道中。

一个正确的事件切割

AB快照中的历史快照之后在途消息切割可以穿过消息箭头,但不能只包含接收、不包含发送
查看 SVG 配图原件
示意图原件 26-006.svg
26-006.svg · 点击查看大图
这里的边界不一定是所有机器在某一真实物理时间形成的垂直线。因为在异步系统中,不同机器没有统一的全球时钟。而且算法收集的全局状态,也不必对应于实际执行过程中某一个真实瞬间恰好出现过的全部状态组合。它需要的是一个在因果关系上能够成立的全局状态。Chandy 与 Lamport 的1985年论文还明确讨论了这种看似奇怪的情况:算法构造的快照,未必与系统实际经过的任何一个瞬时全局状态完全相同,但它仍可用于推理相应的系统性质。[research.cs.wisc.edu]

第五幕:一个叫 Marker 的小消息,为什么能够解决大问题?

Chandy–Lamport 算法的核心机制,是一种特殊控制消息:Marker,标记消息。它并不是一条普通转账消息。它不修改业务余额。它只告诉接收者:“我的快照边界已经到达你们之间的这条通信通道。”整个过程遵循几条规则。

规则一:进程启动快照

假设服务器 B 决定启动快照。它首先保存自己的本地状态。随后向所有出站通道发送 Marker。对于尚未收到 Marker 的入站通道,开始记录后来到达的普通业务消息。

规则二:第一次收到某个快照的 Marker

如果服务器 A 以前从未记录过这次快照:立即保存 A 的本地状态。将第一次收到 Marker 的那条入站通道的快照状态记录为空。向自己的所有出站通道发送 Marker。同时对其他尚未收到 Marker 的入站通道,开始记录普通业务消息。

规则三:已经记录过快照,又收到某条通道的 Marker

假设 B 之前已经记录了本地状态,并持续记录从 A 收到的普通消息。当 A→B 通道上的 Marker 到达时,B 就停止记录这条通道。从 B 保存本地状态之后,到 A 的 Marker 到达之前,在 A→B 通道上收到的普通消息,就是该通道要记录的快照状态。为什么如此巧妙?因为算法假定通道按 FIFO 顺序交付。即同一条通道中:先发送的消息,必须先于后发送的消息被接收。所以:在 A 记录快照之前发送的普通消息,会排在它随后发送的 Marker 前面。Marker 因此充当了一个可靠的消息边界。这些规则可以直接在1985年原始论文第70—71页的算法描述中找到。[research.cs.wisc.edu]

第六幕:亲手运行 Chandy–Lamport 算法

现在我们把前面的银行转账例子完整运行一遍。系统只有 A、B 两台服务器。它们通过两条独立的 FIFO 单向通道通信:A→BA\rightarrow BB→AB\rightarrow A初始账户余额分别为1000元和500元。A 已经准备发送100元转账。这次我们让 B 主动发起快照。为什么让 B 发起?因为这样可以清晰地看见:一条普通转账消息,如何被记录为快照中的在途消息。

实验:Marker 在两台服务器间传递

分布式快照实验室

两进程 · FIFO 通道 · Marker 协议
0/5
服务器 A · 当前余额

¥1,000

快照:未记录
服务器 B · 当前余额

¥500

快照:未记录
A → B 消息队列先发先到
空队列

B → A 的 Marker未发送
B 是否正在记录 A→B 通道否
已收集的快照
A 的本地状态

—

B 的本地状态

—

记录的在途金额

0


实际系统的总额(含真实在途消息)¥1500
初始状态:A 有1000元,B 有500元,两条通信通道为空。

这六个状态中,最重要的是哪一步?

请重点观察第四步和第五步之间的变化。B 已经保存了本地快照:SB=500S_B=500A 随后保存本地快照:SA=900S_A=900但在 B 收到 A 的 Marker 之前,普通转账消息先到了。于是 B 把这条消息加入通道快照。虽然 B 的当前业务余额已经变成600元,但之前保存的快照仍然是500元。快照不应该被后来的业务更新覆盖。最终得到:SA=900S_A=900SB=500S_B=500SA→B=[100]S_{A\rightarrow B}=[100]SB→A=[]S_{B\rightarrow A}=[]因此:900+500+100=1500\boxed{900+500+100=1500}这才是一份真正完整的全局状态。它保存的是一个分布式因果切割,而不是在完成时重新读取所有服务器的当前值。

第七幕:FIFO 为什么是整个算法的关键?

现在我们问一个特别尖锐的问题:为什么刚才那条 Marker 必须排在转账消息后面?因为如果允许 Marker 超车,系统就会陷入危险。假设 A 先发送:
text
transfer(100)
然后发送:
text
MARKER
如果通道不保证 FIFO,B 可能先收到 Marker。于是 B 认为:“A 这条通道的快照边界已经结束。”随后 B 才收到那条转账消息。那100元就可能既没有体现在 B 已保存的本地状态中,也没有被记录进通道快照。快照就会漏记一次在途修改。在1985年的经典算法模型中,每条通信通道具有以下重要条件:
  • 通道无错误,并且按发送顺序交付消息。
  • 消息延迟可以任意长,但必须是有限的。
  • 进程保存快照状态与发送 Marker 的相关步骤,需要遵守正确的局部顺序。
论文还使用无限缓冲通道作为便于分析的假设,并说明可以在证明不会发生缓冲区写入阻塞等条件下考虑有界缓冲。[research.cs.wisc.edu]

FIFO 是每条通道有序,不是全网只有一条队列

假设:A→B。C→B。这两条是不同通道。A 发送的 Marker 和 C 发送的 Marker,不必同时到达 B。B 可能先在其中一条通道看到 Marker。而另一条通道还有尚未到达的普通消息。这正是为什么:每条入站通道需要分别记录自己的快照状态。不是 B 收到第一条 Marker 后,就停止记录所有消息。而是:第一次 Marker 决定 B 的本地快照边界。每条入站通道后续各自的 Marker,决定那条通道的记录何时结束。对于不保证 FIFO 的网络,可以通过序号、缓冲和额外协议构造类似的有序交付保证,也可以研究适合非 FIFO 模型的其他快照算法。但是,不能直接将经典 Chandy–Lamport 算法照搬到任意无序消息系统,然后仍然宣称其正确性已经得到证明。

第八幕:如果网络里有100台服务器,需要发送多少 Marker?

现在我们开始分析复杂度。假设分布式系统表示为一张有向图:G=(V,E)G=(V,E)其中:VV 是进程或服务器的集合。EE 是有向通信通道集合。例如:A→BA\rightarrow B和:B→AB\rightarrow A是两条不同的有向通道。在一次正常完成的经典快照执行中,每个参与进程在记录本地状态后,会向自己的每条出站通道发送一次 Marker。所以 Marker 消息的总数为:∣E∣\boxed{|E|}这是非常优雅的结论。每条有向通道只需要一条 Marker,就能确定其快照边界。因此,控制消息数量为:O(∣E∣)O(|E|)但是,我们还必须区分三种开销。

控制消息开销

Marker 消息的数量与有向通道数量成正比。

快照数据开销

每台服务器需要保存自己的本地状态。每条入站通道还可能需要保存若干条在途业务消息。这些数据的大小并不一定是常数。如果服务器内存很大,或者通道中积压了大量消息,真正保存快照的成本可能远高于 Marker 消息本身。

完成时间

如果消息延迟可以任意长,那么算法无法给出一个独立于网络延迟的固定完成时间。在经典假设下,只要必要的消息最终能够送达,且所有目标进程均能够通过相应路径收到 Marker,快照过程可以完成。但如果有服务器永久失联,就不能仍然假定它一定能在有限时间内完成完整快照。另外,论文区分了两件事:各进程完成本地状态与入站通道记录。将这些分散的记录汇总到某个能够检查全局状态的进程。后者需要另外的数据收集或传播过程,不应把它当作 Marker 规则自动免费完成的事情。[research.cs.wisc.edu]

第九幕:用 Python 实现一个最小 Chandy–Lamport 快照

现在我们来实现刚才的两台服务器实验。为了让代码容易理解,我们使用 Python 的 deque 模拟 FIFO 通道。所有消息的交付顺序由测试程序显式控制。这不是一个真正同时运行的网络程序,却完整体现了经典 Marker 规则在这条指定执行路径上的作用。
python
from collections import deque

nodes = ["A", "B"]

balances = {
    "A": 1000,
    "B": 500,
}

# 两条独立的 FIFO 单向通道
channels = {
    ("A", "B"): deque(),
    ("B", "A"): deque(),
}

# 本地状态快照
local_snapshot = {}

# 各条通道的快照
channel_snapshot = {}

# 当前仍在记录的入站通道
recording = set()


def send(src, dst, kind, amount=0):
    channels[(src, dst)].append(
        (kind, amount)
    )


def record_local(node, first_marker_channel=None):
    """
    保存节点的本地快照。

    first_marker_channel:
    导致该节点第一次开始快照的入站通道。
    这条通道的快照状态为空。
    """
    local_snapshot[node] = balances[node]

    # 初始化所有入站通道的快照
    for channel in channels:
        src, dst = channel

        if dst != node:
            continue

        channel_snapshot[channel] = []

        if channel != first_marker_channel:
            recording.add(channel)

    # 保存本地状态后,先发 Marker
    for channel in channels:
        src, dst = channel

        if src == node:
            send(src, dst, "MARKER")


def receive(src, dst):
    channel = (src, dst)

    kind, amount = channels[channel].popleft()

    if kind == "MARKER":

        if dst not in local_snapshot:
            # 第一次收到 Marker
            record_local(
                dst,
                first_marker_channel=channel,
            )

        else:
            # 已经记录本地快照;
            # 此通道现在停止记录
            recording.discard(channel)

    elif kind == "TRANSFER":

        # 如果此通道处于快照记录阶段,
        # 保存这条在途消息
        if channel in recording:
            channel_snapshot[channel].append(amount)

        # 正常业务处理仍然继续
        balances[dst] += amount

按正确顺序执行

python
# 1. A 扣款,并发送转账消息
balances["A"] -= 100
send("A", "B", "TRANSFER", 100)

# 2. B 主动发起快照
record_local("B")

# 3. A 收到 B 发来的 Marker
receive("B", "A")

# 4. B 收到 A 之前发出的转账消息
receive("A", "B")

# 5. B 收到 A 随后发出的 Marker
receive("A", "B")
最后检查结果:
python
print(local_snapshot)

print(channel_snapshot)

snapshot_total = (
    sum(local_snapshot.values())
    + sum(
        sum(messages)
        for messages in channel_snapshot.values()
    )
)

print(snapshot_total)
print(balances)
输出应该是:
text
{'B': 500, 'A': 900}

{('A', 'B'): [100], ('B', 'A'): []}

1500

{'A': 900, 'B': 600}
观察最后两行。快照中的总额是1500。当前实际业务状态中的总额也是1500。但两者的构成不同。快照:
text
A = 900
B = 500
A→B 通道 = 100
业务当前状态:
text
A = 900
B = 600
A→B 通道 = 空
这说明快照并不是简单复制程序当前状态,而是能够保留一段因果上完整的执行历史边界。

这份教学代码还缺什么?

它固定了两台服务器和一次快照。没有实现快照编号、超时处理、真实网络 I/O、状态持久化、多次并发快照和最终结果收集。它也假设消息不会丢失、FIFO 队列不会出错。因此可以作为算法实验使用,但不能直接拿去作为生产级分布式检查点服务。真正的分布式应用还必须保证:保存快照本身具有必要的原子性。Marker 与普通消息满足发送顺序约束。快照结果最终能够可靠保存。恢复时,消息通道状态也能按照正确语义还原或重新处理。

第十幕:1983年,Dijkstra 已经见过这项算法

这里有一个很有意思的历史细节。虽然 Chandy–Lamport 论文正式发表于1985年,但算法在此之前已经被学术界讨论。在 Dijkstra 的 EWD864 手稿中,他写道:1983年7月1日,Chandy 和 Jayadev Misra 向他介绍了这项分布式快照算法,并特别提到 Lamport 的贡献。随后,Dijkstra 又和研究同伴研究了算法的正确性证明。他在 EWD864a 中进一步使用一种非常直观的“白色与红色进程”模型解释 Marker 传播。白色表示尚未跨过快照边界。红色表示已经跨过快照边界。Marker 负责保证:进程从白色转为红色时,相关消息边界不会违背通信的 FIFO 顺序。[德克萨斯大学奥斯汀分校计算机科学][德克萨斯大学奥斯汀分校计算机科学]这与我们刚才的示意图完全对应。在图中,快照边界左边的事件相当于白色历史。右边的事件相当于红色未来。一条从过去发送、在未来才接收的业务消息,就是需要记录的在途消息。而在经典 FIFO Marker 协议的正确执行中,不应该出现:一个被记录到快照中的接收事件,其对应发送事件却尚未被纳入快照。Dijkstra 的证明重新强调了一个我们整个算法系列反复遇到的原则:真正证明算法正确的,不是测试结果看起来合理,而是找到一个在所有允许执行交错中始终成立的不变量。

第十一幕:1985年论文真正想解决的,还有“系统到底停没停?”

前面我们一直研究保存分布式系统状态。但 Chandy 和 Lamport 的论文还有一个重要目标:Stable Property Detection——稳定性质检测。什么叫稳定性质?假设系统已经彻底完成所有工作。每台服务器都不再执行任务。所有通信通道也没有等待处理的业务消息。而且在当前计算阶段中,不会再凭空出现新任务。那么,系统的这一阶段就可以认为已经终止。在这些前提下,“该计算阶段已终止”就是一种稳定性质。一旦成立,就不会因为该阶段自己继续运行而重新变成未完成。论文列举的稳定性质还包括相应模型下的死锁,以及令牌环中所有令牌消失等情况。[普林斯顿大学计算机科学系]

为什么仅检查 CPU 是否空闲不够?

假设有三台服务器:A、B、C。A 当前空闲。B 当前空闲。C 当前空闲。管理员检查:
text
A:空闲
B:空闲
C:空闲
于是宣布:“所有工作已经完成。”但实际上,A→B 通道中还有一条任务消息。B 一旦收到它,就会重新开始工作。所以,判断分布式计算是否真正结束,不能只看每个进程有没有正在执行的任务。还要看:是否存在可能重新激活进程的在途消息。这就是全局快照能够提供的价值。它将进程状态与通道状态组合起来,帮助系统判断那些需要观察整个分布式执行才能确认的性质。

稳定性质与短暂性质

假设某个系统总库存暂时为100。一秒之后可能又变成99。“库存当前等于100”就是一种可能很短暂的性质。如果你通过一次分布式快照观察到库存100,只能说明这份快照中的状态满足该条件。不能据此宣布:“现在整个系统一定仍然等于100。”因为业务可能已经继续执行。而对于真正稳定的性质,只要它在一致性快照中成立,就可以根据性质自身的稳定性,推出它在之后可达的相关状态中也成立。这也是论文特别强调稳定性质检测的原因。这里有一个小小但重要的理论细节:死锁是否稳定,取决于系统允许哪些恢复和外部干预行为。如果一个管理程序可以主动撤销任务来打破死锁,那么“系统处于死锁”就不一定是永远保持的性质。原始论文对此也作了限定。[research.cs.wisc.edu]

第十二幕:2015年,Flink 为什么重新用到了1985年的思想?

如果你认为分布式快照只是1980年代的学术问题,那就低估了它的生命力。在现代实时数据处理系统中,有一项技术与它紧密相关:Checkpoint,检查点。例如 Apache Flink。Flink 可以持续处理数据流。假设有这样一条流水线:
text
Kafka 消息
    ↓
订单过滤
    ↓
按用户分组
    ↓
实时汇总
    ↓
写入下游存储
不同算子可能运行在不同服务器上。它们正在并行处理大量消息。如果其中一台服务器发生故障,怎样恢复?一种方法是从上一个可靠检查点重新构建算子状态,并结合输入源位置和下游处理语义恢复计算。但如果每个算子随便保存一下自己的状态,就会遇到我们开头讲过的同一类问题:上游已经发出的记录,下游可能尚未处理。下游已经处理的记录,上游快照却可能还没包括它们。如果恢复边界不一致,就可能重复处理或漏处理数据。

Flink 的 Checkpoint Barrier

Flink 使用一种与 Chandy–Lamport 思想相关的机制:Checkpoint Barrier,检查点屏障。Barrier 会带有检查点编号,沿数据流传播。对于同一条有序数据流,Barrier 不会随意超越已经排在它前面的记录。于是,Barrier 可以把输入流划分为:属于当前检查点之前的记录。以及属于下一个执行阶段的记录。Flink 官方文档明确说明:其异步检查点机制受到 Chandy–Lamport 算法启发,并根据流处理执行模型作了专门设计。[Apache Flink][Apache Flink]

两种重要的检查点方式

方式核心思路主要权衡
Aligned Checkpoint多输入算子协调 Barrier,形成一致的输入处理边界可能需要临时等待部分输入
Unaligned Checkpoint将某些在途或缓冲数据也纳入检查点减少屏障对齐等待,但可能增加检查点 I/O 和状态体积
请注意:Flink 的机制不是把1985年的算法一字不改地复制进去。它结合了流处理图、数据源偏移、算子状态、检查点编号和失败恢复要求。现代 Flink 还要协调外部数据源和 Sink。因此,“算子检查点已经一致”不自动意味着:任何外部数据库写入、支付请求或第三方 API 调用都具有严格的恰好一次副作用。要获得端到端恰好一次处理语义,仍然需要数据源、状态管理和外部输出共同提供合适的恢复、事务或幂等保证。这其实与我们第二十章的数据库事务完全相通。一致的内部快照是可靠恢复的重要组成部分,却不是所有业务正确性的全部。

第十三幕:Chandy–Lamport 与 Raft、WAL、向量时钟到底有什么区别?

我们此前已经学习了许多分布式技术。现在可以把它们放在一张表中。
技术回答的核心问题
Lamport 逻辑时钟怎样维护与因果顺序相容的标量时间?
向量时钟两个事件能否比较因果先后?
Raft多台副本怎样共同决定日志顺序?
WAL崩溃后如何恢复已提交的数据库修改?
CRDT某些并发修改如何通过数学规则收敛?
Chandy–Lamport不要求同时暂停,怎样获得一致的全局状态?
这些算法彼此关联,却不可以直接互相替代。例如:Raft 能够让一个复制组就日志顺序达成共识。但一份跨多个独立处理阶段的全局检查点,可能仍然需要考虑消息与计算状态的一致边界。WAL 可以让单个数据库恢复自身的事务状态。但它通常不能直接告诉你一整个分布式业务流水线中,所有队列、消费者与外部系统处于哪个共同处理边界。向量时钟可以表示事件因果关系。但它本身不负责保存各服务器的内存和通道数据。Chandy–Lamport 可以构造一致的全局快照。但它不负责决定两个并发数据库写入谁应当获胜,也不自动实现应用业务事务的原子提交。因此,真正的系统架构,往往需要组合多种机制。这也是我们连续几章学习分布式算法的原因。一致顺序、状态收敛、故障恢复和全局快照,是不同的正确性问题。

终章挑战:哪一种快照是真正一致的?

现在有两台服务器。初始状态:A=1000,B=500A=1000,\quad B=500A 向 B 转账100元。发送和接收都使用正确的消息协议。请选择能够构成合法全局快照的一种组合。
请选择正确答案
这道题的关键不只是加法。更重要的是两项原则:第一,快照不能包含一个事件的结果,却遗漏它的因果原因。第二,快照必须正确区分已经被接收的消息和仍在通信通道中的消息。这两条原则共同构成了理解 Chandy–Lamport 算法的核心。

尾声:1985年,两位科学家教会计算机在流动的世界里拍照

1978年,Leslie Lamport 研究分布式系统中的逻辑时间与事件顺序。1983年,Chandy、Lamport 等人的分布式快照思想已经在学术界传播,并进入 Dijkstra 的算法正确性讨论。1985年2月,Chandy 与 Lamport 正式发表经典论文,为异步分布式计算提供了一种构造全局状态的算法。2015年,现代流处理检查点的研究进一步展示了这类思想在持续运行的数据流计算中的生命力。这段历史最令人印象深刻的地方,是它并没有试图强迫整个世界停下来。现实中的分布式系统原本就处于持续变化中。消息一直在发送。队列一直在处理。数据库一直在更新。如果我们要求每一台服务器都在完全相同的真实物理时刻停止并保存状态,协调成本可能极高。而 Chandy–Lamport 算法告诉我们:不一定要让整个系统同时静止,才能构造一份逻辑一致的全局状态。我们可以让各个参与者继续工作。让 Marker 沿着通信通道传播。分别保存本地状态与正确的在途消息。最后把这些局部记录组合起来。得到一个能够用于推理、检查与恢复的全局状态。不过,这仍然不代表算法完全没有运行开销,或任何实现都不需要短暂的局部同步。其真正突破在于:不需要整个分布式系统在同一个物理时刻进行全局停机。这也是为什么 Chandy–Lamport 分布式快照,至今仍然是分布式计算领域最优雅的算法之一。

第二十六章知识总结

概念核心思想
Global State所有进程的局部状态,加上通信通道状态
Local Snapshot单个进程在自己的快照边界保存的状态
In-Transit Message已发送但尚未被接收的消息
Consistent Cut包含某事件时,也包含其所有因果前驱
Marker标记通信通道上的快照边界
FIFO Channel同一通道内消息按发送顺序交付
Chandy–Lamport通过局部记录与 Marker 构造一致的全局快照
Stable Property一旦成立,在相关计算阶段之后仍然保持的性质
Checkpoint用于故障恢复的持久化检查点
Barrier Alignment协调多输入数据流的检查点边界
复杂度经典执行中,每条有向通道发送一次 Marker,共 O(∣E∣)O(|E|) 条控制消息

本章原始历史与工程资料

Chandy、Lamport(1985):《Distributed Snapshots》原始论文 PDFACM:1985年2月原始论文正式发表记录Leslie Lamport 本人的论文回顾:晚餐、洗澡与分布式快照算法的起源Dijkstra(1983):EWD864 分布式快照正确性讨论Dijkstra:EWD864a,使用白色与红色进程解释快照算法Apache Flink 官方文档:容错、Checkpoint Barrier 与一致性快照Apache Flink 官方文档:对齐与非对齐检查点
下一章预告

第二十七章:MapReduce——2004年,Google 如何让上千台普通计算机共同处理海量数据?

2004年,Google 的两位工程师:Jeffrey Dean 和 Sanjay Ghemawat,在第六届 USENIX 操作系统设计与实现研讨会(OSDI)上发表论文:MapReduce: Simplified Data Processing on Large Clusters《MapReduce:大型集群上的简化数据处理》。论文发表于2004年,页码137—150。[Google Research][USENIX]假设 Google 需要统计数十亿份网页中,每个单词出现过多少次。一台计算机可能要运行很久。但如果把数据分给1000台计算机处理,就可以同时进行大量工作。问题是:怎样切分任务?怎样分配机器?怎样把相同单词的统计结果合并?如果其中20台计算机突然宕机,又该怎么办?MapReduce 提出了一种极具影响力的计算模型。程序员只需要重点描述两类操作:Map:把输入数据转换为一批中间键值对。Reduce:按照键汇总这些中间结果。而运行时系统负责数据划分、任务调度、网络通信与故障重试。这让普通程序员也能够利用大规模计算集群执行复杂的数据处理任务。下一章,我们将从 Dean 与 Ghemawat 的2004年原始论文出发,亲手实现一个 MapReduce 单词统计系统,再模拟 Worker 故障、任务重试、Shuffle 和数据分区。最值得研究的问题是:当上千台机器总会有几台出故障时,Google 为什么不试图让每台机器永远可靠,而是让任务能够被安全地重新执行?