【CRDT 底层实现原理(通俗版)】

先讲一个故事

想象你和朋友各自在纸上写购物清单。你写了"牛奶、面包",朋友写了"牛奶、鸡蛋"。碰面之后,你们想把两份清单合并成一份。

  • 差劲的做法:你按你那张来,朋友按朋友那张来 → 有人说"牛奶写了两次!",有人说"面包呢?"
  • Git 的做法:标出冲突,让你手动选 → 但这不是计算机能自动做的
  • CRDT 的做法 :设计一种数据结构,任意顺序合并,结果自动一致 → 这就是本文要讲的

一、数学保证:半格

CRDT 的无冲突能力来自一个叫半格的数学结构。

半格 = 一个集合 + 一个合并操作,这个合并操作满足三条规则:

规则 通俗解释
交换律 a ⊔ b = b ⊔ a 谁先谁后无所谓
结合律 a ⊔ (b ⊔ c) = (a ⊔ b) ⊔ c 分批合和一起合一样
幂等律 a ⊔ a = a 重复合并不会多出东西

只要你的合并操作满足这三条,不管以什么顺序合并多少次,最终结果都相同。这就是 CRDT"无冲突"的根。


二、最简例子:只增计数器

三个同事各自统计今天的 bug 数:

复制代码
张三发现了 3 个 bug  →  张三记: 3
李四发现了 5 个 bug  →  李四记: 5
王五发现了 2 个 bug  →  王五记: 2

合并时不用"对账"(数据怎么来、谁先谁后),只做一个操作:取最大值

复制代码
max(3, 5, 2) = 5

取最大值满足交换律(谁先谁后一样)、结合律(分批算也一样)、幂等律(重复算不会变)。问题解决。

真实 CRDT 里稍微复杂一点------PN-Counter(正负计数器):

复制代码
张三加了 3,又减了 1
李四加了 5

保存成两套计数器:
  正向: {张三: 3, 李四: 5}   → max → {张三: 3, 李四: 5}
  负向: {张三: 1, 李四: 0}   → max → {张三: 1, 李四: 0}

结果 = 正向之和 - 负向之和 = 8 - 1 = 7

三、解决"两个人同时改一个字段":LWW-Register

这是最常用的 CRDT 原语。LWW = Last-Writer-Wins(最后写的人获胜)。

问题:你和同事同时修改一个文档标题,你的机器上 title="你好",同事机器上 title="Hello"。同步后该用哪个?

解法 :不只看值,还要看谁在什么时间写的

每个写操作附带一个 Lamport 时钟

复制代码
Lamport 时钟 = (逻辑序号, 操作者ID)

张三的第 7 次操作  →  (7, 张三)
李四的第 5 次操作  →  (5, 李四)

合并规则很简单:时钟大的赢

复制代码
比较 (7, 张三) 和 (5, 李四):
  先比序号: 7 > 5  →  张三赢
  如果序号相同: 比操作者 ID → 按字母序

结果:保留 title = "你好"(张三写的)

关键 :被覆盖的 "Hello" 不会删除,只是标记为"旧的"。CRDT 里从不真删除数据,下面会讲为什么。


四、解决"两个人同时插入列表":RGA 算法

这是 CRDT 里最精妙的设计。Automerge 用的叫 RGA(Replicated Growable Array)

问题

复制代码
初始列表:  [苹果, 香蕉]

你在手机上在第 1 位插入"橘子" → [苹果, 橘子, 香蕉]
同事在电脑上也在第 1 位插入"西瓜" → [苹果, 西瓜, 香蕉]

同步后应该是?
  [苹果, 橘子, 西瓜, 香蕉]  还是  [苹果, 西瓜, 橘子, 香蕉]?

传统方法记"插入到第 1 位"会冲突,因为两边的"第 1 位"指向的是同一处。

RGA 的做法:不记位置编号,而是记"我紧跟在谁后面"。

复制代码
每个列表元素有一个全局唯一的"地址":

橘子: "我紧跟在 [苹果] 后面,操作序号 (5, 张三)"
西瓜: "我紧跟在 [苹果] 后面,操作序号 (3, 李四)"

合并时,两个都以苹果为锚点,按 (序号, 操作者) 排序:
  (3, 李四) < (5, 张三)  →  西瓜在前,橘子在后

最终: [苹果, 西瓜, 橘子, 香蕉]

无论多少并发插入,因为每个元素的地址是全局唯一的,排序永远确定。 这就是 RGA 的精髓。


五、为什么不能"真删除"

你用过 DevMesh 就知道,删除知识项不是真的删掉,而是标记为 tombstone(墓碑)。这不是代码偷懒,是 CRDT 的硬约束。

复制代码
场景:
  张三: 删除了知识项 X
  李四: 不知道张三删了 X,修改了 X 的标题

如果"真删除"(X 从存储里消失):
  → 李四的修改到达时,找不到 X,出错或丢失数据

CRDT 的做法(保留墓碑):
  → X 保留在 ops 里,带上删除标记
  → 李四的修改到达时,看到 X 已被删,忽略这个修改

这解释了为什么 .automerge 文件只会变大不会变小------所有历史操作都在里面。


六、Automerge 内部怎么存的

Automerge 把一个 JSON 文档拆成一堆**操作(Op)**来存储,而不是存最终状态。

复制代码
假设文档是:
{
  "title": "CRDT 指南",
  "tags": ["技术", "分布式"]
}

Automerge 存的是:
  Op 1: Set(doc.title) = "CRDT 指南"    seq=1, actor=张三
  Op 2: Insert(doc.tags, 锚点=null) = "技术"  seq=2, actor=张三
  Op 3: Insert(doc.tags, 锚点=Op2)  = "分布式"  seq=3, actor=张三

读取文档时,Automerge 重放所有操作,重建出最终状态。这就是为什么叫 "Op-based CRDT"。


七、同步时怎么只传差异

A 和 B 各自离线工作了一段时间,各有几十个新操作。同步时不需要传全部操作------只需要传对方没见过的那部分

复制代码
机制:Heads(头哈希)

1. A 说: "我见过的最后操作是 Op#42 和 Op#43"
2. B 在自己的 ops 集合里找: "我比 A 多了 Op#44 到 Op#47"
3. B 把这 4 个 Op 的二进制发给 A
4. A 把收到的 Op 插入自己的 ops 集合
5. A 重放所有操作 → 得到合并后的文档

原理类似 Git 的 commit hash 追踪,但 Git 需要人工 merge,
CRDT 自动搞定。

Change 的二进制格式是 Automerge 自定义的列式压缩:

复制代码
一个 Change 包含:
  ┌─────────────────────────────┐
  │ Header: actor_id, seq, deps │
  │ Op Column: 所有操作的压缩     │
  │ Actor Column: 相关操作者     │
  └─────────────────────────────┘

压缩后很小,适合网络传输。


八、一张图总结全流程

复制代码
用户写代码:
  backend.change({ mutate: (doc) => { doc.title = "新标题" } })
  ↓
Automerge 处理:
  1. 检查 doc.title 的当前 Op
  2. 创建新 Op: Set(doc.title) = "新标题", seq=当前+1
  3. 旧 Op 标记为被覆盖(不删除!)
  ↓
冲突解决:
  - 如果新 Op 和另一个 Op 并发修改同一字段
  - 比较 Lamport 时钟 → 时钟大的赢(LWW)
  - 输的保留在 ops 里,只是不生效
  ↓
持久化:
  二进制序列化 → 写入 .automerge 文件
  ↓
同步:
  getChangesSince(heads) → 提取增量 → 发给对端
  ↓
投影:
  toJS() 还原成 JSON → 建 FlexSearch 索引 → 可搜索

九、三种核心 CRDT 原语对比

原语 解决的问题 Automerge 中的应用
LWW-Register 并发写同一个值 JSON 对象的标量字段
RGA 并发插入列表 JSON 数组
PN-Counter 并发增减计数 Automerge.increment()

十、记住这三句话就够

  1. 半格合并保证终局一致------不管什么顺序合并多少次,结果相同
  2. Lamport 时钟 + RGA 锚点决定并发冲突的赢家------比谁晚、比谁挨着谁
  3. 不能真删除因为数据只追加------墓碑标记代替物理删除

这就是从数学到代码的 CRDT 全貌。