12 · 多写者与冲突:多数派和合并的边界
目标:判断并发写入能否合并,以及合并后的收敛是否满足业务约束。
两个离线写者没有共同的现在
多主复制让不同地点都接受写入;本地优先应用也可能让多个离线设备分别修改状态。恢复连接时,必须处理冲突。如果一方修改依赖于另一方的结果,两次写入存在因果顺序;若彼此都没看到对方,就可能是并发的,哪怕物理时间相差几秒。
“最后写入胜出”(LWW)按选定排序保留一个值,会丢掉另一个更新;时钟误差还会扭曲排序。版本向量用每个写者的计数帮助判断因果:一个向量各分量都不小于另一个且至少一个更大,才可以说它覆盖了后者;互有大有小表示并发。
无主复制把写发给多个副本、读也查多个副本。固定副本集合中,R + W > N 保证读写集合相交,但这不是线性一致性的完整证明。并发写、部分成功、错误版本排序、替补副本和故障恢复都可能使简单推论失效。相交不能替业务决定如何合并。
为什么取最大值反而能保留两次增加
CRDT 的一种做法是选择天然可收敛的数据表示。下面是只能增加的 G-counter,每个设备只增加自己的分量,合并逐分量取最大,最终数值再求和。保存 conflicts.py,预测重复合并会不会重复计数。
def merge(a, b):
return {k: max(a.get(k, 0), b.get(k, 0)) for k in sorted(a.keys() | b.keys())}
def precedes(a, b):
keys = a.keys() | b.keys()
return (all(a.get(k, 0) <= b.get(k, 0) for k in keys)
and any(a.get(k, 0) < b.get(k, 0) for k in keys))
left = {"device-a": 1, "device-b": 0}
right = {"device-a": 0, "device-b": 1}
both = merge(left, right)
assert not precedes(left, right) and not precedes(right, left)
assert merge(both, right) == both
assert merge(left, right) == merge(right, left)
assert sum(both.values()) == 2
print("merged_count:", sum(both.values()))
print("same_after_retry:", merge(both, right) == both)
uv run --python 3.12 --no-project conflicts.py
预期输出:
merged_count: 2
same_after_retry: True
两个分量分别承载独立增加,取最大避免重复消息再次累加。同一写者必须有稳定身份,并持久保持自己的计数。合并满足交换、结合和幂等性质,适合在重复、乱序传播后收敛。文本协作的 OT 会变换操作位置,序列 CRDT 则采用另一套身份和顺序规则;一个计数器实验不能代表完整协同编辑算法。
引导修改:① 把设备 A 的计数改为 2;② 预期总数改为 3;③ 按两个相反顺序合并并断言一致。还可检查 merge(merge(left, right), both) == merge(left, merge(right, both))。
独立练习:活动只剩一个名额,两个离线设备各自确认一份报名。能否把本实验计数器用于保证不超卖?给出一个可实施的产品选择。
参考答案
不能。收敛到 2 恰好证明发生了两次确认,并不满足总数至多 1。可以让离线操作只创建“待确认请求”,连接权威服务后才确认;也可以事先把可用名额拆成互不重叠的额度分给设备,但额度转移本身需要可靠协议。若业务允许超额后补偿,必须明示这一语义,不能称为不超卖。
重复合并仍增加时,检查是否把整份远端计数直接累加。设备重装后计数丢失时,检查身份与持久化策略。删除、撤销不能直接套 G-counter,需要另外定义可合并表示及业务规则。
在 design.md 将点赞或浏览计数与报名名额分开:前者可接受延迟收敛,后者必须先约束确认权。