实时协作规格:冲突解决策略
我审过的每一份实时协作规格,demo 都做得漂漂亮亮,边界情况却一塌糊涂。两个用户在同一份文档里打字,配上千兆以太网的笔记本,看起来像魔法。真正让规格值回票价的场景是:wifi 忽断忽续的航班上、编辑过程中权限被撤销、错的人在错的时间按下了撤销键。
内容整理说明
复查日期:2026-05-06。本文已重新纳入公开索引路径,作为 Spec-First 开发 Hub 的延伸阅读。我们补齐了专题路径、站内链接和可索引元数据,便于搜索引擎和读者理解它与核心主题的关系。
先选冲突模型,再选数据库
整份规格的走向,取决于并发编辑如何收敛这件事。从下面四种里挑一个,并把取舍写下来:
- Operational Transform(OT)。服务端权威,针对并发 op 逐一做 transform。线上载荷更小,但 transform 函数正是 bug 的温床。Google Docs 至今跑在 OT 上,是因为它起步最早,而把一个能跑的引擎推倒重写属于职业自杀。
- CRDT(Yjs、Automerge、Loro)。对端到端友好,收敛性由数学给你保证。代价是元数据开销(tombstone、向量时钟)以及光标模型不太直观。
- 服务端按段落或单元格加锁。正确性一目了然,用在长文里体验极差,用在表格里却没什么问题。Figma 就采用按对象加锁,因为用户很少抢同一个矩形。
- 保存时三方合并。Git 式。对于协作者不多的长文写作,这种方式坦诚;但如果你对外宣传过 "real-time",它就是自欺欺人。
一个不讨人喜欢但真实的结论:对于一个小团队的新产品,用现成库跑 CRDT 每次都完胜手写 OT。OT 的调试税是实打实的,你也没有 Google 那么多年可以慢慢还。
Presence 是另一个系统,单独写规格
Presence(谁在线、光标、选区、头像颜色)看起来像文档状态,行为却完全不是一回事。它短暂、可丢、高频、对隐私敏感。把它放在独立的通道里:
- 永不持久化。超过 30 秒的 presence 记录就是垃圾。
- 客户端节流。光标最多 20 次/秒,服务端聚合到 10 次/秒再广播。
- 日志里剔除 PII。user id 没问题,姓名加上选区文本就是一起潜在的泄露。
- 独立降级。presence 挂了,文档通道还能继续编辑,反之亦然。
快照、op log 与压缩
规范形式是快照加上其后的 op。把调度策略写进规格,而不是 wiki,因为运维在压力下要用到:
- 每 500 个 op 或持续编辑 10 分钟,以先到者为准,做一次快照。
- 最近 30 天的 op 不压缩,保留用于审计和撤销深度。更老的 op 并入下一次快照。
- 空闲 24 小时的文档做一次快照,op log 压缩到零。光这一条就让我们的热存储下降了 40%。
离线:分钟、小时和天,是三个不同的问题
"客户端掉线了"不是一条需求。它是三条,规格需要逐一回答:
- 分钟级。op 缓存在内存里,重连后按服务端当前向量重放。服务端接受或 transform。这是最容易的情形,也是大多数 demo 唯一覆盖的场景。
- 小时级。把 op 缓冲持久化到 IndexedDB。重连时拉取新的服务端快照,把本地 op rebase 上去,向用户展示哪些编辑 rebase 成功、哪些因为目标不再存在而被拒。
- 天级。本地基准快照比服务端保留最久的 op 还要老。你 rebase 不了,只能三方合并。给用户一个 diff 视图,让他自己当合并作者。不要默默丢掉他的工作,也不要默默覆盖服务端。
一段具体的段落例子
Alice 和 Bob 同时打开 "The quick brown fox jumps over the lazy dog"。Alice 的光标在 "brown" 后,她输入 " and fast"。Bob 选中 "lazy" 并替换为 "sleeping"。两个 op 在 40ms 内先后到达服务端。
在 OT 下,服务端按到达顺序排序,把 Bob 的 op 对 Alice 的插入做 transform(删除范围后移 9 个字符),两端收敛到 "The quick brown and fast fox jumps over the sleeping dog"。在 CRDT 下,每个字符都有稳定 id,插入锚定在 "brown" 的 'n' 之后,替换针对具体的 "lazy" 字符,收敛自动完成。在锁模型下,谁先拿到锁谁赢。把这个具体例子原封不动写进规格,这样评审吵架吵的是行为而不是流程图。
撤销:产品里最难的 UX 决策
我没见过哪个团队第一次就把撤销做对。问题的关键是:你弹的是谁的栈?
- 本地撤销。Ctrl+Z 只撤销自己的 op。符合用户直觉。要求每个 op 相对当前文档可独立求逆,这在 op 已经被 transform 过之后并不好办。
- 全局撤销。撤销文档最后一个 op,不分作者。实现简单。Alice 第一次撤掉 Bob 的段落,信任就塌了。
- 带归属的会话级撤销。Google Docs 的做法。默认本地,但建立在被撤销 op 之上的 op 必须 rebase 或丢弃。丢弃策略要在规格里写清楚。
选本地撤销。写下:当本地撤销命中另一位用户后续修改过的内容时,会发生什么。那一段,才是评审里应该吵起来的地方。
会话中权限变更与传输协议
连接时做一次权限检查远远不够。当管理员在 Bob 有三个未发送 op 时撤销他的编辑权限,会发生什么?我的默认做法是:服务端以一个有类型的错误拒绝;客户端显示不可关闭的横幅;本地副本切成只读;未发送的 op 导出成可下载的文件,不让工作默默丢失。
传输层面,2026 年默认选 WebSocket 加二进制帧格式(CBOR 或 MessagePack)。只读查看用 SSE 完全够用。长轮询留给那些会拦截 WebSocket 升级的企业代理,每季度测一次,不然一定会腐烂。一条不可妥协:每条消息都带一个单调递增的客户端序号和一个服务端分配的 commit id,重连时对账。没有这两个数字,凌晨两点的不同步你根本没法 debug。
可观测性与可测试性
真正能告诉你系统健康与否的指标:
- 每文档每秒 op 数,p50 与 p99。p99 超过 200 ops/秒的文档,几乎必定是脚本失控。
- transform 冲突率。冲突率上升往往先于用户可见的损坏。
- p99 apply 延迟,从客户端发送到客户端 ack,端到端,而不只是服务端。
- 每会话重连 rebase 次数。出现尖峰意味着你的通道切分漏了。
测试上,我坚持要一个确定性模拟器:按随机种子调度 N 个虚拟客户端的 op,配合脚本化的网络分区。每一个线上 bug 都会回到一个种子,变成一个失败用例,再也不会上线。如果并发编辑的 bug 不能从种子里复现,那你根本不是一个可测试的系统。
验收标准
- Given Alice and Bob are editing the same paragraph When both submit overlapping ops within 50ms Then both clients converge to identical document state within 200ms And the server op log records both ops with monotonic commit ids - Given Bob has been offline for 90 minutes with 12 buffered ops When Bob reconnects and the server snapshot has advanced Then the client rebases Bob's ops onto the new base And ops whose targets no longer exist are shown to Bob for review, not dropped - Given an admin revokes Bob's edit permission while Bob has unsent ops When Bob's next op reaches the server Then the server responds with PERMISSION_REVOKED And Bob's client locks to read-only and offers to export buffered ops as a file
冲突解决要写到字段级
“使用 CRDT”不是规格。真正会吵架的是字段:标题、正文、权限、评论、光标、附件各自怎么合并。正文可以自动合并,权限变更通常不能。把这些混成一个策略,迟早会丢数据。
Conflict policy: - document body: CRDT merge, preserve both edits - title: last writer wins, show rename event in activity log - permissions: server-authoritative, stale client write rejected - comments: append-only, client_id used for dedupe - attachments: no merge, conflict creates review task - evidence: conflict fixture replays two offline clients in opposite order
边界:实时协作不是所有字段都实时。权限、计费状态和删除操作更适合强一致服务端决策。
给 QA 一组可重放操作
实时协作不能只靠手工乱点。规格里要放一组可重放操作:client A 离线编辑正文,client B 在线改标题,admin 同时撤销权限,然后按两种顺序同步。测试要断言最终字段、活动日志、冲突状态和用户可见提示。
我会给每个字段补 owner:正文归协作引擎,权限归服务端 ACL,附件归文件服务,评论归讨论模块。测试断言状态、事件、数据库版本号和 UI 提示。没有 owner 的冲突规则,最后没人敢改代码。
冲突测试还要记录版本字段:base_version、client_version、server_version 和 resolved_version。API 返回里最好带 conflict_id,UI 用它显示恢复状态,日志用它追踪 owner。这样代码 review 时能看出每个状态从哪里来,而不是只看到“合并成功”。
专题阅读路径
先读主题 Hub,再用下面的相邻文章和模板把这篇内容放进完整工作流。
继续阅读
编辑说明与免责声明
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面