1. 项目背景与方案选型:为什么在 PHP 里碰 CRDT 这种“硬核玩具”
直到今天,仍有很多人觉得 CRDT 是前端协同、白板协作的专属概念,跟 PHP 这种“传统后端”不沾边。可我恰恰是在一个用 PHP 维护了多年的协同编辑系统里,把无冲突复制数据类型变成了每天在跑的核心逻辑。
先把这个词拆开解释一下。CRDT 的意思是说:多台机器各自修改同一份数据副本,不需要中央节点排队审批,最后把各自的改动合并起来,仍然能收敛到同一个结果。用大白话讲,就是“你改你的,我改我的,最后合并,谁都不用覆盖谁”。这在协同编辑里至关重要——两个人在同一段落里改文字,如果后写的一方直接覆盖先写的改动,那产品就没法用了。
那为什么偏偏用 PHP 来做?我知道很多人第一反应是“PHP 不是写网页表单的吗”。实际上,在存量系统里,协同能力往往是后加的。业务代码、账号体系、权限模块全在 PHP 上,如果为了一个协同编辑功能就引入一套完全不同的技术栈,运维成本、团队学习成本、数据打通成本都会翻好几倍。我当时的判断是:先把 CRDT 数据结构在 PHP 里落地,做成一个独立的服务模块,不改变整体架构,这才是对存量项目最温和、最可控的改造方式。
这个方案适合谁?如果你是 PHP 后端工程师,要接手或重构一个带协同编辑功能的老模块;如果你是项目负责人,正在评估不换语言的前提下能不能做多人实时编辑;或者你只是对分布式一致性感兴趣,想知道 CRDT 到底怎么落到具体代码里——这篇文章应该都能给你一个可以直接参考的答案。
1.1 协同编辑场景里的真实需求
先说场景。协同编辑最常见的形态就是多人同时编辑一篇文档、一张表格或一段配置。难点不在于“保存”,而在于“合并”。
举个例子,A 和 B 同时打开同一段文本。A 删掉了第三句话,B 在第二句话后面插了一句话。两个改动本身不冲突,但如果系统只认“最后保存的人”,那么先保存的一方就会被后保存的覆盖。用户看到的结果就是自己刚改的内容莫名其妙消失了。
要解决这个问题,最朴素的思路是给每个改动加时间戳,后保存的赢。但时间戳在分布式环境下并不可靠:两台机器的时钟可能不一致,而且如果 A 改的位置和 B 改的位置恰好重叠,时间戳再新也没用,那叫真冲突,不是排序能解决的。
CRDT 的思路跟这个完全不同。它不比较“谁后改”,而是让每个节点保留足够的修改信息,合并时通过可交换、可结合、幂等的运算,把所有修改都融入最终结果。也就是说,只要两份副本曾经从同一个状态分叉出去,无论它们各自经历了多少次修改,最后合在一起,结果一定是确定的。
在 PHP 项目里落地 CRDT,核心需求其实是三件事:一是能表达“新增”和“删除”两类操作;二是合并规则必须满足数学上的收敛性;三是性能要能扛住日常文档的编辑频率,不能每次按键都引发一次全量合并。
1.2 PHP 选型的争议与合理性
有人会问,为什么不直接用现成的 Node.js 协同框架,或者用数据库的乐观锁解决。乐观锁确实能做协同,但它要求每次提交前都检查版本号,冲突了就报错,让用户手动刷新重试。这在 Web 表单场景没问题,在实时协同编辑场景里体验很差——两个用户同时改一段话,系统弹出“版本冲突”,这产品基本没法交付。
现成的协同框架也不是不能用,但引入一个重框架往往意味着引入一套新的数据协议、一套新的同步机制。如果你的协同编辑只是整个业务系统里的一个子功能,这种代价经常是团队不接受、老板不批预算的。
PHP 实现 CRDT 的合理性体现在三个地方。第一,PHP 数组本身就是一种弱类型的哈希结构,天然适合表达“元素→状态”的映射,比如集合里有哪些元素、墓碑标记的状态是什么。第二,CRDT 的合并操作本质上是数组合并,PHP 的数组语义让实现代码能保持很短的篇幅。第三,在存量 PHP 系统里,你能复用现有的数据库、缓存、队列和权限模块,把 CRDT 模块嵌入到已有业务闭环里,而不是另起炉灶。
1.3 CRDT 的两大流派:为什么选 G-Set 和 LWW-Element-Set
CRDT 大体上分两类:基于操作的和基于状态的。基于操作的 CRDT 像“广播日志”,每个节点把自己执行过的操作发给其他节点,其他节点重放这些操作。基于状态的 CRDT 则像“快递合并”,每个节点保留整个状态,合并时把对方的状态和自己的状态融合。
在 PHP 这种服务端主导的场景里,我偏向用基于状态的 CRDT,因为它的容错性好得多。操作日志依赖消息的完整性,如果中间漏了一条,得靠额外机制补传;状态合并就没有这个问题,即使某个节点已经离线很久,只要它带着旧状态回来,一合并就自动追上最新进度。
具体的数据结构里,G-Set(增长集合)是最简单的一种。它只支持添加元素,不支持删除,所以合并规则就是一个“并集”。虽然看起来太朴素,但它是一切复杂结构的基础。真正用来解决协同编辑问题的是 LWW-Element-Set(Last-Writer-Wins Element Set,最后写入者优先的元素集合),它在 G-Set 的基础上引入“墓碑”机制,让删除操作也变成可合并的。
2. 核心数据结构设计与原理解读
2.1 无冲突复制数据类型的核心不变量
如果说 CRDT 有一根精神支柱,那就是合并操作必须满足三个代数性质:可交换、可结合、幂等。
可交换的意思是 AB = BA,即副本 A 先和副本 B 合并,还是 B 先和 A 合并,结果应该一样。可结合的意思是 (AB)C = A(BC),多副本合并时合并顺序不影响结果。幂等意味着一个副本重复合并两次不会产生副作用。这三个性质合在一起,就保证了“无论各个节点按什么顺序、在什么时间收到对方的改动,最终都会收敛到同一个状态”。
理解了这三个性质,你就能明白为什么某些看起来合理的方案其实是错的。比如,有一种合并策略是“冲突字段取非空值”,这就不满足幂等性——同一字段本身有值,和空值合并后值没变,但重复合并时行为不一致。另一种常见错误是合并时依赖“数组追加顺序”,不同节点接收顺序不同,最终数组里的元素排列就会不同,也不满足可交换性。
所以实现 CRDT 的第一步,不是写代码,而是先在纸上列清楚你的合并操作是否满足这三个性质。不满足,就别叫 CRDT,后面所有关于“无冲突”的承诺都会崩塌。
2.2 G-Set 的合并逻辑与偏序关系
G-Set 的完整定义很简单:一个只增集合,支持 add 接口和 query 接口,合并时做集合的并集。
它为什么能收敛?因为元素一旦加入 G-Set 就永远不会被移除,所以任意两个节点持有的集合,要么包含某个元素,要么不包含,不可能出现“节点 A 删除了它,节点 B 又添加了它”的矛盾。合并就是所有元素取最大集合。
在协同编辑里,G-Set 可以单独用来维护“已经删除的文件 ID”清单——一旦标记删除就不再允许恢复,这样不同节点同时做删除操作,最后合出来的删除清单一定是一致的。但这个结构显然解决不了文档编辑里的核心问题:用户需要撤销删除、恢复文本,也就是说删除不应该是永久的。
2.3 LWW-Element-Set:把“删过的东西”也记下来
LWW-Element-Set 要做的事是:允许一个元素在时间线上反复添加和删除,但最终状态由最后一次操作决定。
它的实现思路是维护两个集合:一个是 add-set,存所有被添加过的元素;另一个是 remove-set,存所有被删除过的元素。判断某个元素是否存在于最终集合里,规则是“add-set 包含它,并且 remove-set 不包含它,或者 add-set 里的时间戳比 remove-set 里的时间戳新”。
换句话说,每个元素在 add-set 里都带着一个“添加时间”,在 remove-set 里带着一个“删除时间”。最终归属,比的是两个时间戳谁更大。删除操作实际上不是把元素真的移除,而是往 remove-set 里塞一条“墓碑”记录,里面的时间戳盖过 add-set 里的旧时间。这样做的好处是:删除本身也被当成一种“添加其他集合”的操作,天然满足 CRDT 的合并性质。
在 PHP 代码里,这种结构可以用两个关联数组表示:key 是元素 ID,value 是时间戳。
3. 在 PHP 中落地实现的完整过程
3.1 基础类骨架:接口与不可变性
我之前实现的时候,第一步先定义接口。接口的目的不是为了形式主义,而是为了后期扩展其他 CRDT 结构预留位置。
<?php interface Crdt { // 合并对方的状态,返回一个新实例,不修改自身 public function merge(self $other): self; // 导出当前状态,用于序列化和传输 public function toArray(): array; // 从数组还原状态 public static function fromArray(array $data): self; }注意 merge 方法的设计原则:不要修改自身,而是返回一个新实例。为什么?因为协同编辑里一份副本经常需要和多个节点合并,如果你原地修改自身,一次合并后原来的状态就丢了,后面再想和其他节点合并时,历史就不完整了。不可变性在这个场景下不是洁癖,而是功能需要。
3.2 实现 G-Set:PHP 数组与引用问题
G-Set 的 PHP 实现非常短,但有几个细节得说清楚。
<?php class GSet implements Crdt { private array $elements = []; public function add(string $element): void { $this->elements[$element] = true; } public function contains(string $element): bool { return isset($this->elements[$element]); } public function merge(self $other): self { $merged = new self(); foreach ($this->elements as $key => $value) { $merged->elements[$key] = true; } foreach ($other->elements as $key => $value) { $merged->elements[$key] = true; } return $merged; } public function toArray(): array { return array_keys($this->elements); } public static function fromArray(array $data): self { $set = new self(); foreach ($data as $item) { $set->elements[$item] = true; } return $set; } }这里我用数组的 key 来存元素,value 统一存 true。为什么不用 PHP 的 array_unique 数组值去重?因为 key 的哈希查找和去重性能远好于 value 的比较。特别是元素类型复杂、数量多的时候,用 key 做集合语义是最自然的。
还有一个细节:G-Set 的合并里,我没有用 array_merge,而是手动遍历两个数组逐一写入。原因是 array_merge 在遇到相同字符串 key 时后一个会覆盖前一个,看起来没问题,但如果未来元素 ID 与数字字符串混淆,行为会有变数。手动遍历写法更直白,也能保证合并逻辑完全由自己掌控。
3.3 实现 LWW-Element-Set:时间戳与墓碑
LWW-Element-Set 的代码核心是两个数组和一个查询规则。
<?php class LwwElementSet implements Crdt { private array $addSet = []; // elementId => timestamp private array $removeSet = []; // elementId => timestamp public function add(string $elementId, int $timestamp): void { $this->addSet[$elementId] = $timestamp; } public function remove(string $elementId, int $timestamp): void { $this->removeSet[$elementId] = $timestamp; } public function contains(string $elementId): bool { if (!isset($this->addSet[$elementId])) { return false; } $addTime = $this->addSet[$elementId]; $removeTime = $this->removeSet[$elementId] ?? -1; return $addTime >= $removeTime; } public function merge(self $other): self { $merged = new self(); foreach ($this->addSet as $id => $ts) { $merged->addSet[$id] = $ts; } foreach ($other->addSet as $id => $ts) { if (!isset($merged->addSet[$id]) || $ts >= $merged->addSet[$id]) { $merged->addSet[$id] = $ts; } } foreach ($this->removeSet as $id => $ts) { $merged->removeSet[$id] = $ts; } foreach ($other->removeSet as $id => $ts) { if (!isset($merged->removeSet[$id]) || $ts >= $merged->removeSet[$id]) { $merged->removeSet[$id] = $ts; } } return $merged; } public function toArray(): array { return [ 'add' => $this->addSet, 'remove' => $this->removeSet, ]; } public static function fromArray(array $data): self { $set = new self(); $set->addSet = $data['add'] ?? []; $set->removeSet = $data['remove'] ?? []; return $set; } }查询规则我写的是addTime >= removeTime,这里有个容易踩的坑:如果添加和删除时间戳恰好相等,到底算存在还是不存在?我的选择是算存在。因为在实际操作里,先有 add 再删 remove,如果两条操作落在同一毫秒,优先保留元素的结果对用户更宽容,不至于丢失内容。如果你的业务要求相反,就改成>,但必须所有节点统一,否则合并会出乱子。
3.4 序列化与版本同步
协同编辑系统里,服务端经常需要把文档状态发给新加入的协作者。这时候序列化格式一定要稳定,不能在版本升级后破坏旧数据。
我用的方案是把结构序列化成 JSON,格式固定为:
{ "add": {"element-1": 1690000000123, "element-2": 1690000000124}, "remove": {"element-2": 1690000000125} }这里 element ID 必须是字符串,时间戳必须是整数。为什么强调整数?因为浮点数在 JSON 的序列化和反序列化过程中有精度损失风险,一旦毫秒级时间戳被转成浮点数,后面合并时比大小就可能出错。
4. 协同编辑场景的接入与字段级合并
4.1 文档级 CRDT 与字段级 CRDT 的选择
拿到 LWW-Element-Set 之后,第一个问题是:拿它管理什么粒度?是把整篇文档当作 CRDT,还是把文档里的每个字段分别管理?
我的答案是,看你的冲突弹窗需求。如果整篇文档是一个 CRDT,那每一次按键都会触发一次状态合并,任何两个节点之间的微小时间差都可能造成大范围的“文本块重排”,不仔细控制很容易出现用户感知的跳动。
所以我更推荐字段级 CRDT:文档被拆成标题、正文段落、评论列表、标签数组等字段,每个字段独立维护 LWW-Element-Set 或 G-Set。这样做的好处是,用户在标题区打字时,只合并标题字段的状态,不会干扰正文区正在进行的输入。现代协同编辑的体验大多建立在“小粒度数据同步”之上,字段级明显更细腻。
4.2 合并冲突的决定性规则
即使有了 LWW-Element-Set,也不可能避免所有冲突。真实冲突是什么?是两个人同时修改同一个字段的同一个位置,而且内容和意图互不相容,比如一个人在写“我们明天开会”,另一个人写了一篇完全不相干的新闻稿。这类冲突 CRDT 无法解决,也不应该强行用时间戳裁决。
正确的做法是:CRDT 保证底层不会丢数据、不会状态错乱,但不承诺“自动理解语义”。我在项目里设定的规则是:文本类字段把冲突保留为衍生版本,附件类字段用时间戳决定最终保留项。也就是说,CRDT 管数据收敛,业务规则管人怎么看待冲突。
4.3 与前端协同框架的配合方式
这个 PHP CRDT 模块并不是完全替代前端的协同逻辑,而是作为后端权威状态。前端可以自己做本地编辑、本地合并、体验优化,但最终落库到服务端时,要把增量操作发过来,由 PHP 模块合并到持久化状态里。
我在设计时给增量操作定义了一个最小接口:操作类型(add/remove)、元素 ID、时间戳、发生节点 ID。前端每次提交都带上这四个字段。PHP 端收到后直接喂给 LWW-Element-Set。这种设计还带来一个好处:新设备加入协作时,只要把合并后的整个状态发给它,它就自动补完了所有历史“上下文”,不需要像操作日志那样重放成千上万条记录。
5. 生产环境实战:踩坑与性能优化
5.1 PHP 内存引用陷阱
第一个让我印象深刻的坑,是 PHP 数组的引用行为。表面上看 PHP 的变量赋值是写时复制,数组赋给另一个变量后修改一个,另一个不变。但如果你在 foreach 循环里对数组元素取引用,然后继续复用变量,就可能在下一轮循环里意外改掉上一轮的数据。
在实现合并逻辑时,我最初写的合并函数是逐个数组赋值,代码虽然长但逻辑很安全。后来为了追求“简洁”,改用引用遍历,结果在并发请求压测时,出现了两个合并结果互相污染的情况。这个问题的本质不是 CRDT 算法错了,而是 PHP 写引用代码太激进了。我的建议是:CRDT 合并函数内部,所有数组都采用完整的复制再赋值,不要为了省几行代码用引用。
5.2 大文档性能问题与增量同步
第二个问题发生在文档变大之后。一个文档有几千个段落,每个段落又有几十个元素,全量合并一次的时间飙到了几百毫秒。协同编辑的合并频率是每秒多次,几百毫秒是绝对不能接受的。
优化的突破口很明确:不合并不需要合并的状态。服务端缓存了一份完整的 CRDT 状态,客户端每次只发送增量改动。服务端用增量更新自己的状态,同时生成一个轻量的“差异包”广播给其他在线客户端。只有新加入的节点才需要全量状态。
为了做增量,我给每个元素 ID 增加了“最后修改时间”索引。收到增量时先比较本地时间戳,如果对方记录旧于本地,就直接丢弃,避免无意义的数组写入。实测下来,增量合并一次能控制在几毫秒以内,全量合并只在节点加入时触发一次。
5.3 单调钟比墙上时钟可靠
CRDT 的 LWW 规则严重依赖时间戳,但服务器的墙上时钟(NTP 对时后的时间)存在回拨风险。一旦某台机器的时钟倒退,它新产生的操作时间戳可能比旧操作还小,合并时老操作反而赢了新操作。
解决方案是引入单调时钟或逻辑时钟。我用了混合逻辑时钟:物理时间戳在前,节点 ID 在后,同节点内单调递增。这样即使物理时间发生了回拨,逻辑部分仍然能保证同节点产生的新操作时间戳一定大于旧操作。在 PHP 里实现很简单,每次操作前比较当前物理时间和上次时间,取较大值加一。
6. 常见问题与排查技巧实录
6.1 时间戳相等怎么办
这是最容易发生怪异现象的地方。两个节点恰好同时操作同一个元素,且时间戳完全相同,合并规则怎么写都行,但必须全局一致。
我遇到过的情况是:A 节点认为元素存在,B 节点认为不存在,两边各执一词,前端表现就是同一个字段有人看到内容有人看不到。排查到最后发现,就是 add 和 remove 时间戳相等时,两个节点实现了不同的比较规则,一个用了>,一个用了>=。
排查方法很直接:把集成测试里所有时间戳全部固定为同一个值,跑一遍合并用例,看结果是否符合预期。如果不同节点给出不同结果,就能立刻复现。修正方式上面说过,全系统统一定义比较符号。
6.2 合并后 ID 冲突
第二个常见问题是元素 ID 的生成策略。如果两个节点各自生成了一模一样的元素 ID,合并时就会被当成同一个元素处理,但实际它们是两个不同的文本片段。这会导致删除一个时另一个也被误伤。
我在项目里强制元素 ID 采用“节点 ID + 自增序号 + 随机数”的组合方式。节点 ID 保证机器维度不重复,自增序号保证单机内不重复,随机数防止重启或克隆虚拟机后自增序号从零开始。这套方案虽然 ID 有点长,但彻底杜绝了跨节点冲突。
6.3 JSON 编解码精度丢失
第三个问题来自 PHP 的 JSON 处理。PHP 在处理大整数 JSON 时,如果数字超过 64 位有符号整数范围,会默认转成浮点数。我的时间戳虽然不会超过 64 位,但曾经因为某段代码把时间戳做了乘法再存入数组,导致溢出后出现精度损失。
另外,PHP 的 json_decode 默认把数字解析成 int 或 float。如果数组 key 是数字字符串,在某些场景下会被自动转成整数,序列表里的元素 ID 就会悄悄变化。解决方式是所有元素 ID 在进出 JSON 时都做严格的字符串校验,保持 key 的字符串类型不被 PHP 自动转换。
这类问题很难靠看代码发现,我把排查方法总结成了一个速查表:
| 症状 | 可能原因 | 检查方式 |
|---|---|---|
| 合并后元素消失 | 时间戳比较符号不一致 | 固定时间戳跑用例 |
| 同一字段两边结果不同 | add/remove 集合被引用污染 | 检查合并函数是否有引用赋值 |
| 删除一个内容影响另一个 | 元素 ID 重复 | 检查 ID 生成策略 |
| 大文档合并越来越慢 | 全量合并频繁触发 | 看日志中 merge 调用频率 |
| 序列化后数据异常 | JSON 数字精度丢失 | 打断点看 toArray 输出类型 |
| 某节点时钟回拨导致旧覆盖新 | 使用了墙上时钟 | 替换为混合逻辑时钟 |
结语:一点个人体会
这套 PHP CRDT 方案上线后,我最大的感受是:真正难的不是算法本身,而是把算法嵌进一个千疮百孔的旧业务系统时,还能保持它的数学性质不被破坏。G-Set 和 LWW-Element-Set 的核心代码加起来不到两百行,可这两百行背后的设计决策,包括不可变合并、统一的比较规则、严格的 ID 策略,每一个都在后面的生产事故里被反复验证过。
如果你也在用 PHP 做协同类功能,我建议不要一上来就铺开做复杂结构,先从字段级 LWW-Element-Set 入手,把 G-Set 作为辅助集合处理那些“只增不删”的元数据,比如操作日志、已读标记、成员列表。等这套模型稳定了,再考虑往字符级别的 CRDT 扩展。最后再提一个实操小技巧:给所有 CRDT 合并函数加上输入参数的合法性校验,哪怕前端已经校验过,服务端也要查一遍,分布式环境里你永远不知道对方节点什么时候会发来一批畸形数据。这种校验代码不复杂,但在关键时刻能帮你省掉好几个小时的定位时间。