向量为每个被跟踪的参与方记录进度。比较这些计数可以区分因果先后与并发,但如何调和并发值,仍然需要由应用决定。

单一时间戳不能证明因果

标量时间戳可以人为排出顺序,但单凭这个顺序不能证明因果关系。在参与者身份稳定、遵循事件时钟规则的前提下,每个进程为本地事件增加自己的分量;收到消息时,先逐项取最大值合并发送方上下文,再增加接收方自己的分量。后一项不能漏掉,否则接收事件可能与接收前的上下文混为一谈。这些规则记录信息怎样流动,不测量实际经过多少秒,也不会为完全没有交换信息的机器证明物理时间先后。

必须区分四种比较结果

比较需要检查全部分量,缺席的参与者按零处理。只有 A 每项都不大于 B,且至少有一项严格小于 B,才能说 A 严格先于 B。反向支配是后于,全部相同是相等;两边都不能支配对方时才是并发。不能看到“并非 A 先于 B”,就直接推导并发,因为 B 先于 A 和两者相等都还是可能结果。

例如 [A:2] 与 [A:2, B:1] 不是相等,前者严格先于后者。某一个槽位相同,不等于全部历史相同;相反,省略的零槽位与显式零槽位可以表示同一上下文。这里比较的是时钟记录的信息,不是业务数据正文,也不是两份数据在现实世界中看起来有多相似。

并发不替应用决定赢家

并发不会选出赢家,也不一定就是业务冲突。独立字段更新可能可以合并,不兼容的整值替换则可能需要保留兄弟版本或交给用户判断。确定性裁决可以有意丢弃一个值,但那是应用选择的政策,不是时钟发现了先后。相等只表示被编码的上下文相同;只有额外的事件身份契约,才能证明两份载荷确实对应同一事件,不能把向量直接当成正文校验和。

坏掉的样子

时钟大小随方案实际跟踪的身份集合增长。Dynamo 的分量标识协调写入的存储节点,并不是每个曾写过该键的客户端。短命参与者不断出现、重启后计数器归零,都需要专门处理;如果旧身份被复用而计数回退,真正的新操作反而可能看起来像旧历史。身份稳定是算法前提,不是无关紧要的实现细节。

裁掉一个分量,会丢失真实祖先关系。祖先 [A:2, C:4] 先于后代 [A:3, B:2, C:5];从后代删去 C 后,剩下的向量却与祖先呈并发关系。缺席按零,适用于未见过某个参与者的语义,无法替主动遗忘的历史补回证据。Dynamo 论文明确提醒截断会使调和关系不精确。点版本向量采用不同因果表示,并不意味着普通向量的任意槽位都能安全删除。

只比较总和、最大值或旁边的墙钟,同样会丢信息。[A:2, B:1] 和 [A:1, B:2] 总和相同,却互不支配。后续写入应带回读取到的上下文,让存储知道它要替代哪些版本。省略上下文可能产生兄弟版本,具体取决于存储与现有版本,不能说每次都会产生。元数据保留和调和必须按照真实协议实现,不能由这几项算术断言替代。

快速验证

把下面程序保存为 check_vector_clocks.py,执行 python3 check_vector_clocks.py。这个纯内存比较模型覆盖全部四种关系、合并后记录接收事件的自增,以及裁剪导致的错误并发。它不建立网络、存储服务或持久化的参与者身份,输入都是明确给出的非负整数计数。

def compare(a, b):
    """Compare causal contexts with absent participants interpreted as zero."""
    nodes = set(a) | set(b)
    le = all(a.get(n, 0) <= b.get(n, 0) for n in nodes)
    ge = all(a.get(n, 0) >= b.get(n, 0) for n in nodes)
    if le and ge:
        return "equal"
    if le:
        return "before"
    if ge:
        return "after"
    return "concurrent"


def merge(a, b):
    return {n: max(a.get(n, 0), b.get(n, 0)) for n in set(a) | set(b)}


def tick(clock, node):
    result = dict(clock)
    result[node] = result.get(node, 0) + 1
    return result


a = {"A": 1}
b = tick(a, "B")
assert compare(a, b) == "before" and compare(b, a) == "after"
assert compare(a, a) == "equal"
assert compare({"A": 1}, {"A": 1, "B": 0}) == "equal"
assert a == {"A": 1}  # tick does not mutate the input
c, d = {"A": 2, "B": 1}, {"A": 1, "B": 2}
assert compare(c, d) == compare(d, c) == "concurrent"
assert sum(c.values()) == sum(d.values())  # equal sums do not mean equal history
m = merge(c, d)
assert m == {"A": 2, "B": 2}
assert compare(c, m) == compare(d, m) == "before"
received = tick(m, "B")
assert received == {"A": 2, "B": 3} and compare(m, received) == "before"

# Truncation loses a genuine ancestor relationship.
ancestor = {"A": 2, "C": 4}
descendant = {"A": 3, "B": 2, "C": 5}
pruned = {"A": 3, "B": 2}
assert compare(ancestor, descendant) == "before"
assert compare(ancestor, pruned) == "concurrent"
print("PASS: four relations, merge-then-tick, zero slots, and false concurrency after pruning")

所有断言在 Python 3.12.14 通过。两个并发历史合并得到 [A:2, B:2],再记录 B 的接收事件,得到 [A:2, B:3]。裁剪案例把原本明确的先于关系变成并发,展示的是具体因果信息损失,而不只是向量少了一个字段。零槽位和不修改输入的检查,也让函数的边界行为可以直接复核。

模型假定计数有效、身份方案一致,没有测试崩溃恢复、成员变化、客户端与服务器的上下文交换,也没有实现点版本向量。它们是另外的集成属性。比较算术通过,不代表分布式存储一定在正确时机自增、可靠保存状态或安全淘汰元数据;尤其不能将这个小程序当成具备持久性与并发写入保障的数据库。

延伸阅读

来源