Skip to content

如何实现操作转换(Operational Transformation, OT)? #126

@pfan123

Description

@pfan123

实现操作转换(OT)是一个复杂但富有挑战性的过程。OT 的核心目标是在多用户实时编辑时,通过转换并发操作,保证所有用户最终看到一致的文档状态。以下是 OT 的实现框架、核心算法和关键步骤,帮助你理解如何从零构建一个简单的 OT 系统。

OT 的核心概念

在实现 OT 之前,需明确以下关键概念:

  1. 操作(Operation):用户对文档的修改行为(如插入、删除、格式调整),需包含:

    • type:操作类型(insertdeleteformat 等);
    • position:操作在文档中的位置(索引);
    • content:操作内容(如插入的文本、格式类型);
    • version:操作基于的文档版本(用于冲突检测)。
  2. 文档状态(Document State):文档的当前内容(如文本字符串、JSON 结构),每个状态对应一个唯一版本号。

  3. 操作转换(Transformation):当两个用户的操作并发时,需将后收到的操作转换为「与先执行操作兼容」的新操作,再应用到当前文档。

  4. 版本向量(Version Vector):记录每个用户的操作历史,用于更精细的冲突检测(可选,适用于分布式场景)。

OT 的核心算法:操作转换函数

OT 的核心是转换函数transform),它接收两个并发操作(op1op2),返回转换后的新操作(op1'op2'),确保两者先后执行的结果一致。

转换函数需处理不同操作类型的组合(如 insertinsertinsertdeletedeletedelete),以下是最常见的文本编辑场景的转换规则:

1. 文本编辑的操作定义

假设文档是字符串,操作类型为 insertdelete

  • insert(pos, text):在位置 pos 插入文本 text
  • delete(pos, length):从位置 pos 开始删除 length 个字符。

2. 转换规则(核心)

转换函数的设计需遵循「先执行一个操作,再转换另一个操作,结果与顺序无关」的原则。以下是具体规则:

规则 1:insert 与 insert 冲突

当两个 insert 操作在同一位置插入内容时,转换规则为:

  • 假设 op1 = insert(p1, t1)op2 = insert(p2, t2)
  • p1 < p2op2 的插入位置需后移 len(t1)(因为 op1 插入的内容占了 len(t1) 个位置),即 op2' = insert(p2 + len(t1), t2)op1 无需转换(op1' = op1);
  • p1 > p2op1 的插入位置需后移 len(t2),即 op1' = insert(p1 + len(t2), t1)op2 无需转换(op2' = op2);
  • p1 == p2:按用户 ID 或操作时间戳排序(避免歧义),后插入的操作位置后移前一个操作的文本长度。

示例

  • 初始文档:"ABC"(版本 0);
  • op1 = insert(2, "X")(在位置 2 插入 "X");
  • op2 = insert(2, "Y")(在位置 2 插入 "Y");
  • 转换后:op1' = insert(2, "X")op2' = insert(3, "Y")(因为 op1 插入的 "X" 占 1 个位置,op2 的位置从 2 变为 3);
  • 执行顺序 1:op1'op2' → 文档变为 "AXYC"
  • 执行顺序 2:op2op1'op1 转换为 insert(3, "X"))→ 文档变为 "AYXC"? 这里存在歧义,需补充规则:当 p1 == p2 时,按预设优先级(如用户 ID 小的在前),确保转换后顺序一致。例如,若 op1 的用户 ID 小于 op2,则 op2 位置后移 len(t1),最终结果统一为 "AXYC"

规则 2:insert 与 delete 冲突

假设 op1 = insert(p1, t1)op2 = delete(p2, l2)

  • p1 <= p2op2 的删除位置需后移 len(t1)(因为 op1 插入的内容在 op2 删除位置之前,占了 len(t1) 个位置),即 op2' = delete(p2 + len(t1), l2)op1 无需转换;
  • p1 > p2
    • p1 <= p2 + l2op1 的插入位置需前移 l2(因为 op2 删除的内容覆盖了 op1 插入位置的一部分,删除后 op1 位置提前),即 op1' = insert(p1 - l2, t1)op2 无需转换;
    • p1 > p2 + l2op1op2 无重叠,无需转换。

示例

  • 初始文档:"ABCDE"(版本 0);
  • op1 = insert(3, "X")(在位置 3 插入 "X");
  • op2 = delete(1, 2)(从位置 1 删除 2 个字符,即删除 "BC");
  • 分析:p1 = 3p2 = 1l2 = 2p1 > p2p1 <= p2 + l2(3 <= 1+2=3);
  • 转换后:op1' = insert(3 - 2, "X") = insert(1, "X")op2' = delete(1, 2)
  • 执行顺序 1:op2' → 删除 "BC" 后文档变为 "ADE" → 执行 op1' → 插入 "X" 后变为 "AXDE"
  • 执行顺序 2:op1 → 插入 "X" 后文档变为 "ABXCDE" → 执行 op2 → 删除位置 1 的 2 个字符("BX")→ 变为 "ACDE"? 这里明显矛盾,说明转换规则需调整。正确的逻辑应该是:当 op2 是删除操作时,若 op1 的插入位置在 op2 的删除范围内(p2 <= p1 <= p2 + l2),则 op1 被删除,无需转换;若在删除范围之后,则 op1 位置前移 l2。重新分析:
    • op2 = delete(1, 2) 删除的是位置 1-2("BC");
    • op1 = insert(3, "X") 的位置 3 在删除范围之后(1+2=3),所以 op1 位置前移 2 → insert(1, "X")
    • 执行顺序 2:op1"ABXCDE"op2 删除位置 1-2("BX")→ "ACDE",这与顺序 1 的结果不一致,说明规则需优化。正确的转换规则应为:delete 操作会影响后续 insert 的位置,而 insert 操作不会影响 delete 的位置(因为 delete 是删除已有内容,insert 是新增内容)。因此,正确的转换应为:
      • op1 是 insert,op2 是 delete 时:
        • p1 < p2op2 位置后移 len(t1)
        • p1 >= p2op2 位置不变(因为 delete 是删除 op1 之前或之后的内容,不影响 op1 的插入位置);
        • op1 的位置:若 p1 > p2 + l2,则前移 l2;否则不变(因为 op1 插入在 delete 范围之后,delete 会缩短文档长度)。
          修正后示例:
    • op1 = insert(3, "X")op2 = delete(1, 2)
    • op2 转换:p2 = 1op1p1 = 3 > p2,所以 op2 位置不变 → op2' = delete(1, 2)
    • op1 转换:p1 = 3 > p2 + l2 = 3? 不,p2 + l2 = 1 + 2 = 3p1 等于 3,属于 delete 范围的末尾,所以 op1 位置前移 l2insert(1, "X")
    • 执行顺序 1:op2'"ADE"op1'"AXDE"
    • 执行顺序 2:op1"ABXCDE"op2 转换为 delete(1, 2)(因为 op1 插入在位置 3,op2 的删除位置 1 不受影响)→ 删除 "BC" → "AXCDE"。 显然仍有问题,这说明 OT 转换规则的设计需要更严谨的数学证明,实际实现中建议参考成熟的 OT 算法(如 Google Wave 的 OT 算法)。

规则 3:delete 与 delete 冲突

假设 op1 = delete(p1, l1)op2 = delete(p2, l2)

  • p1 + l1 <= p2:无重叠,无需转换;
  • p2 + l2 <= p1:无重叠,无需转换;
  • 若有重叠:
    • 取两个删除范围的交集 [max(p1, p2), min(p1 + l1, p2 + l2))
    • op1 的删除范围在 op2 之前(p1 < p2):op2 的删除位置需前移 l1 - overlap_length(因为 op1 删除了部分内容,op2 的位置提前);
    • op2 的删除范围在 op1 之前(p2 < p1):op1 的删除位置需前移 l2 - overlap_length
    • 若完全重叠:后执行的操作删除长度为 0(无需操作)。

示例

  • 初始文档:"ABCDEF"(版本 0);
  • op1 = delete(1, 3)(删除位置 1-3,即 "BCD");
  • op2 = delete(2, 2)(删除位置 2-3,即 "CD");
  • 重叠范围:[2, 3)(长度 1);
  • 转换 op2p2 = 2op1 删除了位置 1-3,op2 的位置在 op1 范围内,所以 op2 的删除长度变为 2 - 1 = 1(重叠部分已被 op1 删除),即 op2' = delete(2, 1)(但 op1 执行后文档变为 "AEF",位置 2 已无内容,所以 op2' 实际无效);
  • 转换 op1p1 = 1op2 删除了位置 2-3,op1 的删除范围包含 op2,所以 op1 的删除长度变为 3 - 2 = 1op2 已删除 2 个字符),即 op1' = delete(1, 1)(删除 "B");
  • 执行顺序 1:op1' → 删除 "B" 后文档变为 "ACDEF" → 执行 op2' → 删除位置 2 的 1 个字符("D")→ 变为 "ACEF"
  • 执行顺序 2:op2 → 删除 "CD" 后文档变为 "ABEF" → 执行 op1' → 删除位置 1 的 1 个字符("B")→ 变为 "AEF"。 这里仍有问题,说明 delete-delete 冲突的转换规则最复杂,需结合具体场景细化。

OT 的实现步骤(简化版)

以下是一个简化的 OT 系统实现框架,包含「操作定义、转换函数、文档状态管理、网络同步」四个核心模块:

1. 定义操作类(Operation)

class Operation {
  constructor(type, position, content, version) {
    this.type = type; // "insert" 或 "delete"
    this.position = position; // 操作位置
    this.content = content; // 插入的文本(insert)或删除的长度(delete)
    this.version = version; // 操作基于的文档版本
  }

  // 序列化操作(用于网络传输)
  toJSON() {
    return {
      type: this.type,
      position: this.position,
      content: this.content,
      version: this.version,
    };
  }

  // 反序列化操作
  static fromJSON(json) {
    return new Operation(json.type, json.position, json.content, json.version);
  }
}

2. 实现转换函数(transform)

function transform(op1, op2) {
  if (op1.type === "insert" && op2.type === "insert") {
    // insert 与 insert 冲突
    if (op1.position < op2.position) {
      // op2 位置后移 op1 文本长度
      return [op1, new Operation("insert", op2.position + op1.content.length, op2.content, op2.version)];
    } else if (op1.position > op2.position) {
      // op1 位置后移 op2 文本长度
      return [new Operation("insert", op1.position + op2.content.length, op1.content, op1.version), op2];
    } else {
      // 位置相同,按用户 ID 排序(假设 op1.userId < op2.userId)
      return [op1, new Operation("insert", op2.position + op1.content.length, op2.content, op2.version)];
    }
  } else if (op1.type === "insert" && op2.type === "delete") {
    // insert 与 delete 冲突
    if (op1.position <= op2.position) {
      // op2 位置后移 op1 文本长度
      return [op1, new Operation("delete", op2.position + op1.content.length, op2.content, op2.version)];
    } else {
      // op1 位置前移 op2 删除长度(若在删除范围之后)
      const newPosition = op1.position > op2.position + op2.content ? op1.position - op2.content : op1.position;
      return [new Operation("insert", newPosition, op1.content, op1.version), op2];
    }
  } else if (op1.type === "delete" && op2.type === "insert") {
    // delete 与 insert 冲突(对称于 insert 与 delete)
    if (op2.position <= op1.position) {
      // op1 位置后移 op2 文本长度
      return [new Operation("delete", op1.position + op2.content.length, op1.content, op1.version), op2];
    } else {
      // op2 位置前移 op1 删除长度(若在删除范围之后)
      const newPosition = op2.position > op1.position + op1.content ? op2.position - op1.content : op2.position;
      return [op1, new Operation("insert", newPosition, op2.content, op2.version)];
    }
  } else if (op1.type === "delete" && op2.type === "delete") {
    // delete 与 delete 冲突(简化版)
    const op1End = op1.position + op1.content;
    const op2End = op2.position + op2.content;
    const overlapStart = Math.max(op1.position, op2.position);
    const overlapEnd = Math.min(op1End, op2End);
    const overlapLength = overlapEnd - overlapStart;

    if (overlapLength <= 0) {
      // 无重叠,无需转换
      return [op1, op2];
    } else {
      // 有重叠,调整后执行的操作长度
      if (op1.position < op2.position) {
        // op2 先执行,op1 调整长度
        const newOp1Content = op1.content - overlapLength;
        return [new Operation("delete", op1.position, newOp1Content, op1.version), op2];
      } else {
        // op1 先执行,op2 调整长度
        const newOp2Content = op2.content - overlapLength;
        return [op1, new Operation("delete", op2.position, newOp2Content, op2.version)];
      }
    }
  }
}

3. 文档状态管理(Document)

class Document {
  constructor(initialContent = "") {
    this.content = initialContent; // 文档内容
    this.version = 0; // 文档当前版本
    this.pendingOperations = []; // 待确认的本地操作(网络同步时使用)
  }

  // 应用操作到文档
  applyOperation(op) {
    if (op.version !== this.version) {
      throw new Error("操作版本不匹配,需先转换");
    }

    if (op.type === "insert") {
      this.content = this.content.slice(0, op.position) + op.content + this.content.slice(op.position);
    } else if (op.type === "delete") {
      this.content = this.content.slice(0, op.position) + this.content.slice(op.position + op.content);
    }

    this.version++;
    return this.content;
  }

  // 处理远程操作(需先转换)
  processRemoteOperation(remoteOp) {
    // 1. 检查本地是否有待确认的操作(本地操作已执行,但服务器未响应)
    const transformedOps = [];
    let currentOp = remoteOp;

    // 2. 对每个待确认的本地操作,转换远程操作
    for (const localOp of this.pendingOperations) {
      const [transformedLocal, transformedRemote] = transform(localOp, currentOp);
      transformedOps.push(transformedLocal);
      currentOp = transformedRemote;
    }

    // 3. 应用转换后的远程操作
    this.applyOperation(currentOp);

    // 4. 更新待确认的本地操作(若有)
    this.pendingOperations = transformedOps;

    return this.content;
  }

  // 发送本地操作到服务器(乐观更新)
  sendLocalOperation(op) {
    // 1. 本地先应用操作(乐观更新)
    this.applyOperation(op);

    // 2. 将操作加入待确认队列
    this.pendingOperations.push(op);

    // 3. 发送到服务器(网络请求)
    // fetch("/api/operations", { method: "POST", body: JSON.stringify(op) });

    return this.content;
  }

  // 收到服务器确认(移除待确认操作)
  confirmLocalOperation(opId) {
    this.pendingOperations = this.pendingOperations.filter(op => op.id !== opId);
  }
}

4. 网络同步(服务器端)

服务器的核心职责是:

  1. 接收用户发送的操作;
  2. 广播操作到其他用户;
  3. 维护操作日志(用于新用户加入时重放历史操作)。

简化的服务器伪代码:

// 服务器端:维护所有操作日志
const operationLog = [];

// 接收客户端操作
function handleClientOperation(op) {
  // 1. 验证操作版本(可选,确保操作基于最新版本)
  if (op.version !== operationLog.length) {
    // 版本不匹配,返回冲突信息,客户端需重新转换
    return { status: "conflict", currentVersion: operationLog.length };
  }

  // 2. 将操作加入日志
  operationLog.push(op);

  // 3. 广播操作到所有其他客户端
  broadcastToClients(op);

  return { status: "ok" };
}

// 新用户加入:返回历史操作日志
function getHistoryOperations() {
  return operationLog;
}

OT 的挑战与优化

  1. 复杂的转换规则:OT 的转换函数需覆盖所有操作类型的组合(insert/delete/format 等),且需严格保证一致性,调试难度大。实际实现中建议参考成熟的 OT 库(如 ot.jsShareDB)。

  2. 版本管理:分布式场景下,需使用版本向量(而非单一版本号)记录每个用户的操作历史,避免版本冲突。

  3. 性能优化

    • 操作合并:将连续的小操作(如连续输入多个字符)合并为一个批量操作,减少网络传输和转换开销;
    • 操作压缩:对操作日志进行压缩(如删除已被覆盖的旧操作),降低存储和传输成本。
  4. 用户体验

    • 乐观更新:本地操作立即生效,无需等待服务器响应;
    • 远程光标:显示其他用户的光标位置,减少冲突概率;
    • 冲突提示:当自动转换失败时,提示用户手动解决冲突。

总结

OT 的实现核心是「转换函数」,它通过调整并发操作的位置和长度,确保所有用户最终看到一致的文档状态。虽然简化版的 OT 系统可以通过上述步骤实现,但生产环境中建议使用成熟的 OT 库(如 ShareDBot.js),避免重复造轮子和潜在的一致性问题。

如果需要处理更复杂的场景(如富文本格式、分布式协同),可以进一步研究 Google Wave 的 OT 算法、Etherpad 的实现,或转向 CRDT(冲突无关复制数据类型)方案(如 Yjs),后者的实现逻辑更简单,对网络延迟的容忍度更高。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions