协同编辑与 OT 算法

深入 Operational Transformation:讲清操作变换与 TP1/TP2 收敛条件、手写一个最小 OT 的 insert/delete 变换函数、剖析坐标系偏移与富文本属性变换、给出 ShareDB 的工程实践与服务端状态管理,并对比 OT 与 CRDT 的选型标准与常见坑。

OT(Operational Transformation)是协同编辑最早成熟的技术路线,Google Docs、早期的 Etherpad 都基于它。它的核心思想很朴素:既然并发操作会导致分歧,那就定义一套「变换」规则,把操作调整到可以在任意顺序下重放,从而保证收敛。

相比 CRDT,OT 的元数据更小、历史更短,但变换函数的正确性证明更难。本文从最小实现讲起,逐步展开到富文本与工程实践。读完你应该能判断何时该用 OT,也能看懂 ShareDB 这类框架在做什么。

一、OT 的核心思想

1.1 操作与变换

在文本编辑中,只有两种基本操作:插入与删除。问题在于它们的位置参数是「当时的索引」,一旦有别的操作先执行,索引就失效了。

初始文本: "abc"
用户 A: insert(1, "X")  -> "aXbc"
用户 B: insert(2, "Y")  -> "abYc"

若 A 先执行,文本变成 “aXbc”,此时 B 的 insert(2, "Y") 会插到 “aX” 之后得到 “aXYbc”,与期望的 “aXbYc” 不符。变换的作用就是把 B 的操作调整为 insert(3, "Y")。

1.2 收敛条件

OT 的正确性依赖两个条件,称为 TP1 与 TP2:

条件含义
TP1两个并发操作变换后重放,结果相同
TP2三个及以上并发操作两两变换后仍收敛

TP1 相对容易满足,TP2 在复杂操作下极难证明,这也是 OT 实现最容易出错的地方。历史上许多 OT 系统只在特定操作集下被证明正确。

二、一个最小 OT 实现

下面实现文本的 insert 与 delete 两种操作及其变换函数,它能正确收敛两个并发操作:

// 操作表示
// { type: "insert", pos, text }
// { type: "delete", pos, count }

function apply(text, op) {
  if (op.type === "insert") {
    return text.slice(0, op.pos) + op.text + text.slice(op.pos);
  }
  if (op.type === "delete") {
    return text.slice(0, op.pos) + text.slice(op.pos + op.count);
  }
  return text;
}

// 把 opB 变换到 opA 之后执行,返回调整后的 opB
function transform(opA, opB) {
  if (opA.type === "insert") {
    if (opB.type === "insert") {
      // 同位置插入时,用 tie-break 规则保证双方一致
      if (opB.pos > opA.pos || (opB.pos === opA.pos && opB.tieBreaker > opA.tieBreaker)) {
        return { ...opB, pos: opB.pos + opA.text.length };
      }
      return { ...opB };
    }
    if (opB.type === "delete") {
      const pos = opB.pos >= opA.pos ? opB.pos + opA.text.length : opB.pos;
      return { ...opB, pos };
    }
  }

  if (opA.type === "delete") {
    if (opB.type === "insert") {
      const pos = opB.pos > opA.pos ? opB.pos - opA.count : opB.pos;
      return { ...opB, pos: Math.max(pos, opA.pos) };
    }
    if (opB.type === "delete") {
      // 两个删除区间可能重叠,需要计算剩余删除范围
      const start = Math.max(opB.pos, opA.pos);
      const endA = opA.pos + opA.count;
      const endB = opB.pos + opB.count;
      const overlap = Math.max(0, Math.min(endA, endB) - start);
      return { ...opB, count: opB.count - overlap };
    }
  }
  return opB;
}

// 验证收敛:A 与 B 并发,两个副本最终一致
function verify() {
  const base = "abc";
  const opA = { type: "insert", pos: 1, text: "X", tieBreaker: 1 };
  const opB = { type: "insert", pos: 2, text: "Y", tieBreaker: 2 };

  // 副本 1:先 A 后 B'
  const b1 = apply(apply(base, opA), transform(opA, opB));
  // 副本 2:先 B 后 A'
  const a2 = apply(apply(base, opB), transform(opB, opA));
  console.log(b1, a2, b1 === a2); // "aXbYc" "aXbYc" true
}

verify();

tieBreaker 是处理「同位置并发插入」的关键。当两个插入落在同一位置时,必须有确定性的规则决定谁在前,否则不同副本会得到不同顺序。

三、OT 的坐标系问题

OT 中最容易出错的是坐标系。操作的位置是相对于「操作发生时」的文档状态,而变换后要落到「变换后」的文档状态上,两者是不同的坐标系。

概念含义陷阱
绝对位置文档中的最终位置操作中不存储绝对位置
相对位置相对于当前状态的索引变换后需重新计算
变换基准操作基于哪个版本基准错了整个变换就错

工程上的做法是给每个操作附加「基准版本号」,服务端只对同一基准的操作做变换,跨版本的操作必须先补全中间历史。这是 OT 系统复杂度的主要来源。

四、ShareDB 实战

ShareDB 是成熟的 OT 框架,把变换、版本管理与持久化都封装好了。使用它时,你只需要定义操作的 apply 与 transform 函数。

import ShareDB from "sharedb";
import richText from "rich-text";

// 注册 OT 类型
ShareDB.types.register(richText.type);

const backend = new ShareDB();
const connection = backend.connect();

// 客户端:打开文档并监听
const doc = connection.get("documents", "doc-1");
doc.subscribe((err) => {
  if (err) throw err;
  console.log("当前内容:", doc.data);

  // 提交一个操作
  doc.submitOp([{ insert: "hello" }], { source: "user" });
});

// 服务端:监听操作,可用于审计或拦截
backend.use("submit", (context, next) => {
  const { op, collection, id } = context;
  console.log(`收到操作 ${collection}/${id}:`, op);
  next();
});

ShareDB 的服务端持有权威版本,客户端提交操作时附带自己的版本号,服务端据此变换并广播给其他客户端。这保证了所有客户端最终收敛到服务端的版本。

4.1 服务端状态管理

// 限制操作大小,防止恶意大操作拖垮服务
backend.use("submit", (context, next) => {
  const op = context.op;
  const size = JSON.stringify(op).length;
  if (size > 100000) {
    return next(new Error("操作过大,拒绝"));
  }
  next();
});

// 记录版本,用于回溯与审计
backend.on("submit", (context) => {
  console.log("版本:", context.snapshot.v, "操作:", context.op);
});

五、OT 与 CRDT 的选型

场景推荐理由
中心化文本编辑OT元数据小,服务器权威
离线优先、P2PCRDT无需中心排序
富文本高频编辑OTCRDT 元数据膨胀明显
复杂结构化状态CRDT数据结构更通用
需要完整历史回溯CRDT天然保留因果历史

判断的关键在于「是否有可信的中心服务器」。若有,OT 的元数据优势明显;若需要去中心或离线优先,CRDT 更合适。两者也可以混用:文档正文用 OT,光标与在线状态用 CRDT 式的 Awareness 协议。CRDT 的基础类型与实现见 实时协作 CRDT 原理 。

六、富文本与属性 OT

纯文本只有插入与删除,富文本还要处理格式属性(加粗、颜色、链接)。属性操作在 OT 中通常表示为对某个区间的属性设置:

// 富文本属性操作
const op = [
  { retain: 5 },
  { retain: 3, attributes: { bold: true } },
  { retain: 2 },
];

// 变换时,属性操作与插入删除需要互相调整区间
function transformAttributes(opA, opB) {
  // 若 opA 在某位置插入了字符,opB 的 retain 区间需要相应扩展
  // 这是富文本 OT 最容易出错的部分
  return opB;
}

富文本 OT 的难点在于:属性作用于区间,而插入删除会改变区间边界,变换时既要调整位置也要调整区间长度。这也是为什么许多富文本协同编辑器选择用 CRDT 的嵌套结构来表达格式。

七、客户端与服务端的同步时序

7.1 乐观本地应用

OT 的体验优势来自「乐观本地应用」:用户操作立即应用到本地文档,同时把操作发给服务端。服务端确认后返回权威版本,客户端据此校验并修正。

class OTClient {
  constructor(connection, docId) {
    this.version = 0;          // 当前已确认的版本号
    this.pending = [];         // 已发送但未确认的操作
    this.doc = connection.get("documents", docId);
  }

  // 本地操作:立即应用,同时发送
  submit(op) {
    this.doc.submitOp(op, { source: "local" });
    this.pending.push(op);
  }

  // 收到远端操作:变换后应用
  onRemoteOp(remoteOp) {
    // 把自己所有未确认的操作依次变换到远端操作之后
    let transformed = remoteOp;
    for (const op of this.pending) {
      transformed = transform(op, transformed);
    }
    apply(this.doc.data, transformed);
    this.version++;
  }
}

7.2 未确认操作的处理

关键难点在于「本地已应用但未确认的操作」与「新到达的远端操作」之间的变换。客户端必须维护一个未确认队列,收到远端操作时把队列里的操作逐个变换过去,才能保证本地状态与远端一致。

状态含义处理
已确认服务端已接受从队列移除
未确认已发送未收到回执保留在队列参与变换
待发送本地已应用未发送发送前先变换

7.3 断线重连的补偿

断线期间服务端可能已接受其他客户端的操作,重连后客户端必须先拉取缺失的历史,再重放自己的未确认操作:

async function reconnect(client) {
  const missing = await fetchOpsSince(client.docId, client.version);
  for (const op of missing) {
    client.onRemoteOp(op);
  }
  // 重放未确认操作
  for (const op of client.pending) {
    client.doc.submitOp(op, { source: "local" });
  }
}

八、操作压缩与快照

8.1 为什么需要压缩

OT 系统为每个操作保存历史,长期运行的文档会积累海量操作。若每次加载都从空文档重放全部历史,加载时间会随编辑次数线性增长。

方案做法代价
全量重放从初始状态重放所有操作加载慢
定期快照每隔 N 个操作存一次状态存储增加
操作合并把连续同类操作合并需保证语义等价

8.2 快照实现

class SnapshotStore {
  constructor(interval = 100) {
    this.interval = interval;
    this.snapshots = new Map(); // version -> state
  }

  maybeSnapshot(version, state) {
    if (version % this.interval === 0) {
      this.snapshots.set(version, structuredClone(state));
    }
  }

  // 加载时从最近快照开始,只重放之后的操作
  load(targetVersion, ops) {
    let baseVersion = 0;
    let state = null;
    for (const [v, s] of this.snapshots) {
      if (v <= targetVersion && v > baseVersion) {
        baseVersion = v;
        state = structuredClone(s);
      }
    }
    const remaining = ops.filter((o) => o.version > baseVersion);
    for (const op of remaining) {
      state = apply(state, op);
    }
    return state;
  }
}

快照间隔是加载速度与存储成本之间的权衡。间隔小则加载快但存储多,间隔大则相反。实践中常取 100 到 500 个操作为一个间隔。

8.3 操作合并的边界

操作合并能进一步减少历史长度,但必须保证合并后的操作与原始操作序列语义等价。例如连续两次 insert 在同一位置可以合并,但插入与删除交替时不能随意合并,否则会破坏变换的正确性。合并逻辑需要针对具体操作类型逐一验证,不能套用通用规则。

九、常见坑清单

  • 变换函数未处理同位置并发插入,导致不同副本顺序不一致。
  • 操作未携带基准版本号,跨版本变换时坐标系错乱。
  • 删除操作与插入操作重叠时,未正确计算剩余删除范围。
  • 服务端不校验操作,恶意客户端提交超大操作拖垮服务。
  • 只实现 TP1 就上线,三方并发时暴露不收敛问题。
  • 富文本属性变换忽略区间长度调整,格式错位。
  • 客户端直接修改本地文档而不经过服务端,导致版本分叉。
  • 忽略网络重发,同一操作被应用两次造成重复插入。

小结

OT 用「变换」把并发操作调整到可任意顺序重放,从而保证收敛。它的元数据开销小、适合中心化架构,是传统协同编辑的主流方案。但变换函数的正确性证明困难,TP2 条件在多操作并发下极难满足,这是 OT 实现的最大风险。工程上应优先使用 ShareDB 这类成熟框架,避免自行实现变换逻辑。选型时若需要离线优先或去中心,应转向 实时协作 CRDT 原理 ;若做白板与光标这类高频状态同步,可参考 在线白板与光标同步实战 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播