3.3 冲突解决:OT 与 CRDT 取舍

多人同时画,两笔几乎同时落在相邻区域,到达顺序又随机——画面会闪、会互相盖。这是协同白板最硬的一关。业界两条路:OT(操作转换) 和 CRDT(无冲突复制数据类型)。本篇讲清两者怎么并、各自代价,以及白板到底选哪条。

冲突到底出在哪

白板多数操作是「各画各的」(追加型),天然不冲突;真正会撞的是「同一区域 / 同一对象」的修改型操作: | 操作 | 冲突强度 | 说明 | |---|---|---| | 各画各的笔 | 低 | 区域不交叠 | | 同时擦同一片 | 高 | 擦除相互覆盖 | | 同时移同一组 | 高 | 位置抢 | | 改同一对象属性 | 高 | 样式撞 |

合并规则不统一,追加型也能见缝插针出错,所以要一套「可交换」的合并语义。

OT 怎么并

OT 把「两个并发操作」转换成「在对方之后仍然成立的操作」,核心是 transform(a, b): | 步骤 | 动作 | |---|---| | 1 | 记录本地操作 a 与已应用的最新态 | | 2 | 收到别人的 b(并发) | | 3 | 计算 transform(a, b),把 a 转成 b 之后还成立 | | 4 | 应用 b、再应用转好的 a |

顺序不同、结果一致(commutativity),画面收敛。代价是 transform 逻辑复杂,操作间要两两可转换,白板「擦除 + 移动 + 改属性」组合一多,转换矩阵很疼。

CRDT 怎么并

CRDT 用「天生可交换」的数据结构:每个元素带因果 / 版本号,合并时按版本取最大、冲突自动消解,无需服务端定序: | 特性 | 说明 | |---|---| | 无需 transform | 合并就是取因果最大 | | 可离线 | 断网各改,恢复自动并 | | 有「删除」难题 | 软删除、墓碑要设计 |

白板常用「每个笔带 Lamport 时钟 / 向量时钟」,两笔同区时按时钟合并,天然收敛。代价是状态偏大(要存版本 / 墓碑),且「移动 / 撤销」要额外建模。

白板怎么选

维度 OT CRDT
复杂度 transform 多,易错 合并简单,建模难
服务端 要定序 可无中心
离线 弱 强
白板贴合度 高(笔多追加型) 中高(需建模移动/撤)

白板多追加、少量修改,OT 更省脑、更贴;若要做「离线各画、联网再并」,CRDT 更稳。务实做法:主用服务端定序 + 按笔合并(轻量 OT),对少数修改型操作补版本号,不硬上完整 CRDT。

踩坑提示

最坑是「以为各画各的不冲突」:两个笔虽不交叠,但「图层顺序」「擦除掩膜」是全局的,A 在 B 之前画决定了谁盖谁,漏了图层序就会出现「同区两笔反复闪烁」。合并必须把图层 / 顺序纳入因果,而不只看坐标。

小结

白板冲突以追加为主、修改为辅:用「服务端定序 + 按笔合并」打底,对修改型补版本号;要离线再上 CRDT。核心是「画面必须收敛到同一块板」,OT 与 CRDT 只是两条到达收敛的路。

transform 到底转换什么

白板上最典型的并发是「擦除遇上移动」:A 正在擦左半区,B 同时把这组图形往右移。OT 的做法是:B 的移动操作在「A 已擦」的状态上重新计算目标区域——转换的不是意图,而是坐标参照系。

并发组合 转换内容 结果
擦除 × 移动 移动目标区域 各自仍成立
改属性 × 改属性 后者覆盖前者 语义取最后
画 × 画 无需转换 均为追加
撤 × 撤 各撤各的 需限定归属

工程上不必自己写 transform:ShareDB 等成熟库已覆盖常见操作类型,自写转换矩阵是「看着简单、一上线全是坑」的重灾区。

CRDT 的版本要怎么带

白板最小的 CRDT 化改造:给每个笔触加一个 (actor, counter) 的 Lamport 时钟。

{ strokeId, actor, counter: 42, pts, style, tombstone: false }

合并规则朴素到感人:同 strokeId 取 counter 大者;删除不是真删,而是置 tombstone。代价就是墓碑会一直占地方,需要周期性压缩;而压缩又必须确信「再没有更老的副本会复活」——这在长周期离线场景下很难,所以多数白板只做到「软删除 + 定期压缩」。

操作 CRDT 表示 合并方式
画一笔 新对象,counter+1 直接并入
擦一片 命中对象置墓碑 墓碑胜
移动一组 新增 move 记录 按时钟取后者

混合方案怎么落地

务实路线(多数团队的选择): 1. 笔触类操作:服务端定序 + 按 seq 追加,根本不当冲突; 2. 对象属性修改:带版本号,客户端版本旧了直接拒绝并回拉; 3. 擦除与图层:把图层顺序纳入服务端定序的一部分,不放在客户端各自决定; 4. 只有真要做离线编辑时,才引入 CRDT 库(如 Yjs)处理局部对象。

这样九成的流量走最便宜的路径,只有少数修改型操作付出冲突成本。IM 场景的排序与补齐实践,可参考 Go IM 实战;分布式取舍的更多思路见 Go 微服务治理。

图层顺序怎么定

白板是「后来者居上」的视觉模型:后画的笔盖在先画的笔上。并发时谁先谁后?答案是服务端 seq 就是图层序——笔画到达服务端的顺序,就是它压在别人头上的顺序。所以图层根本不需要单独维护,它是定序的副产品。一旦客户端自己算图层(比如用本地 ts),同区两笔就会在各端来回闪烁,因为每个客户端看到的先后都不一样。

做法 依据 画面表现
服务端定序 全局 seq 各端一致
客户端 ts 排序 不可靠时钟 反复闪烁
矢量时钟排序 可离线 一致但状态重

为什么白板最终选了服务端定序

回头看白板的特点:操作以追加为主、实时在线为主、离线编辑是少数场景。服务端定序用最小的复杂度换来「强一致的画面」,代价只是单点依赖——服务端挂了就停摆。这个代价用多节点 + 日志主从即可兜住,而 CRDT 的复杂度是写在每行代码里的。真到了万人离线编辑的规模(比如离线文档),才值得把局部对象改造为 CRDT。分布式一致性的更多权衡,见 微服务治理实战;数据库层的顺序与分片代价,可参考 MySQL 分库分表实战。

实践环境可以蹲一个 腾讯云云产品合集特惠,多实例环境下跑并发场景更直观。

悲观方案:锁住冲突区

冲突处理的另一条捷径是「根本不让冲突发生」:对被编辑的对象加短时锁(如 2 秒),期间他人修改直接拒绝并提示「正在被编辑」。代价是手感僵、协作感弱,但对「改属性」这类低频操作极好用——配上前端提示,用户几乎无感。

方案 一致性 手感 复杂度
悲观锁 强 僵 低
版本号 强 弹 中
OT / CRDT 最终一致 顺 高

组合拳:笔触靠定序、改属性靠版本号、抢手对象上短锁。三层叠加,冲突就基本成了纸老虎。并发抢锁的更多玩法,可参考 PHP 秒杀实战,库存扣减和白板抢锁是同一类问题。

客户端怎么看到合并结果

合并发生在服务端,但用户体感在客户端。做法是「乐观本地 + 服务端回填」:本地先按自己的意图渲染(零延迟),服务端定序后若有出入,回一段「修正事件」让客户端局部重画。用户绝大多数时候感知不到修正——只有两笔真的撞在同一区域时,才会看到对方那笔「瞬间压上来」。这个微妙体验,恰恰是实时协作白板区别于「你画完我才能画」的核心。

延伸阅读