先讲一个故事想象你和朋友各自在纸上写购物清单。你写了牛奶、面包朋友写了牛奶、鸡蛋。碰面之后你们想把两份清单合并成一份。差劲的做法你按你那张来朋友按朋友那张来 → 有人说牛奶写了两次“有人说面包呢”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 用的叫RGAReplicated 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 指南 seq1, actor张三 Op 2: Insert(doc.tags, 锚点null) 技术 seq2, actor张三 Op 3: Insert(doc.tags, 锚点Op2) 分布式 seq3, 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 全貌。