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 | 元数据小,服务器权威 |
| 离线优先、P2P | CRDT | 无需中心排序 |
| 富文本高频编辑 | OT | CRDT 元数据膨胀明显 |
| 复杂结构化状态 | 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 原理 ;若做白板与光标这类高频状态同步,可参考 在线白板与光标同步实战 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。