冲突的关键是:两个操作分别基于各自已知的状态产生,而系统没有把它们事先排成一个共同顺序。网络上的到达先后并不等于业务上的因果先后。解决冲突前,应先说明两个操作分别表达什么意图,以及哪种结果符合产品语义。
覆盖更新与合并操作表达不同意图
把点赞数“设置为 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
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 ( ) 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" }
deleted = merged . remove ( " x" )
assert deleted . values ( ) == { " y" }
assert deleted . removed == { " A:1" , " B:1" }
assert deleted . merge ( right ) . values ( ) == { " y" } 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 删除本地已观察的添加标签。未被观察到的并发添加仍可存活;再次观察后删除,才能覆盖它。
{"id":"orset-observed-tags","title":"删除的是哪些添加,而不是一个名字","summary":"OR-Set 删除本地已观察的添加标签。未被观察到的并发添加仍可存活;再次观察后删除,才能覆盖它。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"two\"><section class=\"card\"><header>A</header><strong id=\"visible0\"></strong><div class=\"tags\" id=\"tags0\"></div><button id=\"add0\" data-i18n=\"add\"></button><button id=\"remove0\" data-i18n=\"remove\"></button></section><section class=\"card\"><header>B</header><strong id=\"visible1\"></strong><div class=\"tags\" id=\"tags1\"></div><button id=\"add1\" data-i18n=\"add\"></button><button id=\"remove1\" data-i18n=\"remove\"></button></section></div><div class=\"actions\"><button id=\"merge\" data-i18n=\"merge\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><p class=\"hint\" data-i18n=\"hint\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.tags{min-height:160px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:24px;align-content:start;margin:12px 0}.tags .cell{min-height:0;font-size:11px;padding:2px}.card button{margin-top:8px}.card strong{display:block;min-height:3em;font-size:14px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let adds,removed,count;function reset(){adds=[new Set(['A:1']),new Set(['A:1'])];removed=[new Set,new Set];count=[1,0];draw();}function draw(){for(let i=0;i<2;i++){cells('tags'+i,[...adds[i]].sort().map(x=>({text:x,cls:removed[i].has(x)?'error':'on'})));const live=[...adds[i]].filter(x=>!removed[i].has(x));set('visible'+i,t(live.length?'present':'absent'));$('#visible'+i).dataset.present=String(!!live.length);$('#add'+i).disabled=count[i]>=6;}}for(let i=0;i<2;i++){$('#add'+i).onclick=()=>{adds[i].add('AB'[i]+':'+(++count[i]));draw();};$('#remove'+i).onclick=()=>{adds[i].forEach(x=>removed[i].add(x));draw();};}$('#merge').onclick=()=>{const a=new Set([...adds[0],...adds[1]]),r=new Set([...removed[0],...removed[1]]);adds=[new Set(a),new Set(a)];removed=[new Set(r),new Set(r)];draw();};$('#reset').onclick=reset;reset();","audio":false,"strings":{"absent":"x 不存在","add":"添加 x","heading":"删除的是哪些添加,而不是一个名字","hint":"实线标签仍存活;划线标签已有墓碑。同步不会把同一标签变成新的添加。","intro":"两边都认识 A:1。让 B 并发添加,再让 A 删除;合并时对照添加标签和墓碑。","merge":"同步双方状态","present":"x 存在","remove":"删除已见 x","reset":"重置","scope":"单元素 x,两个写者,唯一递增标签;状态合并为集合并集,墓碑保留。每边最多产生六个标签以限制画面。","scopeLabel":"模型范围与约定","tags":"添加 / 删除标签"}}
选择规则时检查语义,不只检查能否收敛
数据或约束 应明确的语义
个人设置字段 并发覆盖可否接受,赢家如何确定
点赞或统计 是否只增,是否需要去重到业务事件
集合 并发添加与删除谁胜出,能否重新添加
文本编辑 字符身份、位置与删除如何表达,格式如何合并
库存、唯一名、资金限制 哪些操作必须协调,是否能分配局部权限
一个 CRDT 计数器不会自动识别“用户重复发送了同一次点赞”。底层合并去重与业务请求去重是不同层次,后者仍需要稳定事件标识或其他业务规则。
继续阅读:复制策略 、反熵与数据修复 。
参考
Keywords: conflict resolution, LWW, version vector, CRDT, G-Counter, PN-Counter, OR-Set, convergence