先讲一个故事
想象你和朋友各自在纸上写购物清单。你写了"牛奶、面包",朋友写了"牛奶、鸡蛋"。碰面之后,你们想把两份清单合并成一份。
- 差劲的做法:你按你那张来,朋友按朋友那张来 → 有人说"牛奶写了两次!",有人说"面包呢?"
- 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() |
十、记住这三句话就够
- 半格合并保证终局一致------不管什么顺序合并多少次,结果相同
- Lamport 时钟 + RGA 锚点决定并发冲突的赢家------比谁晚、比谁挨着谁
- 不能真删除因为数据只追加------墓碑标记代替物理删除
这就是从数学到代码的 CRDT 全貌。