面试题:设计实时协作文档系统:多人同时编辑同一份文档怎么不冲突?OT 和 CRDT 各自怎么运作、怎么选?
这是「每日一题」专栏的第十五篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 接口幂等,今天按轮换回到系统设计方向,聊上一篇预告的题:实时协作文档系统。
一、问题的本质:三个并发冲突
面试官让你设计 Google Docs / Notion / 飞书文档,核心考点只有一个:多人同时编辑,怎么保证不冲突、最终一致。
拆开看要满足三个目标:
| 目标 | 含义 | 违反的后果 |
|---|---|---|
| 收敛性 Convergence | 所有副本最终状态一致 | 同一文档不同人看到不同内容 |
| 意图保留 Intention Preservation | 每个用户的操作结果符合其意图 | 我插的字跑到了奇怪的位置 |
| 因果序 Causal Ordering | 因果相关的操作按序生效 | 先删后写与先写后删结果不同 |
朴素方案是「最后写入者胜」:谁后到听谁的。两人同时往同一位置插入文字,后到的覆盖先到的——一个人打的字直接消失,这就是没保留意图。真正的解法只有两条路:**OT(操作转换)**和 CRDT(无冲突复制数据类型)。
二、方案一:OT(操作转换)
OT 的核心思想:操作不直接应用,而是先经过变换(transform)再应用。
举例:Alice 在位置 5 插入 "A",Bob 同时在位置 3 插入 "B"。若 Bob 的操作先到达,服务端应用后文档多了一个字符,Alice 的插入位置 5 就偏了。OT 的做法是把 Alice 的操作与 Bob 的操作做一次变换,得到「考虑了 Bob 已生效」之后的版本:
# transform(op1, op2) -> op1'
# 把 op1 变换成「op2 已生效之后」的等价操作
def transform_insert(insert, other):
# insert: 本地待应用的插入操作
# other: 已生效的并发操作
if other.type == "insert":
# 对方也是插入:位置在插入点之后(含同点按站点号定序),后移一位
if other.pos < insert.pos or (other.pos == insert.pos and other.site_id < insert.site_id):
return insert
return insert.with_pos(insert.pos + 1)
if other.type == "delete":
# 对方删掉了插入点之前的字符:位置前移一位
if other.pos < insert.pos:
return insert.with_pos(insert.pos - 1)
return insert
return insert
变换后的操作再应用,两边就收敛到同一状态,且每个人的意图都保留了。
OT 的难点在于变换函数极易写错:操作类型越多(插入、删除、格式、移动),组合爆炸越严重,而且必须满足两条性质——TP1(任意顺序变换结果一致)和 TP2(意图保留)。所以工业界几乎没有纯 P2P 的 OT:Google Docs 用的是 OT + 中心化服务端:服务端作为全局排序者,所有操作先到服务端排好序,客户端只需把本地操作变换到服务端版本上,问题规模从「全对全」降成「一对一」,复杂度才可控。
三、方案二:CRDT(无冲突复制数据类型)
CRDT 换了个思路:不解决冲突,而是让冲突根本不存在——把数据结构设计成「怎么合并都一致」的数学结构,副本之间随便交换状态,最终必然收敛。
文本 CRDT 的代表是 RGA(Replicated Growable Array)和 YATA(Yjs 采用的算法),核心三步:
- 每个字符有全局唯一 ID:
(站点 ID, 逻辑时钟),插入时指定挂在哪个左邻居后面。 - 删除不真删,打墓碑(tombstone)标记。
- 合并规则是交换律的:两个副本各自插入了字符,合并时按 ID 全序排列,谁先谁后有确定规则,结果必然一致。
# Yjs/YATA 风格的文本 CRDT 核心结构
class Char:
id: tuple # (site_id, clock),全局唯一
content: str
left: tuple # 左邻居 ID,插入锚点
deleted: bool # 墓碑标记,删除不真删
def insert(crdt, left_id, char):
node = Char(id=(site_id, next_clock()), left=left_id, ...)
# 插入位置由左邻居确定;两个副本并发插到同一邻居后面时,
# 按 (site_id, clock) 全序排,谁前谁后是确定的,合并不产生分歧
crdt.children[left_id].append(node)
def merge(a, b):
# 合并 = 节点集合取并集,按 left 指针 + ID 全序重排
# 纯函数式合并,天然满足收敛性
CRDT 的优点:不需要中心服务端,P2P、离线编辑、断网重连后合并都是同一套机制,正确性可以数学证明。缺点:墓碑永不消失,存储会膨胀,长文档要定期压缩;工程细节也不少(YATA 的冲突消解、ID 分配、时钟同步)。
四、OT vs CRDT:怎么选
| 维度 | OT | CRDT |
|---|---|---|
| 收敛保证 | 靠变换函数正确性,工程上难验证 | 数学性质,天然收敛 |
| 服务端依赖 | 强依赖中心排序(Google Docs 模式) | 可有可无,P2P / 离线友好 |
| 实现复杂度 | 变换函数组合爆炸,业界公认难 | 核心合并逻辑相对简单 |
| 存储开销 | 小(操作日志) | 大(墓碑 + 元数据) |
| 代表产品 | Google Docs、Word 协同编辑 | Yjs、Figma、Linear、Liveblocks |
面试加分话术:「选型取决于网络模型。中心化文档、服务端强管控(权限、审计、合规)选 OT,操作能过服务端校验和排序;离线优先、P2P 协同、或追求低延迟合并选 CRDT。现代产品常见组合是编辑器内核用 CRDT(如 Yjs + Tiptap),服务端只做权限和持久化。」
五、完整系统设计:从编辑器到服务端
把整条链路画给面试官:
- 客户端:编辑器内核(如 Yjs)+ WebSocket 长连接。本地操作先应用到本地副本(响应快),再异步广播。
- 网关层:WebSocket 接入集群,按文档 ID 做一致性哈希,保证同一文档的连接尽量落在同一节点。
- 协作服务:每个文档一个「房间」,收到操作先做服务端合并(OT 是排序,CRDT 是 merge),再扇出给房间内其他成员。
- 跨节点扇出:用户分散在不同网关节点时,节点间用 Redis Pub/Sub(小规模)或 Kafka(大规模)转发操作。
- 持久化:文档快照存对象存储,操作日志存日志表(WAL),定期 checkpoint 压缩。崩溃恢复 = 最近快照 + 重放增量操作。
- 在线状态 / 光标:presence 通道单独走,ephemeral 数据不需要持久化,用心跳 + 超时判定离线,Last-Write-Wins 足够。
关键容量数字:单机 WebSocket 扛几万连接没问题;单个文档同时在线几十人就是重度场景,几百人同时编辑需要做分区(按分节 / 页面拆分文档)。服务端瓶颈通常在扇出和持久化,不在编辑本身。
六、两道高频追问
追问 1:为什么 Google Docs 用 OT 不用 CRDT? 历史 + 架构原因:OT 提出更早(1989 年),Google Wave 时代就积累了成熟实现;且 Google Docs 是强中心化产品,服务端排序本来就在,OT 的复杂度可控。CRDT 是 2011 年才被系统化提出,工业界成熟(Yjs)更晚。问「哪个更好」是陷阱,重点在「跟你的架构匹不匹配」。
追问 2:CRDT 墓碑膨胀怎么治? 删除的字符不是真删,长文档越积越多。方案:定期压缩(把墓碑从快照清掉,重写文档基线);操作日志分段归档;且清理必须等所有在线副本都完成合并才能做,否则旧副本合并回来会「复活」已删内容——所以要靠版本向量确认全员进度,这是最容易翻车的细节。
七、收尾
协作文档这道题,表面考 OT 和 CRDT,实际考的是你对「分布式一致性的工程取舍」的理解:收敛性可以靠算法保证,意图保留必须靠设计,选型永远要回到网络模型和产品形态。能讲清 OT 为什么难、CRDT 为什么省心又为什么费存储,再把服务端架构画完整,这道题就稳了。
明日预告:AI/LLM 方向 ——「大模型推理服务为什么 vLLM 最快?Continuous Batching 和 PagedAttention 各自解决了什么问题、显存占用怎么降下来的?」