跳转至

经典案例:实时协同编辑文档系统 (Collaborative Editing - 类似 Google Docs 与飞书)

实时协同编辑(如 Google Docs、飞书文档、Notion、Figma)是当今互联网最引人入胜的复杂交互系统之一。

初学者最感神奇的是:当两个人同时打开同一篇文档,Alice 在第 5 个字后面插入了一个字符,Bob 在几乎完全相同的毫秒内删除了第 4 个字。在不可预测的网络延迟下,系统如何确保两个人的屏幕上既不会发生文字打架覆盖,最终又能自动收敛成一模一样的文本?


1. 初学者视角:为什么简单的加锁机制在协同编辑中必死无疑?

很多初学者可能会想:能不能用并发编程最熟悉的“互斥锁”?

graph TD
    subgraph "悲观锁方案 (用户体验灾难)"
    A[Alice 点击第一段开始打字] --> B[系统对第一段加上排他锁]
    B --> C[Bob 试图在第一段打字]
    C --> D[系统弹窗: '当前段落正在被 Alice 编辑, 您已被禁止输入!']
    end
  • 体验极差:在线协同追求的是“行云流水、实时可见”,加锁让多人协同沦为“轮流值日打字”;
  • 死锁与单点风险:如果 Alice 在打字途中突然合上笔记本或断网,锁被长时间霸占,其他人将永远无法编辑该段落。

现代协同系统的核心原则是:允许所有人无锁自由输入,通过精妙的数学算法自动解决冲突并保证最终一致性!


2. 需求分析与系统范围 (Requirements & Scope)

2.1 功能性需求

  1. 多人实时并发编辑:支持多位用户在同一文档的不同位置(甚至同一行)同时输入与删除;
  2. 实时光标与状态感知:实时在界面上显示其他协作者的光标位置、名字和选中区域;
  3. 离线编辑与重连同步:断网期间允许本地继续输入,网络恢复后自动与云端合并;
  4. 历史版本与协作撤销 (Undo/Redo):支持单人独立撤销自己的上一步操作,而不影响其他协作者的内容。

2.2 非功能性需求

  1. 端到端极低延迟 (Ultra-Low Latency):输入内容在协作者之间的端到端同步延迟控制在 \(100\text{ ms}\) 以内;
  2. 绝对强最终一致性 (Strong Eventual Consistency):当所有协作者停止输入后,所有设备上的文档内容必须百分之百一模一样。

3. 核心冲突解决算法:OT 与 CRDT

工业界解决协同编辑冲突有两大主流派系: - OT (Operational Transformation,操作转换):Google Docs、飞书文档等传统协同系统的经典底座; - CRDT (Conflict-Free Replicated Data Types,无冲突复制数据类型):Figma、Notion、Yjs 等现代新一代协同软件的首选。


3.1 方案一:操作转换算法 (Operational Transformation - OT)

OT 的核心思想是:客户端发送的不是整个文档,而是轻量的编辑操作指令(如插入、删除);当多个操作并发到达时,通过“变基转换函数”动态修正操作的位置索引(Index)。

冲突场景复现:

假设初始文档为字符串 "CAT"。 - Alice 在索引 0 处插入字符 'B':\(op_A = \text{Insert}(0, 'B')\),Alice 本地变为 "BCAT"; - Bob 在索引 3 处插入字符 'S':\(op_B = \text{Insert}(3, 'S')\),Bob 本地变为 "CATS"。

graph TD
    subgraph 朴素应用导致内容不一致
    Init["初始文本: 'CAT'"]
    Init -->|Alice 执行 op_A| A1["'BCAT'"]
    Init -->|Bob 执行 op_B| B1["'CATS'"]

    A1 -->|"Alice 收到未经转换的 op_B: Insert(3, 'S')"| FailA["'BCAS' (错误: 'T' 后面插入变成了在 'A' 后面插入!)"]
    B1 -->|"Bob 收到未经转换的 op_A: Insert(0, 'B')"| FailB["'BCATS'"]
    end

OT 变基转换函数的魔法:

当 Alice 想要应用 Bob 的操作 \(op_B\) 时,OT 引擎会执行转换函数 \(T(op_B, op_A)\): - 因为 Alice 已经在前面(索引 0)插入了一个字符 'B',导致后面的所有字符索引全部向右偏移了 1 位! - OT 转换函数将 \(op_B\) 的目标索引从 3 修正为 \(3 + 1 = 4\):

\[ op_B' = \text{Insert}(4, 'S') \]
  • Alice 本地执行 \(op_B'\),最终文本变为 "BCATS"!
  • 两人最终在屏幕上看到的完全一致,冲突完美消除。

3.2 方案二:无冲突复制数据类型 (CRDT)

OT 算法高度依赖中央服务器进行操作的严格全局定序,且转换函数面对复杂格式(富文本、表格)时分支逻辑极其繁琐(曾被称为“OT 噩梦”)。

CRDT 提出了一种全新的革命性思路:彻底废弃数组下标索引,给每一个字符分配一个全局唯一的、不可变的虚拟分数标识符!

初始文本:
[ 字符 'C', ID: 1.0 ]
[ 字符 'A', ID: 2.0 ]
[ 字符 'T', ID: 3.0 ]
  • 如果 Alice 想在 'C' 和 'A' 之间插入 'X':
  • Alice 并不生成“在索引 1 插入”的操作,而是生成:在 ID 1.0 和 ID 2.0 之间插入一个新字符,新 ID 取两者的中间值:ID: 1.5;
  • 如果 Bob 同时在 'C' 和 'A' 之间插入 'Y':
  • Bob 生成的新字符 ID 取两者的中间值加自己客户端 ID 作为区分:ID: 1.51;
  • 天然满足交换律与结合律:
  • 无论网络如何乱序到达,由于每个字符的 ID 大小是绝对唯一且预先确定的,所有客户端只要按照字符 ID 的浮点数大小做单调排序,展示出来的文本必定完全一致!

4. 全链路工业级生产架构 (End-to-End Architecture)

以基于 OT 的中心化协同系统为例:

graph TD
    subgraph 客户端
    Alice[用户 Alice]
    Bob[用户 Bob]
    end

    subgraph 接入与长连接层
    GW[WebSocket 协同网关]
    end

    subgraph "核心协同引擎 (OT Engine)"
    SessionServer["文档协同会话服务 (Doc Session Server)"]
    Redis[(Redis: 内存文档当前状态与版本号)]
    end

    subgraph 持久化存储
    DB[(MySQL: 操作历史变更流水 Logs)]
    S3[(对象存储 S3: 周期性文档全量快照 Snapshots)]
    end

    Alice <==>|WebSocket 实时操作流| GW
    Bob <==>|WebSocket 实时操作流| GW
    GW <--> SessionServer
    SessionServer <--> Redis
    SessionServer --> DB
    SessionServer -.->|每隔 5 分钟生成快照| S3

4.1 核心交互流程

  1. 建立长连接:用户打开文档,通过 WebSocket 与协同网关建立持久连接,加入该文档的房间(Room);
  2. 拉取文档基线:客户端从 S3 加载最新快照,并拉取快照之后的增量操作日志,在前端内存中还原出文档;
  3. 本地乐观渲染 (Optimistic UI):用户每打一个字,本地编辑器立即渲染,同时将操作包裹为包含“当前文档版本号 \(V\)”的消息发送给服务端;
  4. 服务端 OT 转换与广播:
  5. 服务端作为权威裁判,对接收到的操作分配全局单调自增的新版本号 \(V + 1\);
  6. 若发现客户端提交的版本号落后于当前版本,服务端执行 OT 转换后,将转换后的操作广播给房间内的所有其他协作者。

5. 面试深挖:离线编辑与撤销 (Undo) 难点

5.1 离线编辑与重连追赶 (Catch-Up)

用户在飞机上断网编辑了 1 小时,落地连网后如何合并? - 客户端本地维护一个未确认操作队列(Pending Queue); - 联网后,客户端向服务端发送请求:“我本地最后确认的版本是 \(V_{100}\),请把 \(V_{100}\) 之后的所有云端操作发给我”; - 客户端收到云端这 1 小时内的全部变更后,在本地将自己的 Pending 操作与云端操作逐一进行 OT 变基转换,随后将转换后的本地修改一次性提交回云端。


5.2 协作环境下的撤销 (Collaborative Undo)

如果 Alice 连续输入了 10 个字,中途 Bob 也在其他地方插入了内容。此时 Alice 按下 Ctrl + Z(撤销): - 绝对不能直接回退到上一版文档快照:因为这会把 Bob 刚敲的心血也一并抹去! - 正确做法(反向操作补偿): - 撤销的本质是生成一个相反的操作(例如上一操作是“在位置 5 插入 A”,撤销操作即为“在位置 5 删除 A”); - 将该反向操作像普通输入一样,经过 OT 转换后应用并广播给所有人,精准撤销 Alice 自己的修改,对 Bob 毫无侵扰。