实时协作这笔账,你算过吗

实时协作网络

最近在做的项目评估里,有个需求让我犹豫了很久:产品经理提了一句“能不能加个多人实时编辑”,我当时的反应是“这个功能两周应该能搞定吧”。后来认真想了一下,发现两周这个估算,跟实际需要之间差了大概一个数量级。

这不是我第一次在“实时协作”这个需求上栽跟头。每次有人轻描淡写地说“加个协作功能”,我都会想起之前那些被低估的工时和被遗漏的边界场景。

实时协作的真实代价

很多团队对实时协作的理解停留在“不就是WebSocket嘛”的层面——前端开个长连接,后端广播一下变更,搞定。这个理解大概能覆盖演示demo的复杂度,离生产环境差了十万八千里。

真正的难点不是数据传输,是冲突解决。两个用户在同一时刻修改了同一段内容,谁说了算?如果是文本编辑器,两个人同时删了同一个字,光标位置怎么算?如果是表单,一个人提交了A版本,另一个人提交了B版本,合并策略是什么?

我在之前的一个项目里碰到过这么个场景:一个配置平台,两个运营同时编辑同一个理财产品的展示页面。A把标题从“稳健增利”改成了“安心增利”,B在同一时间把风险等级标签从R2改成了R3。如果简单做“后改覆盖先改”,A的标题修改就丢了。如果做“字段级合并”,看起来解决了问题,但如果A和B改的是同一个嵌套对象里的不同字段呢?这种case一旦出现,排查起来极其痛苦,因为你得在几十层嵌套的JSON里定位到底是哪个字段被谁在什么时间覆盖了。

我当时花了将近两天时间才复现了一个偶发的数据丢失bug,最后发现根因是两个人的修改时间戳相差不到50毫秒,后端的“乐观锁”在那种精度下形同虚设。

OT和CRDT,两种不同的痛

业界解决实时协作问题,主流有两个方向:OT(操作转换)和CRDT(无冲突复制数据类型)。Google Docs用的是OT,Figma走的是类OT路线,Linear则选了CRDT。

OT的思路是中心化——所有操作都经过一个中央服务器做转换。A和B同时修改,服务器把两个人的操作“翻译”成不冲突的版本再分发。好处是算法相对成熟,坏处是强依赖中心节点,断网就废了。

CRDT的思路是去中心化——每个节点自己算,算完自动合并,不需要中央仲裁。好处是天然支持离线编辑,坏处是数据结构本身的开销很大。

我之前在一个项目里评估过Yjs(一个JavaScript CRDT库)。在纯文本场景下表现不错,离线编辑后的合并也很自然。但当协作范围从“一段文本”扩展到“一个复杂表单”的时候,CRDT的数据结构开销开始变得不可忽视。

具体来说,每个协作对象都要维护一套版本向量(version vector),当文档结构复杂到几百个可协作字段的时候,光是元数据就有好几KB。这在纯文本场景下不是问题——一篇几千字的文章,Yjs的文档大小也就几十KB。但如果是一个有几百个字段的金融产品配置表单,每个字段都是协作对象,元数据的膨胀速度远超预期。

更关键的是,CRDT的“删除”操作代价很高。CRDT里的删除不是真删,是标记一个“墓碑”(tombstone)。如果用户频繁删除和添加,墓碑会越积越多,查询性能逐渐下降。你需要定期做垃圾回收(GC),但GC本身又会引入新的一致性问题——你得保证所有节点在同一时间对“哪些墓碑可以清理”达成共识。

我了解到有些团队因此放弃了CRDT转向OT,也有团队坚持CRDT但自己写了一套GC层。坚持的那支队伍花了大概三个月才把GC做稳,中间引入了几个比CRDT本身还难排查的分布式一致性bug。其中一个bug的症状是:在某些特定的网络分区场景下,垃圾回收会误删还没被所有节点同步到的墓碑,导致已经被删除的内容在某个离线节点重新上线后“复活”。这种bug你很难在测试环境复现,因为你需要精确模拟网络分区的时序。

选型没有标准答案

OT和CRDT之间的选择,不是“哪个更先进”的问题,是“你的业务场景能容忍哪种代价”的问题。

如果你做的是纯文本编辑器,OT可能是更务实的选择——Google验证了十几年,算法成熟度高,实现复杂度相对可控。但如果你需要支持离线编辑、或者数据结构比较复杂(比如画布、表格、嵌套表单),CRDT的开销可能值得承受。

还有一种折中方案:核心路径用OT保证一致性,非核心路径(比如评论、标注)用CRDT做最终一致。但这种混合架构的理解成本和维护成本也不低——你得确保两套系统在边界处不打架。

有一个判断标准我觉得比较实用:如果你的产品里,协作是核心卖点(像Figma、Linear那样),那投入资源自己做是值得的。如果协作只是一个“锦上添花”的功能,老老实实用现成的方案,或者干脆别做实时——做个“最后编辑者胜出”加冲突提示,能覆盖95%的场景,开发量只要十分之一。

最容易忽略的坑:协作状态的存储

大部分团队在评估协作方案时,关注点都在“怎么同步”上。但真正让人头疼的,往往是“怎么存”。

协作产生的数据和普通业务数据不一样——它有时间维度。你需要支持“回到某个历史版本”、“查看某次修改的diff”、“回滚到三天前的状态”。这些功能在普通数据库里用快照或者日志就能实现,但一旦跟协作系统耦合,复杂度会指数级上升。

比如CRDT的历史记录不是“一个接一个的版本”,而是一棵操作树。你要回滚到某个状态,不是简单地恢复一个快照,而是要重新计算操作路径。有些操作之间有依赖关系,你不能只撤销其中一个而保留后面的——这跟git的rebase有点像,但操作粒度更细,依赖关系更复杂。

我们后来的做法是把协作层和存储层彻底拆开:协作层只管“当前内存状态”的实时同步,持久化的时候把CRDT文档展平成普通JSON存入PostgreSQL。历史记录走另一条路径,用快照加增量的方式记录,跟协作层解耦。这意味着每次持久化都需要做一次“CRDT文档 → 业务JSON”的转换,有一定性能开销,但好处是两套系统可以独立演进、独立出问题。

审计需求也是一个坑。在金融场景下,每一步配置变更都需要留痕。但CRDT的操作日志对用户来说是不可读的——“在版本向量[3,5,2]处插入了一个YATA节点”这种东西,你没法给合规部门看。所以你还得在CRDT操作和业务语义之间建一层翻译,把底层操作映射成“A用户在9:32把风险等级从R2改为R3”这样的人类可读记录。这层翻译的工作量,比很多人预估的要大得多。

写到最后

回过头看,实时协作这个项目给我最大的教训,不是哪个算法更好,而是容易在错误的地方过度投入。最开始的时候,我花了三周时间评估不同的CRDT库,纠结Automerge和Yjs的合并语义差异。后来发现,对80%的场景来说,这两个库的表现几乎一样。真正花时间的是处理协作状态和业务状态之间的同步问题——比如用户在编辑一个产品卡片的时候,这个卡片恰好被另一个用户删除了,这种边界场景的处理占了后续开发时间的大头。

如果让我重新做一次,我会在选型上少花时间,在“协作层和业务层怎么解耦”上多想几天。这个问题想清楚了,后面能省很多事;想不清楚,用再好的CRDT库也救不了你。说到底,协作层应该做薄,复杂的业务逻辑不应该跟协作状态纠缠在一起。一旦你开始在协作层里做“条件合并”或者“业务语义感知的冲突解决”,就是在给自己挖坑——这个坑的深度,往往要到项目后期才能真正看清。

You voted 2. Total votes: 5

添加新评论