设计一个实时协作编辑系统(CRDT 与 OT)

本文系统设计一个实时协作编辑系统:需求澄清与并发估算、OT 与 CRDT 两大协同算法对比、基于 CRDT 的文档模型与状态向量、WebSocket 长连接与操作广播、离线编辑与冲突合并、光标与在线状态同步,并给出数据结构、合并伪代码与一致性权衡。

多人实时协作编辑(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~2010 人同时打字,人均 2 次/秒
全局操作 QPS~10 万50 万在线用户平均每人每 5 秒一次操作
单文档大小平均 200KB富文本 + 图片引用
操作日志~50 亿条/天每次按键/格式化一条操作

一句话:协作编辑的难点不在吞吐(10 万 QPS 不算大),而在正确性——乱序、重发、离线合并下必须收敛,任何一处 bug 都会让文档「永久分裂」。

二、高层架构设计

       ┌──────────┐   ┌──────────┐   ┌──────────┐
       │ 客户端 A  │   │ 客户端 B  │   │ 客户端 C  │  (本地文档副本 + 操作队列)
       └────┬─────┘   └────┬─────┘   └────┬─────┘
            │ WebSocket     │              │
     ┌──────▼───────────────▼──────────────▼──────┐
     │            实时网关 (WebSocket 长连接层)       │
     │   连接管理 / 心跳 / 鉴权 / 文档房间路由          │
     └──────────────────┬──────────────────────────┘
                        │ 内部消息
     ┌──────────────────▼──────────────────────────┐
     │            协同服务 (Collaboration Server)     │
     │  ┌────────────┐  ┌────────────┐  ┌─────────┐ │
     │  │ 文档房间    │  │ 操作排序    │  │ 快照压缩 │ │
     │  │ (内存状态)  │  │ (OT/CRDT)  │  │         │ │
     │  └────────────┘  └────────────┘  └─────────┘ │
     └──────────────────┬──────────────────────────┘
                        │
     ┌──────────────────▼──────────────────────────┐
     │  持久化层: 操作日志(Kafka) + 快照(对象存储/DB) │
     │  在线状态(Presence): Redis 发布订阅            │
     └─────────────────────────────────────────────┘

四层职责:

  1. 客户端:本地维护文档副本,乐观地立即应用自己的操作(零延迟),同时把操作入队待确认。
  2. 实时网关:维持 WebSocket 长连接,把操作按文档房间路由到对应协同服务。
  3. 协同服务:单文档单线程(Actor)处理操作排序与合并,是收敛性的「权威」。
  4. 持久化:操作日志(Kafka)保证不丢,快照定期压缩,Redis 广播在线状态。

2.1 为什么需要「单文档单线程」

同一文档的并发操作必须串行化到一个确定顺序,否则 OT 的变换依赖关系会算错。做法是:按 doc_id 哈希路由到唯一协同节点,节点内用单线程/Actor 顺序处理——这就是「逻辑上的单文档单核」。文档量大时水平加节点即可,因为不同文档互不干扰。

一句话:把「同一个文档」绑定到「同一台机器的同一个线程」,是把分布式并发问题降维成单机串行问题的最有效手段。

三、核心组件设计

3.1 OT 与 CRDT 的抉择

两种主流协同算法:

维度OT(Operational Transform)CRDT(无冲突复制数据类型)
核心思想变换操作使其适应当前文档数据结构本身保证可交换合并
中心化通常需中心服务器定序可去中心,天然 P2P
复杂度变换函数难写(N 路变换易错)结构复杂、元数据膨胀
离线支持但合并逻辑重天然支持,合并即 merge
代表Google Docs、EtherpadYjs、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)OTCRDT 离线天然、无中心定序;OT 省存储但变换函数易错
传输WebSocket轮询 / SSEWebSocket 双向低延迟;轮询简单但延迟高
定序服务端单节点串行去中心 P2P单节点简单可靠;P2P 免服务器但冲突处理复杂
墓碑定期 GC永久保留GC 省空间但需状态向量确认;保留简单但文档膨胀
PresenceRedis 广播(不落盘)进 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 增量同步重连只补缺失操作
PresenceRedis 广播不落盘瞬时状态和内容分开
压缩快照 + 墓碑 GC定期固化,控制体积

一句话:协作编辑的面试核心是讲清楚「为什么需要协同算法、OT 与 CRDT 的取舍、CRDT 靠什么保证收敛(交换律+幂等)、以及离线合并与 Presence 如何处理」,把「单文档单线程定序」当作降维手段挂在嘴边,而不是堆组件。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个视频会议系统(WebRTC SFU)
  2. 设计一个 A/B 测试与实验平台
  3. 设计一个分布式锁服务