实时协作规格:冲突解决策略

实时协作规格:冲突解决策略
Spec Coding 编辑部 · Spec-First 工程实践内容

我审过的每一份实时协作规格,demo 都做得漂漂亮亮,边界情况却一塌糊涂。两个用户在同一份文档里打字,配上千兆以太网的笔记本,看起来像魔法。真正让规格值回票价的场景是:wifi 忽断忽续的航班上、编辑过程中权限被撤销、错的人在错的时间按下了撤销键。

发布于 2026-03-09 · ✓ 已更新 2026-05-06 · 阅读约 7 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

内容整理说明

复查日期:2026-05-06。本文已重新纳入公开索引路径,作为 Spec-First 开发 Hub 的延伸阅读。我们补齐了专题路径、站内链接和可索引元数据,便于搜索引擎和读者理解它与核心主题的关系。

先选冲突模型,再选数据库

整份规格的走向,取决于并发编辑如何收敛这件事。从下面四种里挑一个,并把取舍写下来:

一个不讨人喜欢但真实的结论:对于一个小团队的新产品,用现成库跑 CRDT 每次都完胜手写 OT。OT 的调试税是实打实的,你也没有 Google 那么多年可以慢慢还。

Presence 是另一个系统,单独写规格

Presence(谁在线、光标、选区、头像颜色)看起来像文档状态,行为却完全不是一回事。它短暂、可丢、高频、对隐私敏感。把它放在独立的通道里:

快照、op log 与压缩

规范形式是快照加上其后的 op。把调度策略写进规格,而不是 wiki,因为运维在压力下要用到:

离线:分钟、小时和天,是三个不同的问题

"客户端掉线了"不是一条需求。它是三条,规格需要逐一回答:

一段具体的段落例子

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 决策

我没见过哪个团队第一次就把撤销做对。问题的关键是:你弹的是谁的栈?

选本地撤销。写下:当本地撤销命中另一位用户后续修改过的内容时,会发生什么。那一段,才是评审里应该吵起来的地方。

会话中权限变更与传输协议

连接时做一次权限检查远远不够。当管理员在 Bob 有三个未发送 op 时撤销他的编辑权限,会发生什么?我的默认做法是:服务端以一个有类型的错误拒绝;客户端显示不可关闭的横幅;本地副本切成只读;未发送的 op 导出成可下载的文件,不让工作默默丢失。

传输层面,2026 年默认选 WebSocket 加二进制帧格式(CBOR 或 MessagePack)。只读查看用 SSE 完全够用。长轮询留给那些会拦截 WebSocket 升级的企业代理,每季度测一次,不然一定会腐烂。一条不可妥协:每条消息都带一个单调递增的客户端序号和一个服务端分配的 commit id,重连时对账。没有这两个数字,凌晨两点的不同步你根本没法 debug。

可观测性与可测试性

真正能告诉你系统健康与否的指标:

测试上,我坚持要一个确定性模拟器:按随机种子调度 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 时能看出每个状态从哪里来,而不是只看到“合并成功”。

关键词:operational transform · CRDT · 实时协作规格 · presence 协议 · 离线同步 · 协作撤销

专题阅读路径

先读主题 Hub,再用下面的相邻文章和模板把这篇内容放进完整工作流。

编辑说明与免责声明

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。