系统设计 E01:协同编辑的收敛删除与光标
两个人离线在同一个字符前输入,重新联网后看到相同文本,只能证明副本收敛;它不能证明插入位置符合各自意图,也不能证明光标不会跳到另一段。多人编辑需要分别设计文本合并、删除语义与交互定位。
本篇讨论小型纯文本协同编辑,支持离线插入、删除和再次同步;不实现富文本、表格公式、权限撤销后的离线内容回收或生产级编辑器。关键不变量是同一有效操作集合产生相同可见文本、重复操作不重复插字、已删除字符不会因迟到插入消息复活。体验目标另外定义:本地键入立即回显,在线操作传播拟定 p99 小于 300 ms,离线恢复后明确显示同步状态。
从位置冲突理解 OT 与 CRDT
假设文档是 AB,两名作者都以整数偏移 1 插入 X、Y。一端先 X 后 Y,另一端先 Y 后 X,如果直接执行相同偏移,结果可能为 AYXB 与 AXYB。冲突不在网络丢消息,而在操作引用的“位置 1”依赖各端当时的文档版本。
操作转换 OT 可以根据已经应用的并发操作转换位置;常见集中式安排由服务器确定顺序,客户端对尚未确认的本地操作执行相应转换。正确性依赖转换函数、上下文和协议,不是简单把远端位置加一。CRDT 则把合并所需身份和关系编码进数据结构,使满足特定条件的更新能够收敛。二者都需要完整算法与协议,不能从名称推导出“离线体验一定更好”。Kleppmann 与 Beresford 的 JSON CRDT 论文可作为理解身份、因果关系和离线合并的研究入口;本文的字符树只是更小的教学模型。
候选 API 为 POST /documents/{id}/operations,操作带 actor_id, counter, parent_id, kind, value;同步 GET /documents/{id}/operations?after=cursor 返回稳定日志范围。身份 ID 可用 (actor_id, counter),同一 ID 对应不同内容必须拒绝。服务器仍要做权限、文档大小、操作长度与速率校验,CRDT 收敛不能代替信任边界。
一个可以完整说明的有限算法
实验采用不可变父节点的插入树与只增删除集合。插入操作保存 id → (parent_id, character);所有字符最初挂在 ROOT 或已知字符下。同一父节点的子节点按 ID 字典序排序,深度优先遍历输出文本。删除只把目标 ID 放入集合,不删除其子树;被删节点不输出字符,但继续遍历它的后代。
模型要求 ID 全局唯一、父关系无环、所有缺失父节点最终到达、节点内容不可改变。操作先后顺序只改变何时能显示完整树,不改变最终节点集合。集合并集与固定遍历顺序使最终结果一致;删除集合允许删除消息早于插入消息到达。实验不实现网络上的因果缓存、恶意循环检测或垃圾回收,因此不能直接作为生产服务。
flowchart TD
R[ROOT] -->|按 ID 排序| A[a1 字符 A 已删除]
R -->|按 ID 排序| B[b1 字符 B]
A -->|父关系仍保留| X[a2 字符 x]
A -.->|删除集合包含 a1| T[墓碑]
X -->|深度优先可见输出| V[最终文本 xB]
B -->|继续遍历| V
固定操作为插入 a1=A、插入 b1=B、在 a1 后插入 a2=x、删除 a1。四项操作全部二十四种顺序都得到 xB;重复投递整个集合仍得到同一状态。这里的顺序规则刻意简单,按作者 ID 排序的兄弟节点未必符合自然打字的连续性,不能把确定性排序当作用户意图正确性的证明。
若两个作者分别输入连续单词,不同序列 CRDT 对并发插入的排列规则可能不同;需要根据所选算法检验是否交错、怎样处理粘贴和撤销。本模型没有承诺 RGA、Yjs 或 Automerge 的兼容语义,也未使用它们的实现。读者可通过修改固定输入探索差异,但不能把四操作结果推广为所有编辑场景都已验证。
删除和光标不能只保存整数偏移
光标保存“位于 a1 之后”比“位置 1”更能抵抗并发插入,但 a1 被删除后仍要指定恢复规则。本文约定光标定位到该节点第一个可见后代之前;若没有后代,则定位到确定遍历顺序中的后继,末尾则文档结束。实验只验证本例 a1 删除后解析为 a2 之前,未实现全套选择区间转换。
该约定会让光标留在删除位置附近,但不一定符合所有编辑器习惯。选择范围还需要分别处理起点、终点和插入亲和性;否则用户选中一段文字期间远端插入,选区可能扩大或缩小。撤销也不是从共享历史里删除一条操作:他人可能已引用该节点,撤销需要新的语义操作及权限检查。
sequenceDiagram
participant A as 离线副本甲
participant B as 离线副本乙
participant S as 同步日志
A->>A: 插入 a1 后插入 a2
B->>B: 插入 b1
A->>A: 删除 a1,保留父关系
A->>S: 节点与删除集合
B->>S: 节点 b1
S-->>B: 删除 a1 先到,插入后到
S-->>A: 重复传输 b1
Note over A,B: 最终节点集合相同,均渲染 xB
元数据增长会改变架构选择
教学假设一万份同时活跃文档,每份平均两个编辑者,每人每秒两个操作,入口为四万 op/s;每条操作按 80 B,原始吞吐 3.2 MB/s。一天逻辑日志约 276.48 GB,两份约 552.96 GB,协议与索引另算。实际活跃时段通常并非全天,预算应乘真实活跃占空比,不能把“在线人数”当成全天持续键入。
单文档十万字符,若可见字符均按一字节而节点元数据 80 B,节点开销约 8 MB,远大于 0.1 MB 文本;中文编码和真实对象布局会改变数值。删除不释放墓碑,反复插删会继续增长。快照与日志裁剪需要证明所有可能重连副本不再引用被丢弃标识,或要求旧副本重新基于新快照同步;仅因为当前在线客户端都确认就清理,可能破坏离线设备恢复。
若离线窗口从一天扩大到一年,保留元数据、版本升级和权限撤销的负担同时增加。可以选择集中服务器 OT 加有限离线窗口,或成熟 CRDT 实现与明确的快照世代协议。前者服务端顺序更集中,后者身份与合并元数据更重;选型依据应是离线需求、文档规模和编辑语义,而不是“是否无锁”这样的单一标签。
验证收敛之后还要验证体验
运行 python3 examples/system-design/labs/E01/run.py,断言二十四种排列最终为 xB、重复投递幂等、删除 a1 后 a2 仍可见,并记录光标映射约定。负例采用纯整数偏移插入,AB 与 BA 两种到达顺序产生 BA 与 AB,不收敛。结果、解释、Python 版本和源码哈希保存在 examples/system-design/evidence/E01/。
这是有限节点集合的穷举检查,既不是一般 CRDT 定理证明,也没有真实浏览器编辑器。发布前仍需要随机长序列、离线快照升级、Unicode 字素簇、组合输入和撤销体验验证。对中文输入法而言,一个用户可见字符不等于一个字节或码点;生产接口必须选择字素或编辑器约定,不能直接照搬本模型的单字符假设。
面试追问:副本永远不再联网是否影响墓碑回收;被撤销权限的设备离线产生操作时服务器如何处理;两个副本最终文本相同但词语交错算不算成功。前两个涉及协议与授权,第三个涉及产品体验,不能由收敛一个词统一回答。
[PATTERN] 先定义操作身份和合并不变量,再定义光标、撤销与离线保留。算法保证相同结果,产品仍需决定哪个结果对用户有意义。
实验附件与导航
可运行实验源码 · 本次原始结果系列导读;容量和数据承诺分别沿用系列的方法,本文数字为独立教学假设。






