25.6 CRDT (计数 / 集合 / 寄存器)
一句话
CRDT 让 “任意顺序、重复应用 的结果都一样”:状态型靠
merge满足交换/结合/幂等,操作型靠因果投递——代价是它只能表达可合并的结构,表达不了 “余额不能为负” 这种约束。
两大类
| 类型 | 做法 | 要求 |
|---|---|---|
| 状态型(CvRDT) | 各副本合并状态,merge 满足交换/结合/幂等 | 状态要能定义成一个 “只增不减” 的偏序集 |
| 操作型(CmRDT) | 广播操作,按因果序应用 | 需要可靠、因果有序的投递 |
四个常用结构
| 结构 | 语义 | 合并方式 | 局限 |
|---|---|---|---|
| G-Counter | 只增计数 | 各副本向量逐项取 max 再求和 | 不能减 |
| PN-Counter | 可增可减 | 增/减两个 G-Counter 相减 | 元数据翻倍 |
| 2P-Set | 只增删一次 | 存在集 + 墓碑集,删除优先 | 不能重复添加 |
| OR-Set | 可反复增删 | 每个元素带唯一 tag,删除只删自己见过的 tag | 元数据随操作增长 |
| LWW-Register | 单值覆盖 | 时间戳大者胜 | 依赖时钟(见 23.1 物理时钟与漂移) |
| MV-Register | 单值冲突保留 | 保留并发写入的多个值,交给应用/用户选 | 需要冲突解决界面 |
为什么 “合并” 能保证收敛
状态型 CRDT 的 merge 是一个 join(上确界):交换律保证 “谁先谁后不影响”,结合律保证 “分批合并等价于一次合并”,幂等保证 “重复收到同一状态不变”。三者合起来,任意网络乱序与重传最终都收敛到同一状态。
表达不了什么
| 约束 | 为什么 CRDT 做不到 |
|---|---|
| 余额 ≥ 0 | 并发扣减可能各自合法、合并后为负 |
| 唯一用户名 | 需要全局仲裁,不是可合并结构 |
| 库存不超卖 | 同上,需要 “拒绝” 而不是 “合并” |
| 有序唯一编号 | 需要全局定序(见 21.x 共识) |
这类 “必须拒绝” 的约束,只能靠中心化仲裁(共识/锁)或事后对账。
实测:合并顺序与重复合并都不改变结果
本机 Python 3.14 实测:
| 结构 | 操作 | 结果 |
|---|---|---|
| G-Counter(3 副本,各 +1、+10、+2) | 枚举全部 6 种合并顺序 | 结果集合 = {36}(唯一) |
| OR-Set(A 加 x、y;B 加 x 并删 A 的 tag) | a∪b 与 b∪a | 都是 ['x','y'](一致) |
| OR-Set | (a∪b)∪b(重复合并) | 仍为 ['x','y'](幂等) |
这三条正是状态型 CRDT 的三个数学性质:交换律(顺序无关)、结合律(分批合并等价)、幂等(重复合并不变)。缺任何一个,网络重传或乱序就会让副本发散。
class GCounter:
def merge(self, o): # 逐副本取 max,满足交换/结合/幂等
return GCounter.from_v([max(a, b) for a, b in zip(self.v, o.v)])
def value(self): return sum(self.v) # 求和得到全局计数(只增)
class ORSet:
def merge(self, o): # 每个元素取 tag 并集
r = ORSet()
for e in set(self.elems) | set(o.elems):
r.elems[e] = self.elems.get(e, set()) | o.elems.get(e, set())
return r
反模式
| 反模式 | 后果 |
|---|---|
| 用 CRDT 做金额账务 | 合并出负数,无法对账 |
| 元数据不设上限 | OR-Set 的 tag 无限增长,内存爆 |
| 操作型 CRDT 不保因果序 | 收敛到错误状态 |
| 认为 “最终一致 = 马上一致” | 用户看到回跳,体验差 |
三条铁律
🔑 CRDT 解决 “可合并的冲突”;需要拒绝的约束必须中心化。
🔑 元数据会膨胀:删除/添加都要留 “墓碑/tag”,要设计清理策略。
🔑 能收敛 ≠ 业务正确:合并后的值仍需满足业务语义。
自检
Q1 为什么 G-Counter 只需 “逐副本取 max 再求和”?
▸ 答案:因为每个副本只增不减,其计数值单调递增;合并时取每个副本的 max 就能得到 “每个副本贡献的最大值”,求和即全局总数。这个过程满足交换/结合/幂等,所以任意顺序合并结果一致。
Q2 2P-Set 为什么 “删了就不能再加”?
▸ 答案:因为它用 “墓碑集” 记录删除,且墓碑只增不减。元素一旦进墓碑,就永远处于 “已删” 状态;要支持重复添加,必须用 OR-Set(每个添加带唯一 tag,删除只移除已见的 tag)。
Q3 OR-Set 的元数据为什么会膨胀?
▸ 答案:因为每次添加都会生成唯一 tag 并随元素保存,删除只删除 “本地见过的 tag”。并发添加/删除越多,残留 tag 越多;需要垃圾回收(基于因果稳定时间)来控制。
Q4 购物车用哪种 CRDT 合适?
▸ 答案:OR-Set:并发添加与删除都能正确合并(“加回来” 与 “删掉” 不会互相覆盖错误)。但 “同一商品改数量” 需要额外语义(用 PN-Counter 或 LWW per item),否则会出现 “两次 +1 合并成 1” 的错误。
Q5 什么时候该放弃 CRDT 改用中心化?
▸ 答案:当约束是 “必须拒绝” 而非 “可以合并” 时:库存、余额、唯一键、限额。这类场景需要全局仲裁点(数据库行锁/共识/专用服务);硬套 CRDT 只会把 “不一致” 推迟到对账时爆发。
Q7 OR-Set 删掉 “自己没见过的 tag” 会发生什么?
▸ 答案:删不掉(在并发合并后元素又回来)——这正是 OR-Set 的语义(add-wins)。如果要 “删了就永远不回来”,得用 2P-Set(墓碑)或给删除加更高优先级,代价是 “删后不能再加”。所以 “并发增删” 的语义必须先和产品确认,不能由实现随便定。
关联
- 前置:25.3 冲突检测与解决策略
- 并列:25.1 乐观并发与悲观并发、16.5 最终一致性
- 下游:67.3 对账与差异处理(待补)、23.4 向量时钟