多人实时协作编辑(Google Docs、Figma、飞书文档)是分布式系统里最「反直觉」的一类问题:多个用户同时改同一段文字,网络还会乱序、延迟、断线,但最终每个人的屏幕必须收敛到完全一致的文档。它不追求「谁赢」,而追求「合并后大家都一样」。这背后的核心是协同算法(OT 或 CRDT),以及一套把编辑操作可靠广播给所有人的实时通道。本文按照系统设计面试的标准答题结构,设计一个支持多人同时编辑、离线可继续、断线自动合并的协作文档系统。
一句话:协作编辑的核心不是「实时」,而是「收敛」——用 OT 或 CRDT 把并发操作合并成同一个结果,实时通道只是把操作尽快送达的手段。
一、需求澄清与量级估算
1.1 需求澄清
- 编辑对象:纯文本文档,还是富文本(加粗/表格/图片)、表格、白板(矢量图形)?
- 并发规模:单文档同时在线多少人?10 人还是 1000 人?
- 一致性要求:最终一致即可(允许短暂不同),还是要「强收敛且因果一致」?
- 离线支持:是否要求断网后继续编辑、恢复网络自动合并?
- 历史版本:是否需要版本历史、逐字符回溯、评论与建议模式?
明确假设(面向面试的合理假设):
| 需求项 | 假设 |
|---|---|
| 文档类型 | 富文本(段落 + 内联样式 + 图片占位) |
| 单文档并发 | 常见 10 人,上限 200 人 |
| 一致性 | 最终收敛 + 因果一致(不出现「先看到结果后看到原因」) |
| 离线 | 支持,最长离线 24 小时 |
| 历史 | 保留最近 30 天操作日志,可回放 |
1.2 量级估算
| 指标 | 估算值 | 推导 |
|---|---|---|
| 日活用户 | 2000 万 | 假设月活 6000 万,日活 1/3 |
| 同时在线编辑 | ~50 万 | 日活 2.5%,集中在工作时段 |
| 单文档操作 QPS | ~20 | 10 人同时打字,人均 2 次/秒 |
| 全局操作 QPS | ~10 万 | 50 万在线用户平均每人每 5 秒一次操作 |
| 单文档大小 | 平均 200KB | 富文本 + 图片引用 |
| 操作日志 | ~50 亿条/天 | 每次按键/格式化一条操作 |
一句话:协作编辑的难点不在吞吐(10 万 QPS 不算大),而在正确性——乱序、重发、离线合并下必须收敛,任何一处 bug 都会让文档「永久分裂」。
二、高层架构设计
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 客户端 A │ │ 客户端 B │ │ 客户端 C │ (本地文档副本 + 操作队列)
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ WebSocket │ │
┌──────▼───────────────▼──────────────▼──────┐
│ 实时网关 (WebSocket 长连接层) │
│ 连接管理 / 心跳 / 鉴权 / 文档房间路由 │
└──────────────────┬──────────────────────────┘
│ 内部消息
┌──────────────────▼──────────────────────────┐
│ 协同服务 (Collaboration Server) │
│ ┌────────────┐ ┌────────────┐ ┌─────────┐ │
│ │ 文档房间 │ │ 操作排序 │ │ 快照压缩 │ │
│ │ (内存状态) │ │ (OT/CRDT) │ │ │ │
│ └────────────┘ └────────────┘ └─────────┘ │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ 持久化层: 操作日志(Kafka) + 快照(对象存储/DB) │
│ 在线状态(Presence): Redis 发布订阅 │
└─────────────────────────────────────────────┘
四层职责:
- 客户端:本地维护文档副本,乐观地立即应用自己的操作(零延迟),同时把操作入队待确认。
- 实时网关:维持 WebSocket 长连接,把操作按文档房间路由到对应协同服务。
- 协同服务:单文档单线程(Actor)处理操作排序与合并,是收敛性的「权威」。
- 持久化:操作日志(Kafka)保证不丢,快照定期压缩,Redis 广播在线状态。
2.1 为什么需要「单文档单线程」
同一文档的并发操作必须串行化到一个确定顺序,否则 OT 的变换依赖关系会算错。做法是:按 doc_id 哈希路由到唯一协同节点,节点内用单线程/Actor 顺序处理——这就是「逻辑上的单文档单核」。文档量大时水平加节点即可,因为不同文档互不干扰。
一句话:把「同一个文档」绑定到「同一台机器的同一个线程」,是把分布式并发问题降维成单机串行问题的最有效手段。
三、核心组件设计
3.1 OT 与 CRDT 的抉择
两种主流协同算法:
| 维度 | OT(Operational Transform) | CRDT(无冲突复制数据类型) |
|---|---|---|
| 核心思想 | 变换操作使其适应当前文档 | 数据结构本身保证可交换合并 |
| 中心化 | 通常需中心服务器定序 | 可去中心,天然 P2P |
| 复杂度 | 变换函数难写(N 路变换易错) | 结构复杂、元数据膨胀 |
| 离线 | 支持但合并逻辑重 | 天然支持,合并即 merge |
| 代表 | Google Docs、Etherpad | Yjs、Automerge、Figma |
本文选 CRDT(以 Yjs 思路):理由是离线支持天然、无需中心定序也能收敛,代价是每个字符带一点元数据(ID、时钟),文档体积略大。OT 适合「必须省存储 + 中心化」的场景,但变换函数是著名的 bug 温床。
3.2 CRDT 文档模型
核心是给每个字符一个全局唯一、可比较的 ID,并用偏序关系决定合并:
字符 ID = (client_id, clock) # Lamport 时钟,保证全序
例: 字符 'H' = (A, 1), 'i' = (B, 1), '!' = (A, 2)
插入 = 在「左邻居 ID」和「右邻居 ID」之间插入新字符
并发插入同一位置 → 按 (client_id, clock) 排序决定先后
删除 = 标记墓碑 (tombstone),不物理删除
用 YATA / RGA 这类序列 CRDT:每个元素记录「左起源」和「右起源」,合并时按因果顺序插入,保证所有副本得到同一顺序。
状态向量(State Vector)用于「增量同步」——只发对方缺的操作:
class Doc:
def __init__(self, client_id):
self.clock = 0
self.state_vector = {} # client_id -> 已见过的最大 clock
self.items = [] # 字符序列 (id, left_origin, right_origin, char)
def local_insert(self, pos, ch):
self.clock += 1
left = self.items[pos-1].id if pos > 0 else None
item = Item((self.client_id, self.clock), left, ch)
self.items.insert(pos, item)
return Operation("insert", item)
def apply_remote(self, op):
# 按 (left_origin, right_origin, id) 的偏序规则插入到正确位置
idx = self.find_insert_index(op.item)
self.items.insert(idx, op.item)
self.state_vector[op.item.id[0]] = max(
self.state_vector.get(op.item.id[0], 0), op.item.id[1])
一句话:CRDT 的魔法在于「操作可交换」——无论操作以什么顺序到达,只要最终都到达,合并结果必然一致;代价是每个字符都要带 ID 和起源信息。
3.3 实时通道与操作广播
- WebSocket 长连接:比轮询/SSE 更适合双向低延迟通信;设计一个即时通讯系统 的连接管理经验可直接复用(心跳、重连、房间)。
- 操作广播:客户端发操作 → 协同服务排序落日志 → 广播给房间内其他成员。
- 增量同步:新加入或重连的客户端先交换 State Vector,服务端只回缺失的操作,避免全量传输。
- 可靠性:操作带
(doc_id, client_id, clock),服务端幂等去重;客户端断线重连后从最后确认的 clock 续传——这与 分布式系统幂等设计 的思路一致。 - 低延迟:同机房 WebRTC DataChannel 或边缘节点可进一步降延迟,参见 WebRTC 低延迟直播 的传输优化。
3.4 光标与在线状态(Presence)
Presence(谁在线、光标在哪)与文档内容分开处理:
- 内容是「持久状态」,需要收敛与落盘;Presence 是「瞬时状态」,丢一帧无所谓。
- Presence 走 Redis 发布订阅 / 广播,不写操作日志、不进 CRDT,过期即失效。
- 光标位置要用相对锚点(如「第 5 个字符之后」)而非绝对偏移,否则别人在你前面插入文字后你的光标会漂移。
3.5 快照与压缩
CRDT 文档会随操作无限增长(墓碑堆积),必须压缩:
- 快照:定期把当前文档状态序列化成快照存对象存储/DB,新客户端从快照 + 后续操作恢复。
- 墓碑 GC:确定所有副本都见过的删除标记可安全清理(需状态向量确认无落后副本)。
- 操作日志截断:快照点之前的操作日志归档,保留最近窗口即可回放。
四、数据模型
| 存储 | 用途 | 说明 |
|---|---|---|
| doc_meta | 文档元信息 | doc_id、标题、权限、当前快照指针 |
| snapshot | 文档快照 | 定期序列化,含 state_vector |
| operation_log | 操作日志 | Kafka,按 doc_id 分区,支持回放 |
| presence | 在线状态 | Redis,TTL 30 秒,不进持久化 |
| permission | 协作者权限 | 读/写/评论,独立于内容 |
字段规范:doc_id 用 ULID;client_id 用会话级随机 UUID;操作序号 clock 用 Lamport 计数;快照存压缩后的二进制(Yjs update 格式)。
五、关键流程
5.1 一次编辑的完整时序
客户端A 协同服务 Kafka 客户端B
│ 本地插入字符 │ │ │
│ (乐观立即渲染) │ │ │
├──op(A,5)──────▶│ 排序+落日志 │ │
│ ├─────────────────▶│ │
│ │ 广播 op │ │
│ ├──────────────────┼───────────────▶│ apply_remote
│◀──ack(A,5)─────┤ │ │ (合并到本地)
关键:客户端 A 不等 ack 就渲染(乐观更新),服务端只做「定序 + 广播」,A 收到自己的 ack 后把操作从待确认队列移除。
5.2 离线编辑与恢复合并
1. 断网: 客户端继续本地编辑,操作累积在本地队列(clock 继续自增)
2. 恢复: 客户端发送 state_vector = {A:100} 给服务端
3. 服务端: 比对文档 state_vector,回发 A 缺失的操作 (增量)
4. 客户端: 把离线期间的操作 + 服务端回发的操作一起 merge
5. 结果: 因为 CRDT 可交换,无论合并顺序如何,所有副本收敛一致
难点在离线期间的因果依赖:A 离线时基于旧版本做的插入,可能与在线用户在同一位置插入冲突——CRDT 的偏序规则(按 client_id 决胜)保证这种情况也收敛,只是视觉上「谁在前面」由 ID 大小决定。
六、可靠性与一致性
6.1 收敛性保证
- 交换律:任意两个操作
a ⊕ b == b ⊕ a,所以乱序到达不影响结果。 - 结合律:
(a ⊕ b) ⊕ c == a ⊕ (b ⊕ c),所以分批合并等价于一次合并。 - 幂等:同一操作重复应用结果不变(墓碑 + 唯一 ID 去重)。
三条性质合起来 = 强最终一致(Strong Eventual Consistency):只要所有操作最终都送达,所有副本必然收敛,无需中心仲裁。
6.2 服务端高可用
- 单文档绑定单节点,节点宕机用主备 + 操作日志回放恢复:备节点从 Kafka 重放未快照的操作即可接管。
- 文档热迁移:迁走前把当前状态 + state_vector 快照到共享存储,新节点加载后继续接收操作。
6.3 权限与安全
- 编辑前鉴权(文档级 + 段落级),未授权操作直接丢弃。
- 操作日志不可篡改(append-only + 校验和),支持审计与追责。
- 防止恶意客户端伪造
client_id刷操作:服务端校验会话与 client_id 绑定。
七、性能与扩展
- 单文档单核:单节点可承载数百个活跃文档,按
doc_id分片到多节点线性扩展。 - 操作合并:把 100ms 内的连续单字符插入合并成一个批量操作,减少广播量。
- 边缘接入:就近 WebSocket 接入,减少 RTT;同区域用户走同一边缘节点。
- 快照频率:每 1000 次操作或 5 分钟做一次快照,平衡恢复速度与写放大。
- Presence 限流:光标移动每秒最多广播 20 次,防抖动刷屏。
容量与热点
- 爆款文档(万人围观):读多写少,用广播树/发布订阅扩散,写操作仍走单节点定序。
- 大文档(10 万字符):分块 CRDT,按段落分片,只同步变更段落。
八、权衡与备选
| 决策点 | 本文选型 | 备选 | 权衡说明 |
|---|---|---|---|
| 协同算法 | CRDT(Yjs) | OT | CRDT 离线天然、无中心定序;OT 省存储但变换函数易错 |
| 传输 | WebSocket | 轮询 / SSE | WebSocket 双向低延迟;轮询简单但延迟高 |
| 定序 | 服务端单节点串行 | 去中心 P2P | 单节点简单可靠;P2P 免服务器但冲突处理复杂 |
| 墓碑 | 定期 GC | 永久保留 | GC 省空间但需状态向量确认;保留简单但文档膨胀 |
| Presence | Redis 广播(不落盘) | 进 CRDT | 分开处理省带宽、容忍丢帧;进 CRDT 简单但徒增收敛负担 |
关键取舍
- 一致性 vs 延迟:乐观本地渲染把延迟降到 0,代价是偶尔要「回滚重放」(冲突时重排)——但 CRDT 下这种重排极少。
- 存储 vs 带宽:CRDT 元数据多占 20~50% 体积,换来离线与去中心能力。
- 中心化 vs 去中心:中心定序简单、易鉴权,但服务器是瓶颈与单点;去中心(如 Figma 部分场景)免服务器但客户端逻辑重。
九、扩展场景与面试追问
9.1 富文本与结构化文档
- 富文本用树形 CRDT(如 Y.XmlFragment):段落是子节点,样式是属性,合并规则同序列 CRDT。
- 表格 = 行 CRDT + 单元格 CRDT 嵌套,插入行时整行作为一个原子操作。
- 图片/附件:只同步「引用 + 上传状态」,二进制走独立上传通道。
9.2 评论与建议模式
- 评论锚定到「字符区间 ID」而非偏移,随内容增删自动跟随。
- 建议模式(Track Changes):把「提议的修改」作为独立 CRDT 层叠加,接受时合并进主文档。
9.3 面试常见追问
| 追问 | 关键回答 |
|---|---|
| OT 和 CRDT 怎么选? | 要离线/去中心选 CRDT;要极致省存储且可中心化选 OT |
| 两个人同时插同一位置? | CRDT 按 (client_id, clock) 偏序决胜,所有副本结果一致 |
| 离线一天后合并会乱吗? | 不会,CRDT 可交换+幂等,合并顺序不影响最终结果 |
| 光标为什么会漂移? | 用绝对偏移导致;改用相对锚点(前驱字符 ID)即解决 |
| 文档越来越大怎么办? | 定期快照 + 墓碑 GC + 操作日志截断,控制体积 |
| 服务端挂了会丢操作吗? | 操作先落 Kafka 再广播,备节点重放日志接管,不丢 |
十、总结
| 模块 | 关键设计 | 一句话记忆 |
|---|---|---|
| 协同算法 | CRDT(可交换+幂等) | 乱序到达也能收敛 |
| 文档模型 | 字符带 ID + 起源 | 合并靠偏序,删除用墓碑 |
| 实时通道 | WebSocket + 房间路由 | 单文档单线程定序 |
| 离线 | State Vector 增量同步 | 重连只补缺失操作 |
| Presence | Redis 广播不落盘 | 瞬时状态和内容分开 |
| 压缩 | 快照 + 墓碑 GC | 定期固化,控制体积 |
一句话:协作编辑的面试核心是讲清楚「为什么需要协同算法、OT 与 CRDT 的取舍、CRDT 靠什么保证收敛(交换律+幂等)、以及离线合并与 Presence 如何处理」,把「单文档单线程定序」当作降维手段挂在嘴边,而不是堆组件。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。