冲突解决

本页目录

冲突的关键是:两个操作分别基于各自已知的状态产生,而系统没有把它们事先排成一个共同顺序。网络上的到达先后并不等于业务上的因果先后。解决冲突前,应先说明两个操作分别表达什么意图,以及哪种结果符合产品语义。

覆盖更新与合并操作表达不同意图

把点赞数“设置为 11”和“增加 1”不同。两个客户端从 10 出发分别写入 11,最后保存 11 可能符合赋值语义,却丢掉了一次点赞意图。要保留两次点赞,应记录独立操作或使用适当计数结构。

同样,购物车直接取集合并集能保留并发添加,却可能让已删除商品重新出现。“保留所有变化”不是完整规则,还需要定义删除与再次添加的关系。

LWW:用全序选出一个赢家

Last-Write-Wins 为版本分配可全序比较的排序键,例如 (timestamp, writer_id),选最大的版本。第二个字段用于确定性打破同时间戳的平局,各副本必须使用相同比较规则。

它的优点是结果单一、合并简单;代价是并发失败方的值被覆盖。“最后”指排序键最大,不一定是现实中最后操作的人。物理时钟快的节点可能长期压过其他节点;逻辑时间也不等于真实时间。

LWW 适合确实允许以指定优先规则覆盖的字段。它不会自动合并两个编辑者分别修改的内容,也不能直接将多个 +1 意图保存为赋值后的计数结果。

版本向量:辨认继承与并发

以 A、B 两个写入者为例,版本向量 [a,b] 表示该版本已包含各写入者的逻辑更新进度。对每个分量都满足 vᵢ≤wᵢ 且至少一个严格小于,记为 v<w;这表示 w 在该模型中继承了 v 的因果历史。

从共同版本 [1,0] 出发,A 本地更新得到 [2,0];B 观察旧版本后更新得到 [1,1]。前者的 A 分量更大,后者的 B 分量更大,两个向量不可比较,表示并发。不能通过求和 2 与 2 来决定顺序。

合并时可以先取逐分量最大值 [2,1] 表示已观察的历史,再由执行新更新的写入者递增自身分量。⁠合并因果上下文不等于合并业务值⁠:地址、购物车和权限仍需各自的规则。版本向量帮助检测冲突,并不负责作出产品决定。

这个简化模型假定每个写入者拥有可靠、单调的身份与计数器。节点重装后随意复用旧身份并把计数清零,会破坏版本意义。写入者数量增长也会增加元数据成本。

CRDT:把合并语义放进数据结构

CRDT 用受约束的数据表示和更新规则,让接收了相同更新的副本收敛到相同状态。它不保证断网副本立即知道远端更新,也不保证任意业务规则都能无协调地成立。

基于状态的 CRDT 通常把状态组织成半格:更新沿偏序单调前进,合并取最小上界。合并的交换律、结合律和幂等律使重复和乱序的状态传播不会改变最终结果。只有一个满足这三条定律的函数还不够,更新规则与状态偏序也必须配合。

基于操作的 CRDT 传播操作,依赖其规定的可靠交付、去重或因果交付条件,使需要交换的并发操作可交换。不能把某个状态式实现允许的重复投递,直接套到一个普通“收到消息就 +1”的操作式实现。

G-Counter 与 PN-Counter:按写入者保存贡献

G-Counter 中,每个写入者只递增自己的分量,读取对所有分量求和,合并取逐分量最大值。假设 A 当前为 [3,0],B 当前为 [1,2],合并得到 [3,2],值为 5。不是把两个本地总值 3 和 3 相加为 6,因为 A 的一次增量已同时存在于两份状态中。

重复合并 [1,2] 仍得到 [3,2];交换合并顺序也相同。这解释了为什么记录“每位写入者累计到了哪里”可以避免重复计算。不过,在还没收到 B 的状态时,A 读到的 3 只是其已知结果,不是实时全局总值。

PN-Counter 使用两个 G-Counter,分别记录增加量 P 与减少量 M,读取 sum(P)-sum(M)。底层两个状态仍只增长,可见数值则能增减。

但 PN-Counter 不保证库存非负。库存为 1 时,两地各自扣减 1,合并后可以正确收敛到 −1。若不能超卖,需要协调检查,或预先分配可消费额度并用有界计数协议管理额度转移;收敛本身没有保住这个约束。

两地都“检查还有 1”,为什么合并后变成 −1

初始增加分量只有 seed=1,A、B 尚未看见对方扣减时,各自产生一次减少。各自本地看到的剩余库存都是 0,但两次减少合并后都必须保留:

P = {"seed": 1}
minus_A, minus_B = {"A": 1}, {"B": 1}
assert sum(P.values()) - sum(minus_A.values()) == 0
assert sum(P.values()) - sum(minus_B.values()) == 0
merged_minus = {writer: max(minus_A.get(writer, 0), minus_B.get(writer, 0))
                for writer in minus_A.keys() | minus_B.keys()}
assert sum(P.values()) - sum(merged_minus.values()) == -1

# 改为预先把唯一一份可消费额度分给 A;各地只能消耗自己拥有的额度。
rights = {"A": 1, "B": 0}
def consume_local(node):
    if rights[node] == 0:
        return False
    rights[node] -= 1
    return True

assert consume_local("A")
assert not consume_local("B")
assert not consume_local("A")
assert sum(rights.values()) == 0
收敛计数与不超卖,是两个不同条件 共享旧视图:库存 1 A、B 都觉得可以扣 分别扣 1,再合并 最终 −1:结果收敛但已超卖 预分配:A=1,B=0 额度总数为 1 A 可扣,B 无额度 保住约束,B 可能需要等待 额度转移也必须防止重复消费;示例没有实现完整有界计数协议。

额度方法牺牲了一部分即时可用性:若 A 还有额度但 B 的网络隔离,B 也不能假装自己能卖。它可以等待、拒绝或在允许时向 A 请求转移,但转移过程必须防止同一额度同时在两地使用。上述字典只展示预分配且每地原子扣减的简化模型,没有实现分布式转移、持久化或请求去重。完整有界计数设计可参考 数值不变量论文。

OR-Set:删除观察到的添加标签

一个便于理解的状态式 OR-Set 为每次添加分配唯一标签,保存添加集合 A 和已删除标签集合 T。元素存在,当且仅当它至少有一个添加标签不在 T 中。删除元素时,只记录本地已经观察到的该元素标签。

Observed-Remove:只能删除已经观察到的添加 初始:两端都知道 (x, t1) A 删除 x 记录删除 t1 B 并发添加 x 生成新标签 t2 合并:t1 已删除,t2 存活 → x 存在 A 删除时没有观察到 t2,因此不能把这次并发添加一起删除。

合并可以对 A、T 分别求并集。上图中删除 t1 不影响并发产生的 t2,因此表现为并发添加胜出;如果 A 先观察到 t2,再删除 x,那么 t1、t2 都可被删除,最终 x 不存在。这种区别来自因果关系,而非消息恰好先到还是后到。

教学实现中的 tombstone 会不断增长。清理时需要证明旧标签不再从延迟消息、离线副本或备份中重新出现;实际优化实现可用因果上下文压缩元数据。不能单凭“删除已经过了几天”就无条件清空。

运行一个保留墓碑的 OR-Set

为了看清语义,下面用不可变集合保存全部添加标签与删除标签。新标签由“写者唯一身份+单调序号”提供;合并只传播已有标签,不重新生成标签。模型假设跨副本也不会生成相同标签,函数只能检查自己已经看见的重复。

from dataclasses import dataclass
from itertools import permutations

@dataclass(frozen=True)
class ORSet:
    adds: frozenset = frozenset()  # (element, tag)
    removed: frozenset = frozenset()

    def add(self, element, tag):
        if tag in self.removed or any(t == tag for _, t in self.adds):
            raise ValueError("新添加必须使用未用过的标签")
        return ORSet(self.adds | {(element, tag)}, self.removed)

    def remove(self, element):
        observed = {tag for item, tag in self.adds if item == element}
        return ORSet(self.adds, self.removed | observed)

    def merge(self, other):
        return ORSet(self.adds | other.adds, self.removed | other.removed)

    def values(self):
        return {item for item, tag in self.adds if tag not in self.removed}

base = ORSet().add("x", "A:1")
left = base.remove("x")
right = base.add("x", "B:1")
third = base.add("y", "C:1")
for order in permutations((left, right, third)):
    merged = ORSet()
    for state in (*order, order[0]):  # 改变顺序并重复传播
        merged = merged.merge(state)
    assert merged.values() == {"x", "y"}
    assert merged.removed == {"A:1"}

# 已看过并发添加后再删除,就能删除两个 x 标签。
deleted = merged.remove("x")
assert deleted.values() == {"y"}
assert deleted.removed == {"A:1", "B:1"}
assert deleted.merge(right).values() == {"y"}  # 迟到旧状态不复活 x
assert deleted.add("x", "A:2").values() == {"x", "y"}

# 清空删除标签,旧的添加记录立即重新可见。
assert ORSet(deleted.adds, frozenset()).values() == {"x", "y"}

第一次删除只知道 A:1,因此没有权利删除 B:1;第二次删除已经观察到两个标签,才能让 x 消失。之后使用 A:2 重新添加,表达的是新的业务操作。三种结果由操作的因果上下文决定,交换网络消息到达顺序不会改变它们。

这份朴素表示中 adds 与 removed 都只增长,查询时才过滤标签,便于看到“单调元数据如何产生可删除的可见集合”。最后一条断言说明随意清空墓碑甚至不需要等待远端消息,就能把本地保留的旧添加恢复出来。生产实现可以压缩元数据,但必须保留足够因果证据;优化方向见 An Optimized Conflict-Free Replicated Set。

若同一个业务“加购物车”请求重试时再次生成新标签,它会成为第二次添加。OR-Set 对重复状态传播的幂等性,不负责识别两个新标签其实来自同一个用户请求,业务请求 ID 仍要单独处理。

删除的是哪些添加,而不是一个名字

用标签而不是单一开关表示 x,可以看见并发删除真正覆盖了什么。先在 B 添加一个新标签,再让尚未同步的 A 删除;合并后新标签仍在。若先合并、让 A 看到新标签再删除,结果不同。两种结果来自已观察集合不同,而不是合并函数偏袒哪一方。

正在呈现知识画面
删除的是哪些添加,而不是一个名字

OR-Set 删除本地已观察的添加标签。未被观察到的并发添加仍可存活;再次观察后删除,才能覆盖它。

选择规则时检查语义,不只检查能否收敛

数据或约束应明确的语义
个人设置字段并发覆盖可否接受,赢家如何确定
点赞或统计是否只增,是否需要去重到业务事件
集合并发添加与删除谁胜出,能否重新添加
文本编辑字符身份、位置与删除如何表达,格式如何合并
库存、唯一名、资金限制哪些操作必须协调,是否能分配局部权限

一个 CRDT 计数器不会自动识别“用户重复发送了同一次点赞”。底层合并去重与业务请求去重是不同层次,后者仍需要稳定事件标识或其他业务规则。

继续阅读:复制策略、反熵与数据修复。

参考

Keywords: conflict resolution, LWW, version vector, CRDT, G-Counter, PN-Counter, OR-Set, convergence