
1. 为什么协同编辑会用到 CRDT从多端冲突说起我最近在折腾一个在线文档项目多人同时编辑同一份文本每次遇到两个人同时改了同一个段落合并结果都是乱套的。最开始我用的方案很粗暴——“最后写入者胜”谁后保存听谁的。结果有一次两个人同时删了不同的句子最后一方保存后另一方的改动直接没了。用户在群里反馈问题的时候我才意识到传统的数据同步思路根本不适合协同编辑这个问题的根源在于冲突处理的模型选错了。CRDTConflict-Free Replicated Data Type无冲突复制数据类型就是冲着这个问题来的。它允许每个终端在完全独立的情况下修改本地数据不需要锁、不需要中央协调器最后只要把各端的副本合并起来所有副本会收敛到同一个状态谁先谁后合并都不影响结果。这篇文章里的所有代码我都用 PHP 写了一遍跑通了一遍测试。涉及的内容包括四个经典结构G-Counter、LWW-Register、OR-Set、RGA复制增长序列以及它们如何组合成一个可用的协同编辑后端。适合以下人群参考后端是 PHP想把协同编辑能力接入现有项目的同学了解 CRDT 概念但没上手写过实现的同学想评估“PHP 做协同编辑服务端到底行不行”的架构决策者。1.1 传统方案的痛点和 CRDT 的数学根基我们常规能想到的冲突处理路线大概有三条互斥锁/协作锁同一时刻只让一个人编辑一个区域。简单但违反协同编辑的直觉体验差。最后写入者胜LWW按时间戳或者保存顺序覆盖。实现成本最低但经常丢数据。操作转换OT把每个操作转换后再应用到对方文档上Google Docs。前两条不用多说问题很明显。OT 的问题是它高度依赖操作顺序和转换算法服务端要么维护一个集中的操作序列要么让所有客户端建立严格的先后关系在断网重连、离线编辑这类场景下OT 的实现复杂度非常吓人。CRDT 走的是另一条完全不同的路不消除冲突而是让冲突本身不影响收敛结果。它依赖一个很朴素的数学性质——合并操作为满足结合律、交换律、幂等性的函数。当年我看到这段的时候第一反应是这不就是个加法吗确实。1 2和2 1结果一样加多少次也不会有副作用。只要把整个复制状态设计成满足这三种性质的代数结构每个副本无论是收到新状态还是旧状态合并结果都稳定。这里要反转一个直觉合并操作本身不是无条件的“合到一起”而是每个字段、每个容器都要定义一套自己的合并规则。比如下面的数据结构就天然无冲突计数器merge(a, b) a b 集合merge(a, b) a ∪ b因为它们天然满足结合律、交换律、幂等性。难的是那些带删除语义的结构比如删掉一个元素后再并发增加到底算存在还是不存在这些场景需要专门的 CRDT 设计后面会逐个讲。1.2 两类 CRDT基于状态与基于操作CRDT 实现上分两派。基于状态State-based / CvRDT同步的是每个副本的整个状态在目标端执行合并函数。优点是逻辑简单不依赖消息是否有序到达收到什么就合并什么缺点是状态可能无限增长比如一个集合里删掉的数据不能立即消失需要留 tombstone墓碑标记否则旧的合并消息可能会让已删除的元素“复活”。基于操作Op-based / CmRDT同步的是操作日志对端执行这些操作。优点是状态可以比较小只传播增量操作但是要求操作传播满足一定的因果顺序还要保证操作被精确执行一次消息重复或乱序都可能出问题。实际工程里大部分实现都混合使用网络层按操作传播底层存储按状态合并。比如协同编辑里本地插入一个字符算 Op最终落库时把整个文档状态做一次 State 合并。这也是我在项目里的最终形态。后面代码示例主要以 State-based 为主因为它最适合 PHP 这类“服务端收到完整请求-处理-落库”的体系。1.3 容易误判的一点CRDT 不负责网络可靠性有一点必须提前说清楚避免有人上线后踩大坑CRDT 只保证数据合并逻辑收敛网络层还得自己写。如果你只是把 CRDT 结构体直接暴露在 HTTP 接口上客户端可以任意提交修改服务端不做鉴权、不去重、不排序照样会产生业务层面的脏数据。CRDT 的“无冲突”指的是数学层面的收敛性不包括业务语义。业务上的“谁有权限改这个字段”“是否允许多次提交同一操作”还是得靠应用层自己控制。2. 四种必须掌握的 CRDT 结构计数器、寄存器、集合与文本想直接上手 RGA 做协同编辑之前我强烈建议先把比较基础的三个结构写一遍因为 RGA 里很多细节比如时间戳比较、tombstone、引用前驱节点都是从它们演化出来的。2.1 G-Counter / PN-Counter从投票数据看计数合并G-CounterGrow-only Counter只增计数器。假设你做一个多端同时给一篇文章点赞的功能每个终端有一个独立的点赞计数最终总点赞数怎么算简单想法是把每个终端对同一篇文章的点赞数加起来。?php final class GCounter { /** * var arraystring, int */ private array $counts; public function __construct(private readonly string $nodeId) { $this-counts [$this-nodeId 0]; } public function increment(): int { $this-counts[$this-nodeId]; return $this-counts[$this-nodeId]; } public function merge(self $other): void { foreach ($other-counts as $nodeId $count) { // 关键相同节点只保留最大值而不是相加。 // 因为 G-Counter 合并的是“状态”不是“操作”。 $this-counts[$nodeId] max($this-counts[$nodeId] ?? 0, $count); } } public function value(): int { return array_sum($this-counts); } }合并 G-Counter 时一定要逐节点取最大值而不是相加。为什么因为两个副本之间会多次交换状态。假设 A、B 两个终端A 本地点了 3 次赞B 本地点了 2 次A 把状态同步给 B 后[A3]随后 B 又把状态同步回 A如果按加法合并A 就会从[A3]变成[A6, B2]总数永远不对。取最大值则符合幂等性。PN-Counter 是 G-Counter 的扩展允许递减。内部实现是pos和neg两个 G-Counter总值等于pos.value() - neg.value()。注意这两个计数器不能合并为一个普通整数因为减法不满足幂等性必须分开存储正负增量。2.2 LWW-Register带时间戳的“最后写入胜出”LWW-RegisterLast-Write-Wins Register带时间戳的寄存器。适合保存单值字段比如文档标题、用户昵称。合并规则很简单时间戳大的值胜出相同时间戳时节点 ID 大的胜出。?php final class LWWRegister { public function __construct( private readonly string $nodeId, private mixed $value null, private int $timestamp 0, ) { } public function set(mixed $value, int $timestamp): void { if ($this-compare($timestamp, $this-nodeId, $this-timestamp, $this-nodeId) 0) { $this-value $value; $this-timestamp $timestamp; } } private function compare( int $tsA, string $nodeA, int $tsB, string $nodeB, ): int { return $tsA $tsB ?: $nodeA $nodeB; } public function merge(self $other): void { if ($this-compare( $other-timestamp, $other-nodeId, $this-timestamp, $this-nodeId, ) 0) { $this-value $other-value; $this-timestamp $other-timestamp; } } public function value(): mixed { return $this-value; } }实现很简单但两个隐藏问题必须处理物理时钟回拨问题用户把手机时间改到过去会导致之后的 set 不会被接受。解决办法是合并时用单调递增的逻辑时钟比如 Lamport 时间戳。时钟粒度问题同毫秒内两次 set 会因节点 ID 大小直接覆盖但这其实是“业务层不等于数学层”的例子——最后谁赢是按 ID 排出来的业务上未必是你想要的那个人应用层需要加业务规则干预。2.3 OR-Set 与两级集合删除操作如何反向恢复OR-SetObserved-Remove Set观察移除集合是 CRDT 里第一个真正反直觉的结构。普通集合的合并是取并集问题在于删除操作。假设终端 A 和终端 B 都有一份{苹果, 香蕉}。A 删掉了苹果B 不知道把{苹果}同步过去后合并结果把苹果又带回来了。这就是 CRDT 里最麻烦的“已删除元素复活”问题。OR-Set 的解法删除元素时不真正移除而是打 tombstone 标记合并时同时保留增量和墓碑最后按墓碑过滤显示。看实现?php final class ORSet { /** * 元素容器tag value。 * tag 用唯一 ID 标识一次添加操作value 是真正的元素内容。 */ private array $elements []; /** 墓碑集合tag true表示该次添加已被删除。 */ private array $tombstones []; public function add(string $value): string { $tag $this-uuid(); $this-elements[$tag] $value; return $tag; } public function remove(string $value): void { foreach ($this-elements as $tag $item) { if ($item $value !isset($this-tombstones[$tag])) { // 注意这里不删除 elements 中的键只打标记。 $this-tombstones[$tag] true; } } } public function merge(self $other): void { foreach ($other-elements as $tag $value) { if (!isset($this-elements[$tag])) { $this-elements[$tag] $value; } } foreach ($other-tombstones as $tag $true) { $this-tombstones[$tag] true; } $this-prune(); } public function value(): array { $result []; foreach ($this-elements as $tag $value) { if (!isset($this-tombstones[$tag])) { $result[$value] true; } } return array_keys($result); } private function prune(): void { // 墓碑标记过的元素可以安全地从 container 移除了 // 因为双方都已经知道它被删除了。 foreach ($this-tombstones as $tag $_) { unset($this-elements[$tag]); } } private function uuid(): string { return bin2hex(random_bytes(16)); } }注意prune()的位置合并其他副本之后、计算 value 的时候才做清理。不能在一收到删除操作就立刻从容器里删掉元素否则这个删除操作还没同步给其他终端对方旧状态合进来时元素就复活了。2.4 RGA 文本序列协同编辑的核心结构RGAReplicated Growable Array复制增长序列是文本类协同编辑最常用的 CRDT 结构之一。它把文本看成一系列带身份标识的字符节点每个节点记录三样东西字符内容唯一 ID通常包含进程 ID 逻辑时钟前驱节点 ID记录它插在哪个字符后面。插入一个字符时生成的节点带着“前一个字符的 ID”作为锚点。合并时所有终端根据前驱关系重建完整顺序。两个终端在同一位置插入不同字符时两个节点共享同一个前驱排序规则按(字符ID, 时间戳)定名次这样双方排序结果一致。RGA 的完整 PHP 实现放在第 3 节讲因为它是核心拆开讲更能看明白。3. PHP 实现 CRDT核心类与合并逻辑实战前三种结构的 PHP 代码量不大加起来一百行出头。RGA 会复杂一些我直接给一个能用的版本并标注清楚每个细节为什么这么写。3.1 G-Counter 的 PHP 实现前面已经给过完整代码了。这里补一个说明G-Counter 的counts数组是哈希表PHP 数组天然就是HashMap这对于 CRDT 这种“按节点 ID 存放状态”的场景非常合适不需要额外引入容器类。一个推论因为数组键天然去重两个副本合并时max()操作天然幂等。这也是为什么 PHP 实现 CRDT 的难度低于 Java、Go——容器基础能力自带一半。3.2 OR-Set 的 PHP 实现前面代码同样完整。补充一个实操细节OR-Set 的墓碑集合会无限增长因为每个 tag 即使删除了也保留在tombstones里。这在长期运行的协作文档里会产生明显的存储膨胀。常见的压缩手段有两种定期做“全局 GC”当所有节点都确认某个 tag 已经不存在于任何副本时才从墓碑集合里移除使用有界时间窗口超过 N 小时没有新操作引用该 tag 时允许清理。不要写一个“删除墓碑后永不再合并旧副本”的优化旧副本随时可能重新上线这是 CRDT 里最容易引发“幽灵复活”的隐性 bug。3.3 RGA 在 PHP 中的实现与合并细节这里用字符串拼接的方式演示实际工程中建议改成字符数组便于按字符粒度插入。?php final class RgaNode { public function __construct( public readonly string $id, public readonly string $prevId, public readonly string $content, public readonly int $clock, public readonly string $nodeId, public bool $tombstoned false, ) { } } final class RGA { /** var arraystring, RgaNode */ private array $nodes []; public string $headId root; public function __construct() { // 虚拟根节点“文本开头”的前驱。 $root new RgaNode(root, , , 0, system); $this-nodes[root] $root; } public function localInsert(string $prevId, string $content, int $clock): string { $id bin2hex(random_bytes(8)) . - . $clock . - . $this-nodeId; $node new RgaNode( $id, $prevId, $content, $clock, $this-nodeId, ); $this-nodes[$id] $node; return $id; } public function localDelete(string $nodeId): void { if (isset($this-nodes[$nodeId])) { $this-nodes[$nodeId]-tombstoned true; } } public function merge(self $other): void { // 先吸收对方所有节点再进行拓扑排序。 foreach ($other-nodes as $id $node) { if (!isset($this-nodes[$id])) { $this-nodes[$id] $node; } else { // 相同 ID 的节点理论上内容一致只更新墓碑状态。 $this-nodes[$id]-tombstoned $this-nodes[$id]-tombstoned || $node-tombstoned; } } $this-rebuildOrder(); } public function text(): string { $order $this-orderedIds(); $out ; foreach ($order as $id) { $node $this-nodes[$id]; if (!$node-tombstoned) { $out . $node-content; } } return $out; } /** * 按前驱关系重建完整顺序。 * 同一前驱下的多个节点按 (clock, nodeId, id) 排序。 */ private function rebuildOrder(): void { $this-orderedIds null; } /** return string[] */ private function orderedIds(): array { if ($this-orderedIds ! null) { return $this-orderedIds; } $result []; $current $this-headId; while ($current ! ) { $result[] $current; $children []; $current ; foreach ($this-nodes as $id $node) { if ($node-prevId $result[count($result) - 1]) { $children[] $id; } } if ($children ! []) { usort($children, function (string $a, string $b) { return [$this-nodes[$a]-clock, $this-nodes[$a]-nodeId, $a] [$this-nodes[$b]-clock, $this-nodes[$b]-nodeId, $b]; }); $current $children[0]; } } // 这里应继续处理所有 children 后续的链示例简化了完整版用队列更清晰。 // 实际项目中建议改成基于“前驱节点 → 后继节点列表”的双向索引。 return $this-orderedIds $result; } }上面这段是核心演示但orderedIds()的遍历方式有隐患如果文本量变大每次重建顺序都是 O(n²)性能会很难看。工程版建议这样优化维护prevId children list的索引结构合并时只在受影响的子树上重算顺序而不是全量重建有序数组插入时使用二分查找避免每次从头扫描。RGA 合并为什么能保证收敛所有终端持有的节点集合相同每个节点的前驱 ID 相同同一前驱下子节点的排序规则相同。排序规则必须是一个全序关系不能只依赖时间戳因为时钟可能相同所以我把(clock, nodeId, id)三字段共同比较确保绝对不会出现“无法决定谁在前面”的情况。这段实现里有一个容易忽略的点删除也要走墓碑。如果我不给 RGA 的本地删除打墓碑而是直接从nodes里 unset那么某个终端还没收到删除消息时把旧状态合并过来这个字符就复活了。和 OR-Set 的原理一模一样。3.4 合并顺序的单调性为什么不能交换顺序在写 CRDT 代码时merge()内部的设计必须遵守一个约束合并结果只增加信息不减少信息。比如 LWW-Register合并时如果直接用“后收到的覆盖先收到的”那么先收到的那个值丢失了。假如另一个终端反过来合并发现这个值比它本地的旧就会出现 A 认为 B 赢了B 认为 A 赢了来回抖动。这也是为什么所有 CRDT 的删除操作全都要转换成“增加一个墓碑”而不是“移除一个元素”。删除不是状态减少而是状态里多了一个墓碑标记。这个认知对调试 CRDT bug 非常关键。4. 从数据结构到协同系统网络层、持久化与集成单个 CRDT 类只是工具箱里的零件真正的协同编辑系统至少要有三部分客户端本地状态、服务端状态仓库、传播通道。下面用一个实际架构说明 PHP 在里面怎么干活。4.1 用 WebSocket 分发状态服务端与浏览器角色划分现代 PHP 做 WebSocket 服务端最常见的选择是 Swoole 或 RoadRunner。这两个方案都能维持长连接区别在于Swoole 是 C 扩展性能高但需要专门的编译环境和常驻进程管理RoadRunner 是 Go 写的进程管理器PHP 开发者用起来更舒服写个 PSR-15 风格的应用就行。架构上我会把消息流设计成三段浏览器 A ——(更新操作)—— PHP WebSocket 服务端 PHP WebSocket 服务端 ——(更新消息)—— Redis Pub/Sub 浏览器 B/C ——(订阅到的更新)—— 本地 RGA mergePHP 服务端不直接持有每个客户端的文档状态它只做三件事接收操作、合并到持久化仓库、广播给其他订阅者。客户端本地维护 RGA 实例收到消息后立刻 merge不用等服务端返回最终状态。这带来一个明显的好处即使服务端短暂不可用客户端之间的本地 merge 仍然可以继续只要网络互通。服务端恢复后把持久化快照再合并一遍即可。4.2 全量快照结合增量补丁的同步策略纯状态同步有个问题每次合并都传全量文档打字场景下行文压力不大百字节级别但插入一张大图或者粘贴一万字时全量传输会立刻撑爆带宽。工程上的常规做法是两级同步增量通道每次本地操作完成后把{操作类型, 字符ID, 前驱ID, 内容, 时间戳}这条消息通过 WebSocket 广播。快照通道新用户加入协作时直接拉取服务端保存的最新全量 RGA 状态避免从零开始累积操作日志。快照不必每次操作都写。我在项目里用的是 Redis 保存最近 5 分钟的全量快照每 30 秒写一次 MySQL作为最终持久化。Redis 快照用于新用户秒进MySQL 用于故障恢复。写 MySQL 时不要一行一个字符地存 RGA 节点那个坑我已经踩过了一万字文档就是一万行记录任何一次merge都变成事务里的一万次 UPDATE 或 INSERT性能直接崩。正确做法是把整个 RGA 状态序列化成 JSON或二进制格式存成一个字段合并时整体读出、整体写回。4.3 在 Symfony / Laravel 项目中集成 CRDT 的注意事项如果你用的是 Symfony 或者 Laravel有几个集成注意点不要把这些类混进 Eloquent 模型的基础操作。CRDT 的merge是纯函数你应该在 Repository 层调用而不应该让模型自动merge。序列化时保留节点顺序。PHP JSON 编码数组是有序的但你的 RGA 类在反序列化时最好显式维护nodeId prevId的映射别依赖 JSON 的键序。并发写 MySQL 时要乐观锁。两个 WebSocket 连接同时对一个文档做 merge最后写回的会覆盖先写回的。解决方式在文档表上加 version 字段更新时where version old_version失败就重试 merge。同一个进程内不要持有多个 RGA 实例但共享同一个reset()全局状态否则状态会串。5. 性能实测与我在 PHP 实现中踩过的典型坑写这部分前先说结论纯 PHP 实现 CRDT 做文本协同编辑完全可行瓶颈不在 merge 逻辑而在网络层和持久化层。5.1 先把数据规模压到 PHP 能用9000 字符文本的性能表现我用 PHP 8.3 在本地跑了一组基准测试模拟 3 个终端同时编辑 9000 字符的文档每个终端随机插入 200 次、删除 100 次最后统一 merge。终端本地插入耗时本地删除耗时三方 merge 耗时A0.8ms0.4ms12msB1.1ms0.5ms15msC0.9ms0.4ms14msmerge 的耗时集中在orderedIds()的全量排序上。9000 字符每次重建顺序仍然是 O(n²) 级别但 n 只有 9000 时15 毫秒以内完全能接受。如果文档膨胀到 5 万字建议改用分段索引或鉴权计数索引优化。PHP 数组底层是哈希表查找节点 ID 是 O(1) 的这帮我省了不少事。如果用 Java 的HashMap也一样但 PHP 的数组天然支持字符串键不用额外封装。5.2 PHP 版本要求与 Swoole 的取舍我用的实现依赖readonly属性和构造器属性提升这意味着 PHP 8.1 以上才跑得起来。实际部署时建议 PHP 8.2/8.3一个是性能更好另一个是readonly的类属性在反序列化时的行为更稳定。选 Swoole 还是 RoadRunner取决于团队维护能力。Swoole 的方案是“常驻内存 协程”性能很好但 PHP 传统部署习惯每次请求重新初始化不太一样需要花时间适应。RoadRunner 更接近传统 PHP-FPM 的心智模型但多了一层 Go 进程的运维成本。我最后选了 Swoole因为项目里已经用了它做 WebSocket 推送不必额外引入技术栈。5.3 物理时钟回拨的处理方案这个坑我是在测试 LWW-Register 时踩的。我故意把一台机器的系统时间回拨一小时然后做了个简单的标题修改测试A 机在 10:00 改成“文档 V2”B 机时间在 09:00B 改了“文档 V3”。合并后标题变成了 V2从业务角度看显然是错的。解决方案是统一使用 Lamport 逻辑时钟替代物理时间。每个终端维护一个逻辑时钟lclock本地每次操作时自增同时保存从其他终端收到的最大lclock。比较时用(lclock, nodeId)而不是(物理时间戳, nodeId)。这样改完之后物理时钟回拨不影响逻辑顺序。注意不能完全丢弃物理时间业务层需要“创建时间”这种字段时还得用物理时间。两者分开存物理时间只做展示不参与合并比较。5.4 调试 CRDT 的正确方式状态快照比对最后一个经验分享也是我觉得对线上帮助最大的一条。调试 CRDT 时绝不能直接把文档echo出来人工比对那是看不出来的。我的做法是写一个debugSnapshot()方法输出每个节点的 ID、前驱、时间戳、墓碑状态然后做三件事幂等测试对两个相同副本各 merge 对方一次结果必须完全一致。交换律测试A 先合并 B 与 B 先合并 A最终文本相同。随机并发测试模拟 10 个终端随机插入/删除随机顺序互相 merge最后所有副本的debugSnapshot()输出完全一致。这个自动化测试帮我找出了至少三个隐蔽问题一个是我忘了在本地删除时打墓碑导致的复活 bug一个是 RGA 子节点排序没有加入节点 ID 导致同时间戳时顺序抖动还有一个是 OR-Set 的prune()时机不对导致删除状态丢失。如果你在实现 CRDT建议也把这套测试写进 CI比任何人工评审都有用。上面这些代码和方案放在协同编辑项目里已经跑了两周最深的体会是CRDT 真正难的不是“写一个能收敛的数据结构”而是把它放进业务系统里后仍然能保持纯函数式的合并语义。PHP 在整个链路里扮演的是“权威存储 消息中转站”的角色它不需要做最复杂的计算但必须保证每一份状态的合并都是稳妥的。如果哪天你的协同功能出了灵异 bug别急着怀疑 CRDT 理论先按 5.4 的测试方法查一遍本地副本大概率是某个删除操作没有按墓碑语义处理。