编程 从零手写一个 CRDT 协同编辑引擎:吃透 YATA 冲突消解、状态向量增量同步与墓碑 GC,造一个能离线合并的 Google Docs(附完整 TypeScript 实现)

2026-08-01 12:48:29 +0800 CST views 11

从零手写一个 CRDT 协同编辑引擎:吃透 YATA 冲突消解、状态向量增量同步与墓碑 GC,造一个能离线合并的 Google Docs(附完整 TypeScript 实现)

这篇不讲"CRDT 是什么"的科普。我们直接从第一性原理出发,把一个能跑的序列 CRDT 引擎从零撸出来:数据结构怎么设计、并发插入到底怎么定序、增量同步的报文怎么压到几十字节、墓碑为什么会把内存吃穿、富文本的格式标记为什么不能当普通字符处理。全文代码可运行,最后附一份生产踩坑清单。


一、先把问题问对:协同编辑到底难在哪

大部分人第一次做多人协作,思路是这样的:

客户端改了 → 发整篇文档给服务端 → 服务端广播 → 别人覆盖本地

这套东西在 demo 里跑得挺好,上线第一天就会炸。因为它有个致命假设:同一时刻只有一个人在改

真实场景是这样的:

  • 用户 A 在第 5 个字符位置插入 "Hello"
  • 用户 B 在同一时刻,也在第 5 个字符位置插入 "World"
  • 两条消息在网络里错身而过

服务端先收到谁的?客户端先应用谁的?如果 A 的客户端先应用自己的再应用 B 的,得到 HelloWorld;B 的客户端先应用自己的再应用 A 的,得到 WorldHello两边永久分叉,而且没有任何机制能发现这件事——因为双方都认为自己是对的。

1.1 索引是会漂移的

问题的根源在于:"位置 5" 不是一个稳定的标识

初始:  [A][B][C][D][E][F]
索引:   0  1  2  3  4  5

用户 B 在索引 1 删了一个字符:
       [A][C][D][E][F]
索引:   0  1  2  3  4

此时用户 A 那条 "在索引 5 插入 X" 的操作到了。
索引 5 现在是什么?已经越界了。

只要文档在变,任何基于绝对索引的操作都是过期的。这就是协同编辑的核心矛盾:操作是在旧状态上产生的,却要应用到新状态上

1.2 两条技术路线:OT 与 CRDT

历史上有两条解法:

OT(Operational Transformation,操作变换):不改索引的表示方式,而是在应用远端操作前,用一个 transform 函数把它"改造"成适配当前状态的版本。

op_A = insert(5, "Hello")   // 基于版本 v1 产生
本地已经应用了 op_B = delete(1, 1)  // 本地已到 v2

transform(op_A, op_B) => insert(4, "Hello")   // 索引减 1

Google Docs 走的这条路。优点是数据结构极其紧凑(就是普通字符串 + 一堆操作),缺点是:

  1. transform 函数的组合爆炸。两个操作类型有 N 种,就要写 N² 个变换规则。加个"设置格式"操作,规则数量平方级增长。
  2. 强依赖中心服务器定序。纯 P2P 的 OT(如 TP2 性质)被证明极难正确实现,历史上多篇论文的算法后来都被找出反例。
  3. 离线时间越长越危险。要 transform 的操作链越长,出 bug 的概率越高。

CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型):换个思路——不改操作,改数据结构本身。让数据结构自带"合并"语义,使得:

merge(merge(S, a), b) == merge(merge(S, b), a)   // 交换律
merge(merge(S, a), a) == merge(S, a)             // 幂等性
merge(merge(S,a),b),c) == merge(S, merge(a, merge(b,c)))  // 结合律

只要满足这三条,无论以什么顺序、重复多少次收到操作,所有副本最终必然收敛到同一状态。这是数学保证,不是工程约定。

代价是:数据结构里必须携带额外的元数据(每个字符都要有唯一 ID、要记住谁在谁左边),内存和序列化体积都会变大。这是一场用空间换正确性的交易

本文走 CRDT 这条路,而且是从最朴素的结构一步步演进到工业级实现。


二、从最小的 CRDT 开始:三个玩具

在碰序列(文本)这个硬骨头之前,先用三个 5 分钟就能写完的玩具建立直觉。

2.1 G-Counter:只增计数器

需求:多个节点各自 +1,最终所有节点看到相同的总数。

朴素做法(存一个 count,同步时取最大值)是错的:A 加了 3 次(count=3),B 加了 5 次(count=5),取 max 得 5,但正确答案是 8。

CRDT 做法:不存聚合值,存每个节点各自的贡献

class GCounter {
  private counts = new Map<string, number>();

  constructor(private nodeId: string) {}

  increment(n = 1): void {
    this.counts.set(this.nodeId, (this.counts.get(this.nodeId) ?? 0) + n);
  }

  get value(): number {
    let sum = 0;
    for (const v of this.counts.values()) sum += v;
    return sum;
  }

  // 关键:merge 是逐 key 取 max
  merge(other: GCounter): void {
    for (const [id, v] of other.counts) {
      this.counts.set(id, Math.max(this.counts.get(id) ?? 0, v));
    }
  }
}

为什么 max 能满足三条定律?因为 max 本身就是交换、结合、幂等的(数学上叫半格 / semilattice 的 join 操作)。CRDT 设计的本质,就是想办法把你的业务状态映射成一个半格。

2.2 LWW-Register:最后写入胜出

需求:一个可覆盖的值(比如文档标题)。

type Timestamp = { lamport: number; nodeId: string };

function tsGreater(a: Timestamp, b: Timestamp): boolean {
  if (a.lamport !== b.lamport) return a.lamport > b.lamport;
  return a.nodeId > b.nodeId; // 平局用 nodeId 打破,保证全序
}

class LWWRegister<T> {
  private ts: Timestamp;
  private val: T;
  private clock = 0;

  constructor(private nodeId: string, init: T) {
    this.val = init;
    this.ts = { lamport: 0, nodeId };
  }

  set(v: T): void {
    this.clock++;
    this.val = v;
    this.ts = { lamport: this.clock, nodeId: this.nodeId };
  }

  merge(other: LWWRegister<T>): void {
    // 先推进本地逻辑时钟,保证后续写入一定"更新"
    this.clock = Math.max(this.clock, other.ts.lamport);
    if (tsGreater(other.ts, this.ts)) {
      this.val = other.val;
      this.ts = other.ts;
    }
  }

  get value(): T { return this.val; }
}

这里出现了本文最重要的一个基础设施:Lamport 逻辑时钟

用物理时间戳(Date.now())做冲突消解是新手最常见的坑:客户端时钟不同步、可能被用户手动改、NTP 会回拨。永远不要用墙上时钟做分布式定序。

Lamport 时钟的规则只有两条:

  1. 本地产生事件:clock++
  2. 收到远端消息:clock = max(clock, remoteClock) + 1

它保证:如果事件 a 因果先于事件 b(a → b),那么 clock(a) < clock(b)。反过来不成立——时钟小不代表因果在前,可能只是并发。所以还需要 nodeId 来打破平局,把偏序补成全序。

2.3 2P-Set:能删的集合

G-Set(只增集合)很简单,merge 就是并集。但一旦要支持删除,麻烦就来了:

副本 A: {x}  → 删除 x → {}
副本 B: {x}
merge:  {} ∪ {x} = {x}   ← x 复活了

解法是墓碑(tombstone):删除不是真删,而是记到另一个集合里。

class TwoPhaseSet<T> {
  private added = new Set<T>();
  private removed = new Set<T>(); // 墓碑集合

  add(v: T): void { this.added.add(v); }
  delete(v: T): void { if (this.added.has(v)) this.removed.add(v); }
  has(v: T): boolean { return this.added.has(v) && !this.removed.has(v); }

  merge(other: TwoPhaseSet<T>): void {
    for (const v of other.added) this.added.add(v);
    for (const v of other.removed) this.removed.add(v);
  }
}

记住"墓碑"这个词,它会贯穿全文,也是 CRDT 内存问题的总根源。 2P-Set 的墓碑永远不能删(删了就可能复活),这直接决定了后面序列 CRDT 的 GC 有多难搞。


三、序列 CRDT:三个流派与它们的取舍

文本编辑本质是一个有序序列的 CRDT。这是所有 CRDT 里最难的一类,因为"顺序"这个约束比"集合"强太多了。

历史上有三个主要流派。

3.1 流派一:Logoot / LSEQ —— 用稠密位置标识

思路:不给字符编"第几个",而是给一个可以无限细分的实数式坐标

"A" 位置 [1]
"B" 位置 [3]
想在 A B 之间插入 C → 位置 [2]
再在 A C 之间插入 D → [1, 5]   // 一层不够就加一层

因为任意两个位置之间总能生成新位置(像有理数稠密),所以插入永远不会冲突。删除就是真删(不需要墓碑!)。

优点:无墓碑,删除是真删除,长期内存友好。

致命缺点位置标识会无限膨胀。用户在同一个位置反复插入(比如从左往右打字),坐标会退化成 [1,5,3,7,2,9,...] 这样几十层深的数组。一个字符的元数据比字符本身大几十倍。LSEQ 用「随机策略交替」缓解了这个问题,但没根治。

结论:理论优雅,工程上基本被淘汰。

3.2 流派二:RGA —— 因果链 + 墓碑

RGA(Replicated Growable Array)思路:每个字符记住"我插在谁的右边"

文档 "ABC" 的内部结构(链表):
HEAD → A(id=1@u1) → B(id=2@u1) → C(id=3@u1)

用户 u2 在 A 后面插 X: X.origin = A.id
用户 u3 在 A 后面插 Y: Y.origin = A.id   // 并发!

冲突消解规则:两个 origin 相同的节点,按 ID 降序排列(时间戳大的在前)。这保证了所有副本得到相同的顺序。

删除用墓碑标记(deleted = true),因为后来的插入可能以被删节点为 origin。

优点:结构直观,元数据固定大小(一个 ID)。
缺点:有一个著名的交错异常(interleaving anomaly)

假设 A 和 B 在同一位置并发输入长句:

A 输入: "Hello"
B 输入: "World"

RGA 可能合并出: "HWeolrllod"

原因是 RGA 只看直接 origin,两个人的字符会像洗牌一样交错。这在真实产品里是灾难——用户会看到自己刚打的一句话被切碎。

3.3 流派三:YATA —— Yjs 的答案

YATA(Yet Another Transformation Approach,Kevin Jahns 提出,Yjs 的核心算法)在 RGA 基础上加了关键一笔:每个节点同时记住左右两个 origin

interface Item {
  id: ID;              // 唯一标识 (clientId, clock)
  origin: ID | null;   // 左原点:插入时我左边那个节点的 id
  rightOrigin: ID | null; // 右原点:插入时我右边那个节点的 id
  content: string;
  deleted: boolean;
  left: Item | null;   // 当前实际的左邻居(会变)
  right: Item | null;  // 当前实际的右邻居(会变)
}

origin / rightOrigin 记录的是插入那一刻的意图("我要插在这两个东西中间"),而 left / right 是当前链表的实际结构。有了右原点,YATA 就能识别出"这一整段是同一个人连续输入的",从而把并发的两段文本整体排在一起,而不是交错

这就是我们要实现的算法。下面开始写代码。


四、动手:实现 YATA 核心

4.1 基础类型

// ---------- ID 与状态向量 ----------

export interface ID {
  readonly client: number; // 客户端唯一编号
  readonly clock: number;  // 该客户端的单调递增计数
}

export function createID(client: number, clock: number): ID {
  return { client, clock };
}

export function idEq(a: ID | null, b: ID | null): boolean {
  if (a === null || b === null) return a === b;
  return a.client === b.client && a.clock === b.clock;
}

/**
 * 状态向量:记录"我已经见过每个 client 的哪些 clock"
 * client -> 下一个未见过的 clock(即已见过 [0, clock) )
 */
export type StateVector = Map<number, number>;

注意 clock 的语义:它不是"操作序号",而是字符偏移量。如果一个 Item 携带 5 个字符,它占据 clock 区间 [clock, clock+5)。这是 Yjs 的一个关键优化,后面会讲。

4.2 Item:链表节点

export class Item {
  id: ID;
  origin: ID | null;      // 左原点(插入时刻的左邻居 id)
  rightOrigin: ID | null; // 右原点(插入时刻的右邻居 id)
  content: string;
  deleted = false;

  left: Item | null = null;
  right: Item | null = null;

  constructor(
    id: ID,
    origin: ID | null,
    rightOrigin: ID | null,
    content: string,
  ) {
    this.id = id;
    this.origin = origin;
    this.rightOrigin = rightOrigin;
    this.content = content;
  }

  get length(): number { return this.content.length; }

  /** 该 item 占据的 clock 区间末尾(不含) */
  get clockEnd(): number { return this.id.clock + this.length; }

  /** 最后一个字符的 id —— 后继节点的 origin 会指向它 */
  get lastId(): ID { return createID(this.id.client, this.clockEnd - 1); }
}

4.3 StructStore:按 client 分片存储

我们需要能通过 ID 快速找到 Item。用一个 Map<clientId, Item[]>,每个数组按 clock 有序,二分查找。

export class StructStore {
  clients = new Map<number, Item[]>();

  getItems(client: number): Item[] {
    let arr = this.clients.get(client);
    if (!arr) { arr = []; this.clients.set(client, arr); }
    return arr;
  }

  /** 该 client 下一个可用的 clock */
  getNextClock(client: number): number {
    const arr = this.clients.get(client);
    if (!arr || arr.length === 0) return 0;
    return arr[arr.length - 1].clockEnd;
  }

  /** 二分查找包含指定 clock 的 item 在数组中的下标 */
  private findIndex(arr: Item[], clock: number): number {
    let lo = 0, hi = arr.length - 1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      const it = arr[mid];
      if (clock < it.id.clock) hi = mid - 1;
      else if (clock >= it.clockEnd) lo = mid + 1;
      else return mid;
    }
    throw new Error(`clock ${clock} not found`);
  }

  find(id: ID): Item {
    const arr = this.clients.get(id.client);
    if (!arr) throw new Error(`client ${id.client} unknown`);
    return arr[this.findIndex(arr, id.clock)];
  }

  /**
   * 关键操作:把 item 从 clock 处劈成两半,返回右半部分。
   * 场景:远端 item 的 origin 指向本地某个 item 的"中间某个字符"。
   */
  getItemCleanStart(id: ID): Item {
    const arr = this.clients.get(id.client)!;
    const idx = this.findIndex(arr, id.clock);
    const item = arr[idx];
    if (item.id.clock === id.clock) return item; // 已经是干净起点

    const offset = id.clock - item.id.clock;
    const right = new Item(
      createID(item.id.client, id.clock),
      createID(item.id.client, id.clock - 1), // 右半的左原点 = 左半最后一个字符
      item.rightOrigin,
      item.content.slice(offset),
    );
    right.deleted = item.deleted;

    // 修剪左半
    item.content = item.content.slice(0, offset);

    // 链表接线
    right.left = item;
    right.right = item.right;
    if (item.right) item.right.left = right;
    item.right = right;

    arr.splice(idx + 1, 0, right);
    return right;
  }

  getItemCleanEnd(id: ID): Item {
    const arr = this.clients.get(id.client)!;
    const idx = this.findIndex(arr, id.clock);
    const item = arr[idx];
    if (id.clock === item.clockEnd - 1) return item;
    // 在 clock+1 处切开,返回左半
    this.getItemCleanStart(createID(id.client, id.clock + 1));
    return arr[idx];
  }

  getStateVector(): StateVector {
    const sv: StateVector = new Map();
    for (const [client, arr] of this.clients) {
      if (arr.length > 0) sv.set(client, arr[arr.length - 1].clockEnd);
    }
    return sv;
  }
}

getItemCleanStart 是整个实现里最容易写错的地方。 因为我们允许一个 Item 携带多个字符("Hello" 是一个 Item 而不是 5 个),当远端操作要插在 "Hello"ll 之间时,就必须先把它劈开。这个"劈开"必须同时维护三处:内容、链表指针、StructStore 数组。漏一个就是隐性数据损坏,而且往往几百次操作后才暴露。

4.4 核心中的核心:integrate 冲突消解

现在到了 YATA 的灵魂。当一个远端 Item 到达时,我们知道它的 originrightOrigin,需要决定它在当前链表里的确切位置。

问题在于:originrightOrigin 之间可能已经被别人插了一堆东西(并发插入)。我们要在这个"冲突区间"里找到正确的落点。

export class Doc {
  store = new StructStore();
  /** 哨兵:链表头(不含内容) */
  private head: Item;
  clientId: number;

  constructor(clientId: number) {
    this.clientId = clientId;
    this.head = new Item(createID(-1, 0), null, null, "");
  }

  /**
   * YATA 集成算法:把 item 插入链表正确位置
   */
  integrate(item: Item): void {
    // 1) 定位左右边界
    let left: Item | null = item.origin
      ? this.store.getItemCleanEnd(item.origin)
      : null;
    const right: Item | null = item.rightOrigin
      ? this.store.getItemCleanStart(item.rightOrigin)
      : null;

    // 2) 扫描冲突区间
    let o: Item | null = left ? left.right : this.head.right;
    let conflictStart: Item | null = o;
    const itemsBefore = new Set<number>(); // 记录已"跳过"的 origin client

    while (o !== null && o !== right) {
      itemsBefore.add(o.id.client);

      const oLeftIsLeft = idEq(o.origin, item.origin);
      const oRightIsRight = idEq(o.rightOrigin, item.rightOrigin);

      if (oLeftIsLeft) {
        // 同一左原点 → 真正的并发冲突,按 clientId 定序(全序,保证收敛)
        if (o.id.client < item.id.client) {
          left = o;
          conflictStart = o.right;
        } else if (oRightIsRight) {
          // 左右原点都相同且 clientId 更大 → item 排在 o 前面,结束
          break;
        }
        // 否则:o 的右原点不同,说明 o 是别人更"内层"的插入,跳过继续扫
      } else if (o.origin !== null && this.isBetween(o.origin, item.origin, o)) {
        // o 的左原点落在我们的冲突区间内部 → o 属于某个子分支,跳过
        if (!itemsBefore.has(o.origin.client)) break;
      } else {
        break;
      }
      o = o.right;
    }

    // 3) 接线
    this.insertAfter(left, item);
    void conflictStart;
  }

  private isBetween(origin: ID, itemOrigin: ID | null, _o: Item): boolean {
    // 简化判定:o 的 origin 不是我们的 origin,但在扫描区间内已出现过
    // 生产实现应比较 origin 在链表中的实际位置
    return itemOrigin === null ? true : origin.client !== itemOrigin.client
      || origin.clock !== itemOrigin.clock;
  }

  private insertAfter(left: Item | null, item: Item): void {
    const right = left ? left.right : this.head.right;
    item.left = left;
    item.right = right;
    if (left) left.right = item; else this.head.right = item;
    if (right) right.left = item;

    // 登记到 StructStore
    const arr = this.store.getItems(item.id.client);
    arr.push(item);
    arr.sort((a, b) => a.id.clock - b.id.clock); // 生产实现用插入排序/尾部追加
  }
}

这段代码值得逐行理解,因为它是"为什么 CRDT 能收敛"的全部秘密。

关键点有三个:

第一,o.id.client < item.id.client 这个比较是全序的。 所有副本执行同样的比较,得到同样的结果。这就是收敛的数学基础——冲突消解规则必须是确定性的、与执行顺序无关的

第二,右原点的作用。 如果只看左原点(RGA 做法),当 A 打了 "Hello"、B 打了 "World",每个字符都独立参与冲突消解,就会交错。有了右原点,YATA 能识别 "o 的右原点和我不同,说明 o 属于另一个连续分支",从而整体跳过,避免交错。

第三,itemsBefore 集合的作用是处理传递性依赖。 如果 C 插在 B 后面,B 插在 A 后面,而我们并发插在 A 后面,那么必须整体跳过 B 和 C 这一串,而不是插到 B 和 C 中间。

4.5 对外 API:insert / delete / toString

export class Doc {
  // ... 承上

  /** 按可见索引找到对应的 (item, offset) */
  private findPosition(index: number): { left: Item | null; offset: number } {
    let o = this.head.right;
    let remaining = index;
    let last: Item | null = null;

    while (o !== null) {
      if (!o.deleted) {
        if (remaining <= o.length) {
          return { left: o, offset: remaining };
        }
        remaining -= o.length;
      }
      last = o;
      o = o.right;
    }
    return { left: last, offset: last ? last.length : 0 };
  }

  insert(index: number, text: string): Item {
    const { left, offset } = this.findPosition(index);

    let leftItem: Item | null = left;
    if (left && offset > 0 && offset < left.length) {
      // 插入点在 item 中间 → 先劈开
      leftItem = this.store.getItemCleanEnd(
        createID(left.id.client, left.id.clock + offset - 1),
      );
    } else if (left && offset === 0) {
      leftItem = left.left;
    }

    const rightItem = leftItem ? leftItem.right : this.head.right;
    const item = new Item(
      createID(this.clientId, this.store.getNextClock(this.clientId)),
      leftItem ? leftItem.lastId : null,
      rightItem ? rightItem.id : null,
      text,
    );
    this.integrate(item);
    return item;
  }

  delete(index: number, length: number): void {
    let remaining = length;
    let { left: item, offset } = this.findPosition(index);

    while (item !== null && remaining > 0) {
      if (!item.deleted) {
        if (offset > 0) {
          item = this.store.getItemCleanStart(
            createID(item.id.client, item.id.clock + offset),
          );
          offset = 0;
        }
        if (item.length > remaining) {
          this.store.getItemCleanStart(
            createID(item.id.client, item.id.clock + remaining),
          );
        }
        remaining -= item.length;
        item.deleted = true;      // 墓碑
        item.content = "";        // 内容可以立刻释放,元数据保留
      }
      item = item.right;
    }
  }

  toString(): string {
    let s = "";
    let o = this.head.right;
    while (o) {
      if (!o.deleted) s += o.content;
      o = o.right;
    }
    return s;
  }
}

注意 delete 里的 item.content = ""删除时立刻释放内容,但保留 Item 元数据。这是最基本的内存优化——一篇 10 万字的文档删干净后,如果不做这一步,10 万个字符还占着堆。


五、同步协议:状态向量 + 增量更新

引擎能本地跑了,现在要让两个副本对话。

5.1 为什么不能直接发全量

最朴素的同步是"我把整个文档结构发给你,你 merge"。这在数学上完全正确(CRDT 的 merge 是幂等的),但工程上不可接受:一篇长文档的 CRDT 结构可能是原文的 3-10 倍大,每次同步都传一遍等于自杀。

我们要的是增量同步:只发对方没见过的那部分。

5.2 状态向量:一句话说清"我知道什么"

// 客户端 A 的状态向量
{ 1 => 150, 2 => 87, 5 => 3 }
// 含义:我已经见过 client1 的 clock [0,150)、client2 的 [0,87)、client5 的 [0,3)

同步握手就三步:

A → B:  我的状态向量 SV_A
B → A:  diff(SV_A) —— 所有 B 有而 A 没有的 item
A → B:  我的 diff(可选,用 B 回传的 SV_B 计算)
export function encodeStateVector(sv: StateVector): Uint8Array {
  const parts: number[] = [];
  writeVarUint(parts, sv.size);
  for (const [client, clock] of sv) {
    writeVarUint(parts, client);
    writeVarUint(parts, clock);
  }
  return new Uint8Array(parts);
}

/** 计算「对方缺失」的增量 */
export function encodeDiff(doc: Doc, remoteSV: StateVector): Update {
  const structs: Item[] = [];
  for (const [client, arr] of doc.store.clients) {
    const remoteClock = remoteSV.get(client) ?? 0;
    for (const item of arr) {
      if (item.clockEnd > remoteClock) {
        // 部分重叠时需要切片,这里简化为整发(幂等,重复应用无害)
        structs.push(item);
      }
    }
  }
  return { structs, deleteSet: collectDeleteSet(doc) };
}

这个设计有个很爽的性质:它天然支持离线。 客户端断网三天,回来只需发一个状态向量,服务端立刻算出这三天的所有增量。不需要"操作队列"、不需要"重放日志"、不需要断点续传逻辑。

5.3 因果就绪:pending 队列

远端 item 到达时,它的 origin 指向的 item 可能还没到(UDP 乱序、多路径、消息分片)。直接 integrate 会抛异常。

正确做法是因果就绪检查

export class Doc {
  private pending: Item[] = [];

  applyUpdate(update: Update): void {
    const queue = [...update.structs, ...this.pending];
    this.pending = [];

    let progressed = true;
    while (progressed && queue.length > 0) {
      progressed = false;
      const rest: Item[] = [];

      for (const item of queue) {
        if (this.isCausallyReady(item)) {
          // 幂等检查:已经见过就跳过
          if (this.store.getNextClock(item.id.client) > item.id.clock) continue;
          this.integrate(item);
          progressed = true;
        } else {
          rest.push(item);
        }
      }
      queue.length = 0;
      queue.push(...rest);
    }
    this.pending = queue; // 还缺依赖的,留到下次

    this.applyDeleteSet(update.deleteSet);
  }

  private isCausallyReady(item: Item): boolean {
    const deps = [item.origin, item.rightOrigin].filter(Boolean) as ID[];
    for (const d of deps) {
      if (this.store.getNextClock(d.client) <= d.clock) return false;
    }
    // 同 client 的前序 item 也必须先到
    if (this.store.getNextClock(item.id.client) < item.id.clock) return false;
    return true;
  }
}

while (progressed) 这个循环是必要的:可能第一轮只有 item X 就绪,integrate 之后 item Y 的依赖才满足。

5.4 DeleteSet:删除单独编码

删除不需要走 Item 结构,只需要一组 (client, clock, len) 区间:

export interface DeleteRange { client: number; clock: number; len: number; }

export function collectDeleteSet(doc: Doc): DeleteRange[] {
  const out: DeleteRange[] = [];
  for (const [client, arr] of doc.store.clients) {
    let cur: DeleteRange | null = null;
    for (const item of arr) {
      if (item.deleted) {
        if (cur && cur.clock + cur.len === item.id.clock) {
          cur.len += item.length; // 合并相邻区间
        } else {
          cur = { client, clock: item.id.clock, len: item.length };
          out.push(cur);
        }
      } else {
        cur = null;
      }
    }
  }
  return out;
}

相邻区间合并(run-length)是关键优化。 用户选中一段 1000 字删掉,如果逐字符编码要 1000 条记录,合并后只有 1 条 (client, clock, 1000)。实测中删除操作的报文体积能降两个数量级。


六、二进制编码:为什么 JSON 会把你的带宽吃穿

到这里功能齐了,但如果你用 JSON.stringify(update) 发出去,会发现一个 200 字的段落,同步报文有 8KB。

原因很好理解:

{"id":{"client":1847362,"clock":153},"origin":{"client":1847362,"clock":152},
 "rightOrigin":null,"content":"你","deleted":false}

一个汉字,配 120 字节元数据。

工业级实现(Yjs 的 lib0、Automerge 的列式编码)用三招把它压下来。

6.1 变长整数(varint)

绝大多数 clock 值都很小,用 4 字节定长 int 是浪费。

export function writeVarUint(out: number[], num: number): void {
  while (num > 0b0111_1111) {
    out.push(0b1000_0000 | (num & 0b0111_1111)); // 高位 1 表示还有后续字节
    num = Math.floor(num / 128);
  }
  out.push(num & 0b0111_1111);
}

export function readVarUint(buf: Uint8Array, state: { pos: number }): number {
  let num = 0, mult = 1;
  while (true) {
    const b = buf[state.pos++];
    num += (b & 0b0111_1111) * mult;
    if (b < 0b1000_0000) return num;
    mult *= 128;
  }
}

clock < 128 只占 1 字节,< 16384 占 2 字节。对绝大多数文档,这一招就砍掉 60% 体积。

6.2 按 client 分组 + delta 编码

同一个 client 的 item 在数组里 clock 是递增的,只需要编第一个的绝对值,后面存差值:

export function encodeUpdate(structsByClient: Map<number, Item[]>): Uint8Array {
  const out: number[] = [];
  writeVarUint(out, structsByClient.size);

  for (const [client, items] of structsByClient) {
    writeVarUint(out, client);
    writeVarUint(out, items.length);
    writeVarUint(out, items[0].id.clock); // 只写第一个的绝对 clock

    let prevClock = items[0].id.clock;
    for (const item of items) {
      writeVarUint(out, item.id.clock - prevClock); // delta
      prevClock = item.id.clock;

      // origin:用一个 info 字节记录 origin/rightOrigin 是否存在
      let info = 0;
      if (item.origin) info |= 0b01;
      if (item.rightOrigin) info |= 0b10;
      if (item.deleted) info |= 0b100;
      out.push(info);

      if (item.origin) { writeVarUint(out, item.origin.client); writeVarUint(out, item.origin.clock); }
      if (item.rightOrigin) { writeVarUint(out, item.rightOrigin.client); writeVarUint(out, item.rightOrigin.clock); }
      if (!item.deleted) writeVarString(out, item.content);
    }
  }
  return new Uint8Array(out);
}

6.3 Item 合并(run):最重要的一招

用户顺序打字 "你好世界",如果生成 4 个 Item,元数据是内容的 30 倍。但注意:这 4 个字符满足

  • 同一个 client
  • clock 连续
  • 每个的 origin 都指向前一个的最后一个字符
  • 删除状态相同

那它们完全可以合并成一个 Item

export function tryMergeWithLeft(item: Item): boolean {
  const l = item.left;
  if (!l) return false;
  if (l.id.client !== item.id.client) return false;
  if (l.clockEnd !== item.id.clock) return false;
  if (l.deleted !== item.deleted) return false;
  if (!idEq(item.origin, l.lastId)) return false;
  if (!idEq(l.rightOrigin, item.rightOrigin)) return false;

  l.content += item.content;
  l.right = item.right;
  if (item.right) item.right.left = l;
  return true;
}

这一招的效果是数量级的。 一段 5000 字的连续输入,如果没有并发打断,最终可能只是几十个 Item。这也解释了为什么前面 clock 要设计成"字符偏移"而不是"操作序号"——就是为了让合并成为可能。

三招叠加下来,同样的 200 字段落,报文能从 8KB 降到 300 字节左右量级(具体数字取决于并发程度和内容,请以自己的压测为准)。


七、性能优化:从 O(n) 遍历到亚毫秒定位

7.1 findPosition 是个定时炸弹

上面的 findPosition 是从头遍历链表。文档 10 万字符、Item 数千个时,每次按索引插入都要遍历一遍。在编辑器里,每敲一个键都会触发一次

100 万字符的文档上,光标在文末打字会明显卡顿。

7.2 方案一:搜索标记(Search Marker)

Yjs 用的方案,简单有效:缓存最近访问过的几个 (index, item) 对

interface SearchMarker { item: Item; index: number; timestamp: number; }

class MarkerSystem {
  private markers: SearchMarker[] = [];
  private readonly MAX = 10;
  private tick = 0;

  find(index: number): SearchMarker | null {
    if (this.markers.length === 0) return null;
    // 找距离目标 index 最近的 marker
    let best: SearchMarker | null = null;
    let bestDist = Infinity;
    for (const m of this.markers) {
      const d = Math.abs(index - m.index);
      if (d < bestDist) { bestDist = d; best = m; }
    }
    return best;
  }

  update(item: Item, index: number): void {
    if (this.markers.length < this.MAX) {
      this.markers.push({ item, index, timestamp: this.tick++ });
    } else {
      // 替换最久未用的
      let oldest = this.markers[0];
      for (const m of this.markers) if (m.timestamp < oldest.timestamp) oldest = m;
      oldest.item = item; oldest.index = index; oldest.timestamp = this.tick++;
    }
  }

  /** 插入/删除后,所有位于其右侧的 marker 索引要平移 */
  shift(fromIndex: number, delta: number): void {
    for (const m of this.markers) {
      if (m.index > fromIndex) m.index += delta;
    }
  }
}

为什么这个"土办法"有效? 因为文本编辑有极强的局部性:用户 99% 的操作发生在光标附近。缓存 10 个位置,命中率极高,命中后只需要从 marker 向左/右走几步。

代价是 marker 维护逻辑容易出 bug——插入删除后忘记 shift,就会算出错误索引,而且是那种"偶尔串位一个字符"的幽灵 bug。写单测时一定要覆盖"在 marker 左侧删除大段文本"这个 case。

7.3 方案二:B-Tree 索引(更彻底)

diamond-types、Loro 这类 Rust 实现走的是这条路:链表外挂一棵 B-Tree,每个内部节点记录子树的"可见字符数"。

        [totalLen=1200]
       /       |        \
 [len=400] [len=350]  [len=450]

按索引查找时逐层下降,O(log n)。插入删除时沿路径回溯更新计数。

这个方案在超大文档(百万字符 + 高频并发)下明显优于 marker,代价是实现复杂度高一个量级,而且 JS 里 B-Tree 的对象开销和 GC 压力也不小。

选型建议:JS/TS 实现、文档 < 50 万字符 → search marker 够用。Rust/C++ 实现或需要处理超大文档 → 上 B-Tree。

7.4 事务批处理

一个常见的性能陷阱:编辑器每输入一个字符就触发一次 insert → 一次 encode → 一次网络发送。

正确做法是事务合并

export class Doc {
  private txnDepth = 0;
  private txnItems: Item[] = [];
  private listeners: ((u: Uint8Array) => void)[] = [];

  transact<T>(fn: () => T): T {
    this.txnDepth++;
    try {
      return fn();
    } finally {
      this.txnDepth--;
      if (this.txnDepth === 0 && this.txnItems.length > 0) {
        const update = encodeItems(this.txnItems);
        this.txnItems = [];
        for (const l of this.listeners) l(update);
      }
    }
  }
}

// 使用
doc.transact(() => {
  doc.insert(0, "第一段");
  doc.insert(3, "第二段");
  doc.delete(0, 1);
});
// 只触发一次 update 事件,一次网络发送

再叠加一层输入去抖(比如 16ms 或 50ms 合并一次发送),网络请求量能降一到两个数量级。用户完全感知不到 50ms 的延迟,但服务器压力天差地别。


八、墓碑与 GC:CRDT 最难的工程问题

8.1 问题有多严重

做个思想实验:一个协作文档,用户写了删、删了写,反复迭代半年。

  • 文档当前可见内容:5000 字
  • 历史上产生过的字符总数:50 万
  • 墓碑 Item 数量:可能几万个

CRDT 结构的大小正比于"历史上所有操作",而不是"当前内容"。 打开这个文档要下载几 MB 的结构,解析要几秒,内存占几十 MB。

这不是理论担忧,是每个上线 CRDT 系统都会撞到的墙。

8.2 为什么不能简单删掉墓碑

因为墓碑还承担着"位置锚点"的职责:

文档: A [B已删] C
用户 X 离线时,在 B 后面插入了 D,D.origin = B.id

如果服务端 GC 掉了 B,X 上线时 D 无法 integrate(找不到 origin)

所以 GC 的安全条件是:确认所有副本都不可能再引用这个墓碑

8.3 三种实用策略

策略一:内容释放 + 元数据保留(必做)

前面 delete 里已经做了:item.content = ""。墓碑只留 id / origin / rightOrigin / deleted,一个约 50-100 字节的对象。这不能解决问题,但能把问题推后 10 倍。

策略二:墓碑合并(run-length tombstone)

相邻的、同 client 的、clock 连续的墓碑可以合并成一个:

export function gcMergeTombstones(doc: Doc): number {
  let merged = 0;
  for (const [, arr] of doc.store.clients) {
    for (let i = arr.length - 1; i > 0; i--) {
      const cur = arr[i], prev = arr[i - 1];
      if (cur.deleted && prev.deleted &&
          prev.clockEnd === cur.id.clock &&
          idEq(cur.origin, prev.lastId)) {
        // 合并:prev 吞掉 cur,长度用一个虚拟 length 字段记录
        (prev as any)._tombLen = ((prev as any)._tombLen ?? prev.length)
          + ((cur as any)._tombLen ?? cur.length);
        prev.right = cur.right;
        if (cur.right) cur.right.left = prev;
        arr.splice(i, 1);
        merged++;
      }
    }
  }
  return merged;
}

删掉一整段的场景,几千个墓碑能合成一个。这是性价比最高的一招。

策略三:快照 + 版本截断(终极方案)

当所有活跃客户端的状态向量都超过某个水位线时,服务端可以:

  1. 把当前文档状态"压平"成一个全新的 CRDT 结构(新 clientId、clock 从 0 开始)
  2. 广播这个快照,要求所有客户端重置
  3. 丢弃所有历史
export function compact(doc: Doc, newClientId: number): Doc {
  const text = doc.toString();
  const fresh = new Doc(newClientId);
  fresh.insert(0, text);
  return fresh;
}

代价是:丢失所有历史版本、丢失离线客户端的未同步编辑。所以必须在"确认所有客户端都在线且已同步"的时机做,通常是低峰期 + 强制客户端重连。

实践建议:策略一和二常驻,策略三设成定时任务(比如文档 30 天无人编辑、或墓碑占比超过 70% 时触发),并且做好"离线客户端携带旧结构上来"的降级路径——检测到 clientId 不在快照里,直接把它的编辑当作"在文末追加"处理,或者提示用户手动合并。


九、富文本:格式标记不能当字符处理

纯文本引擎跑通后,真实需求马上来了:加粗、斜体、链接、标题。

9.1 错误做法:把格式塞进字符属性

// ❌ 不要这样
interface Item {
  content: string;
  attrs: { bold?: boolean; italic?: boolean };
}

问题:用户选中 "Hello World" 加粗,如果 HelloWorld 是不同 Item,要么劈开 Item 逐个改属性(元数据爆炸),要么改不了。而且并发场景下"A 加粗前半段、B 加粗后半段"没法正确合并。

9.2 正确做法:格式作为独立的锚点 Item

Yjs 和 Peritext(Ink & Switch 的富文本 CRDT 论文)的思路是:把格式标记本身做成序列里的零宽元素

type Content =
  | { kind: "text"; str: string }
  | { kind: "format"; key: string; value: unknown };  // 零宽,不占可见索引

// "Hello **World**" 的内部结构:
// [text "Hello "] [format bold=true] [text "World"] [format bold=null]

渲染时线性扫描,维护一个"当前激活格式"栈:

export function renderRichText(doc: Doc): Array<{ text: string; attrs: Record<string, unknown> }> {
  const out: Array<{ text: string; attrs: Record<string, unknown> }> = [];
  const active: Record<string, unknown> = {};

  let o = doc.firstItem();
  while (o) {
    if (!o.deleted) {
      const c = o.contentTyped;
      if (c.kind === "format") {
        if (c.value === null) delete active[c.key];
        else active[c.key] = c.value;
      } else {
        out.push({ text: c.str, attrs: { ...active } });
      }
    }
    o = o.right;
  }
  return out;
}

关键性质:格式 Item 参与 CRDT 定序,但不占用可见索引。所以 findPosition 要跳过它们。这也意味着并发的格式操作会自动收敛——两个人同时加粗重叠区间,最终只是多几个冗余的 format item,渲染结果一致。

9.3 格式标记的清理

一段文本被删光后,两端的 format item 会变成"孤儿":

[format bold=true] [text "World" 已删] [format bold=null]

这两个零宽 item 现在完全无意义,但会永久留在结构里。需要一个专门的清理逻辑:检测相邻的、中间无可见内容的、成对的 format item,标记为删除

不做这一步的后果:一个反复编辑的文档,格式 item 数量会缓慢但持续地增长,最终占据结构的大头。这是很多人接手 CRDT 项目半年后才发现的问题。


十、那些 CRDT 不该管的事

10.1 光标位置(Awareness)不要走 CRDT

新手常见误区:把"其他人的光标位置"也做成 CRDT 状态。

这是错的,理由有三:

  1. 光标是临时状态,不需要持久化,不需要最终一致性
  2. 光标变化频率极高(每次移动都变),走 CRDT 会疯狂产生墓碑
  3. 用户离线后,他的光标应该消失,而 CRDT 的语义是"永久保留"

正确做法是独立的 Awareness 协议:一个带 TTL 的、LWW 语义的临时状态广播。

interface AwarenessState {
  clientId: number;
  user: { name: string; color: string };
  cursor: { anchor: RelativePosition; head: RelativePosition } | null;
  clock: number;      // 每次更新 +1
  lastUpdated: number; // 本地收到的时间,用于超时清理
}

export class Awareness {
  private states = new Map<number, AwarenessState>();
  private readonly TIMEOUT = 30_000;

  setLocalState(s: Partial<AwarenessState>): void { /* 广播 */ }

  applyRemote(s: AwarenessState): void {
    const prev = this.states.get(s.clientId);
    if (prev && prev.clock >= s.clock) return; // LWW
    this.states.set(s.clientId, { ...s, lastUpdated: Date.now() });
  }

  /** 定时调用,清理掉线用户 */
  gc(): void {
    const now = Date.now();
    for (const [id, s] of this.states) {
      if (now - s.lastUpdated > this.TIMEOUT) this.states.delete(id);
    }
  }
}

10.2 相对位置:光标怎么在并发编辑下不漂

光标不能存索引(别人在前面插字,你的光标就错位了),要存相对位置——即"锚定在某个 Item 的第 N 个字符后面"。

export interface RelativePosition {
  item: ID | null; // 锚定的 item(null 表示文档开头)
  assoc: number;   // 1 = 贴右边, -1 = 贴左边
}

export function createRelativePosition(doc: Doc, index: number): RelativePosition {
  const { left, offset } = doc.findPositionPublic(index);
  if (!left) return { item: null, assoc: 1 };
  return {
    item: createID(left.id.client, left.id.clock + offset - 1),
    assoc: -1,
  };
}

export function toAbsoluteIndex(doc: Doc, rel: RelativePosition): number {
  if (!rel.item) return 0;
  let idx = 0;
  let o = doc.firstItem();
  while (o) {
    if (o.id.client === rel.item.client &&
        rel.item.clock >= o.id.clock && rel.item.clock < o.clockEnd) {
      return idx + (o.deleted ? 0 : rel.item.clock - o.id.clock + 1);
    }
    if (!o.deleted) idx += o.length;
    o = o.right;
  }
  return idx;
}

assoc 这个字段很微妙:光标在 A|B 位置,如果别人在这里插入 X,光标应该变成 AX|B 还是 A|XBassoc = -1 表示"贴住左边的 A",得到 AX|B。这符合大多数编辑器的直觉。

如果锚定的 Item 被删了怎么办? 需要向左回溯到最近的未删除位置。生产实现里这一段逻辑要仔细写,否则会出现"光标跳到文档开头"这种极其恼人的 bug。

10.3 Undo/Redo:为什么协同下的撤销是另一个问题

单机 undo 很简单:栈里存反向操作。协同下不行,因为:

A 输入 "Hello"
B 输入 "World"
A 按 Ctrl+Z

A 应该撤销自己的 "Hello",不能撤销 B 的 "World"。这叫选择性撤销(selective undo),比线性撤销难得多。

实现要点:

export class UndoManager {
  private undoStack: Array<{ inserted: ID[]; deleted: ID[] }> = [];
  private redoStack: typeof this.undoStack = [];

  constructor(private doc: Doc, private trackedClient: number) {}

  /** 只追踪本客户端产生的变更 */
  onAfterTransaction(inserted: Item[], deleted: Item[]): void {
    const mine = inserted.filter(i => i.id.client === this.trackedClient);
    if (mine.length === 0 && deleted.length === 0) return;
    this.undoStack.push({
      inserted: mine.map(i => i.id),
      deleted: deleted.map(i => i.id),
    });
    this.redoStack = [];
  }

  undo(): void {
    const item = this.undoStack.pop();
    if (!item) return;
    this.doc.transact(() => {
      // 撤销插入 = 删除;撤销删除 = 重新插入(用原 origin 重建)
      for (const id of item.inserted) this.doc.deleteById(id);
      for (const id of item.deleted) this.doc.undeleteById(id);
    });
    this.redoStack.push(item);
  }
}

注意 undeleteById:撤销删除不是"重新插入",而是"把墓碑复活"。 这又是墓碑不能随便 GC 的一个理由——undo 栈里可能还引用着它们。

还有个更隐蔽的坑:如果 A 撤销了自己的操作,而 B 在这个操作的基础上做了新编辑,会怎样? 比如 A 输入 "Hello",B 在 "Hello" 中间插了 "XX" 变成 "HeXXllo",A 撤销。结果是 "XX"——A 的字符全部变墓碑,B 的字符还在。这在语义上是合理的(B 的编辑没有被吞掉),但用户可能觉得诡异。产品设计上要有心理准备。


十一、网络层:把它接到真实世界

11.1 WebSocket 同步的完整握手

enum MsgType { SyncStep1 = 0, SyncStep2 = 1, Update = 2, Awareness = 3 }

export class WebSocketProvider {
  private ws: WebSocket | null = null;
  private synced = false;
  private queue: Uint8Array[] = [];

  constructor(private url: string, private doc: Doc, private room: string) {
    this.connect();
    this.doc.onUpdate(u => this.broadcast(MsgType.Update, u));
  }

  private connect(): void {
    this.ws = new WebSocket(`${this.url}?room=${this.room}`);
    this.ws.binaryType = "arraybuffer";

    this.ws.onopen = () => {
      // Step1: 发送本地状态向量
      this.send(MsgType.SyncStep1, encodeStateVector(this.doc.store.getStateVector()));
      // 补发离线期间积压的本地更新
      for (const u of this.queue) this.send(MsgType.Update, u);
      this.queue = [];
    };

    this.ws.onmessage = (e) => {
      const buf = new Uint8Array(e.data as ArrayBuffer);
      const type = buf[0] as MsgType;
      const payload = buf.subarray(1);

      switch (type) {
        case MsgType.SyncStep1: {
          // 对方发来它的状态向量 → 回它缺的增量 + 我的状态向量
          const remoteSV = decodeStateVector(payload);
          this.send(MsgType.SyncStep2, encodeUpdateFromSV(this.doc, remoteSV));
          this.send(MsgType.SyncStep1, encodeStateVector(this.doc.store.getStateVector()));
          break;
        }
        case MsgType.SyncStep2:
        case MsgType.Update:
          this.doc.applyUpdate(decodeUpdate(payload));
          this.synced = true;
          break;
      }
    };

    this.ws.onclose = () => setTimeout(() => this.connect(), this.backoff());
  }

  private backoff(): number {
    return Math.min(30_000, 1000 * Math.pow(2, this.retries++));
  }

  private broadcast(type: MsgType, data: Uint8Array): void {
    if (this.ws?.readyState === WebSocket.OPEN) this.send(type, data);
    else this.queue.push(data); // 离线积压
  }

  private retries = 0;
  private send(type: MsgType, data: Uint8Array): void {
    const msg = new Uint8Array(data.length + 1);
    msg[0] = type;
    msg.set(data, 1);
    this.ws!.send(msg);
  }
}

注意 queue 的存在:这是 CRDT 相比 OT 的一大优势。 离线期间产生的更新直接堆在队列里,上线后一股脑发出去,服务端 apply 顺序无所谓,结果一定收敛。OT 在这里需要复杂的 pending queue + transform 链。

11.2 服务端可以有多蠢

这可能是 CRDT 最被低估的好处:服务端不需要理解 CRDT

// 一个够用的中继服务端
const rooms = new Map<string, Set<WebSocket>>();
const docs = new Map<string, Uint8Array>(); // 持久化的合并结果

wss.on("connection", (ws, req) => {
  const room = new URL(req.url!, "http://x").searchParams.get("room")!;
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room)!.add(ws);

  ws.on("message", (data: Buffer) => {
    // 广播给同房间其他人 —— 不解析、不排序、不做冲突处理
    for (const peer of rooms.get(room)!) {
      if (peer !== ws && peer.readyState === WebSocket.OPEN) peer.send(data);
    }
  });

  ws.on("close", () => rooms.get(room)!.delete(ws));
});

服务端就是个哑广播器。不需要单点定序、不需要维护文档状态机、可以随便水平扩展(配个 Redis pub/sub 跨节点广播就行)。持久化也简单:起一个"影子客户端"跟着 apply update,定期 dump。

对比 OT:服务端必须是权威定序中心,必须维护完整文档状态,必须执行 transform,是天然的单点瓶颈。


十二、生产踩坑清单

这些是真实项目里踩过的坑,按踩到的概率排序。

1. clientId 冲突
Math.random() * 1000 生成 clientId,用户一多必然撞。撞了会导致 clock 空间重叠,数据永久损坏且无法自愈。
→ 用 32 位随机数(crypto.getRandomValues)或服务端分配。每个 Doc 实例一个 clientId,页面刷新后要换新的(否则旧的未同步 clock 会和新的冲突)。

2. 用物理时间戳做冲突消解
Date.now() 在客户端不可信。已经反复强调,但每个项目都有人这么写。
→ 只用 Lamport 时钟 + clientId。

3. 忘记幂等检查
同一个 update 被 apply 两次(网络重传、用户刷新后重新握手),如果不检查会重复插入。
→ apply 前查 getNextClock(client) > item.id.clock

4. Item 切分后忘记更新 StructStore 数组
getItemCleanStartarr.splice 那一行,忘了或者位置算错,后果是二分查找返回错误的 item,表现为"偶尔丢字符"。
→ 写一个 validateInvariants() 在开发模式下每次操作后跑一遍:检查每个 client 数组 clock 严格连续、链表双向指针一致、可见长度和 toString().length 相等。

5. 大文档首次加载卡死主线程
10 万 Item 的 decode + integrate 在主线程跑,页面白屏 3 秒。
→ 放 Web Worker,或者分片 apply(每 1000 个 item await Promise.resolve() 让出一次)。

6. 墓碑无限增长
上线三个月后有用户反馈"文档打开要 10 秒"。
→ 上线第一天就要有监控:记录每个文档的 itemCount / visibleLength 比值,超过阈值(比如 20)告警。

7. 编辑器绑定的双向循环
CRDT 变化 → 更新编辑器 → 触发编辑器 change 事件 → 又写回 CRDT → 无限循环。
→ 加 origin 标记:doc.transact(fn, "remote"),编辑器 change handler 检查 origin 是 "remote" 就跳过。

8. 富文本格式 item 泄漏
前面讲过,成对的孤儿 format item 不清理会缓慢累积。
→ 每次 delete 后检查相邻 format item。

9. Undo 跨越了别人的编辑
UndoManager 没过滤 clientId,A 的 Ctrl+Z 撤销了 B 的输入。
→ 严格只追踪本 client 产生的 Item。

10. 状态向量在稀疏 client 场景下膨胀
一个公开文档被 10 万人访问过,状态向量有 10 万个条目,每次握手要传几 MB。
→ 对只读访问者不分配 clientId;定期 compact 合并历史 client。


十三、横向对比与选型

写完自己的引擎,再回头看生态里的方案,选型会清楚很多。

Yjs(JS/TS,YATA 算法)
生态最成熟,编辑器绑定齐全(ProseMirror / Slate / CodeMirror / Monaco / Quill / Lexical),有 y-websocket、y-webrtc、y-indexeddb 等现成 provider。二进制编码效率高。缺点是内部实现比较"紧",定制成本高;富文本的格式 item 清理需要自己关注。
前端项目默认选它,别造轮子(本文造轮子是为了理解原理)。

Automerge(Rust + WASM / JS)
API 更"数据库"(immutable、支持任意 JSON 结构、内置版本历史与分支合并),列式存储压缩率好。历史上性能落后于 Yjs,后续版本改进明显。适合需要完整版本历史、Git 式分支语义的场景。
需要"文档 Git"能力时优先考虑

Loro(Rust + WASM)
较新的实现,采用 Fugue / Eg-walker 方向的算法,专门解决了交错异常,同时在时间旅行、内存占用上做了很多工程优化。
对性能和正确性要求高、能接受相对年轻生态时值得评估

diamond-types(Rust)
Joseph Gentle 的实验性实现,把纯文本序列 CRDT 的性能推到了极致,很多优化思路(B-Tree 索引、run 压缩)被后来者借鉴。
→ 更适合作为学习材料和性能标杆

一个反直觉的结论:Eg-walker 路线

近年最有意思的进展是 Eg-walker(Event Graph Walker) 这一类"混合"方案:它不在内存里长期维护完整的 CRDT 结构,而是存一份操作事件图(event graph),只在需要合并分叉时才临时"重放"出 CRDT 结构,合并完就丢弃。

好处是:没有并发时(99% 的时间),内存里就是一个普通字符串 + 一个操作日志,没有任何 CRDT 元数据开销。只有真的出现并发分叉时才付出 CRDT 的代价。

这某种程度上是对"CRDT 必须永久携带元数据"这个前提的反思。如果你在设计一个新系统,值得认真评估这条路线——尤其是当你的场景是"大部分时间单人编辑,偶尔多人协作"(这其实是绝大多数文档产品的真实分布)。


十四、我们到底学到了什么

回头看,这套东西的核心洞察其实只有几条:

第一,把"位置"从索引换成不可变 ID。 所有协同编辑的困难都源于索引会漂移,而 ID 不会。一旦你接受"每个字符都有身份证",剩下的都是工程细节。

第二,冲突消解规则必须是全序且确定的。 clientId 比较看起来很土,但正是这个"土办法"提供了收敛性的数学保证。任何依赖执行顺序、依赖时间、依赖网络的规则都会破坏收敛。

第三,空间换正确性是有代价的,代价要主动管理。 墓碑不会自己消失,元数据不会自己变小。Item 合并、run-length 编码、快照压缩这些优化不是"锦上添花",而是上线的必要条件。

第四,不是所有状态都该用 CRDT。 光标、在线状态、临时选区,这些用 LWW + TTL 就够了。滥用 CRDT 会带来不必要的复杂度和内存开销。

第五,CRDT 最大的价值不在"自动合并",而在"服务端可以很蠢"。 无需中心定序意味着可以水平扩展、可以 P2P、可以本地优先(local-first)、可以离线三天回来无痛合并。这才是它相对 OT 的结构性优势。

值得继续挖的方向

  • 本地优先(Local-First Software):Ink & Switch 提出的七大原则,CRDT 是它的技术底座。整个应用架构范式的转变。
  • 富文本的 Peritext 模型:格式 CRDT 的系统性方案,比"零宽 item"更严谨。
  • CRDT + 权限:多人协作绕不开的问题——如何在无中心定序的前提下做访问控制?目前还没有优雅答案,是活跃的研究方向。
  • CRDT 用于 AI Agent 协作:多个 Agent 并发修改同一份代码/文档时的状态合并,本质上就是这个问题。这可能是 CRDT 未来几年最大的新增场景。

附:完整可运行的最小实现

把前面的核心代码串起来,一个能跑的最小引擎(约 200 行):

// crdt.ts —— 最小可运行 YATA 实现
export interface ID { client: number; clock: number; }
export const createID = (client: number, clock: number): ID => ({ client, clock });
export const idEq = (a: ID | null, b: ID | null): boolean =>
  a === null || b === null ? a === b : a.client === b.client && a.clock === b.clock;

export class Item {
  deleted = false;
  left: Item | null = null;
  right: Item | null = null;
  constructor(
    public id: ID,
    public origin: ID | null,
    public rightOrigin: ID | null,
    public content: string,
  ) {}
  get length() { return this.content.length; }
  get clockEnd() { return this.id.clock + this.length; }
  get lastId() { return createID(this.id.client, this.clockEnd - 1); }
}

export class Doc {
  clients = new Map<number, Item[]>();
  head = new Item(createID(-1, 0), null, null, "");
  private pending: Item[] = [];

  constructor(public clientId: number) {}

  private arr(c: number): Item[] {
    let a = this.clients.get(c);
    if (!a) { a = []; this.clients.set(c, a); }
    return a;
  }
  nextClock(c: number): number {
    const a = this.clients.get(c);
    return a && a.length ? a[a.length - 1].clockEnd : 0;
  }
  private locate(id: ID): Item | null {
    const a = this.clients.get(id.client);
    if (!a) return null;
    for (const it of a) if (id.clock >= it.id.clock && id.clock < it.clockEnd) return it;
    return null;
  }
  private splitAt(id: ID): Item | null {
    const it = this.locate(id);
    if (!it) return null;
    if (it.id.clock === id.clock) return it;
    const off = id.clock - it.id.clock;
    const r = new Item(id, createID(id.client, id.clock - 1), it.rightOrigin, it.content.slice(off));
    r.deleted = it.deleted;
    it.content = it.content.slice(0, off);
    r.left = it; r.right = it.right;
    if (it.right) it.right.left = r;
    it.right = r;
    const a = this.arr(id.client);
    a.splice(a.indexOf(it) + 1, 0, r);
    return r;
  }

  integrate(item: Item): void {
    let left = item.origin ? this.locate(item.origin) : null;
    if (left && item.origin && item.origin.clock !== left.clockEnd - 1) {
      this.splitAt(createID(item.origin.client, item.origin.clock + 1));
      left = this.locate(item.origin);
    }
    const right = item.rightOrigin ? this.splitAt(item.rightOrigin) : null;

    let o = left ? left.right : this.head.right;
    while (o && o !== right) {
      if (idEq(o.origin, item.origin)) {
        if (o.id.client < item.id.client) left = o;
        else if (idEq(o.rightOrigin, item.rightOrigin)) break;
      } else break;
      o = o.right;
    }

    const r = left ? left.right : this.head.right;
    item.left = left; item.right = r;
    if (left) left.right = item; else this.head.right = item;
    if (r) r.left = item;
    const a = this.arr(item.id.client);
    a.push(item);
    a.sort((x, y) => x.id.clock - y.id.clock);
  }

  insert(index: number, text: string): void {
    let o = this.head.right, rem = index, left: Item | null = null;
    while (o) {
      if (!o.deleted) {
        if (rem < o.length) { if (rem > 0) { this.splitAt(createID(o.id.client, o.id.clock + rem)); } break; }
        if (rem === o.length) { left = o; rem = 0; o = o.right; continue; }
        rem -= o.length;
      }
      left = o; o = o.right;
    }
    if (o && rem > 0) left = this.locate(createID(o.id.client, o.id.clock + rem - 1));
    const rightItem = left ? left.right : this.head.right;
    const item = new Item(
      createID(this.clientId, this.nextClock(this.clientId)),
      left ? left.lastId : null,
      rightItem ? rightItem.id : null,
      text,
    );
    this.integrate(item);
  }

  delete(index: number, len: number): void {
    let o = this.head.right, rem = index;
    while (o && rem > 0) {
      if (!o.deleted) {
        if (rem < o.length) { this.splitAt(createID(o.id.client, o.id.clock + rem)); rem = 0; o = o.right; continue; }
        rem -= o.length;
      }
      o = o.right;
    }
    let need = len;
    while (o && need > 0) {
      if (!o.deleted) {
        if (o.length > need) this.splitAt(createID(o.id.client, o.id.clock + need));
        need -= o.length;
        o.deleted = true;
      }
      o = o.right;
    }
  }

  toString(): string {
    let s = "", o = this.head.right;
    while (o) { if (!o.deleted) s += o.content; o = o.right; }
    return s;
  }

  getStateVector(): Map<number, number> {
    const sv = new Map<number, number>();
    for (const [c, a] of this.clients) if (a.length) sv.set(c, a[a.length - 1].clockEnd);
    return sv;
  }

  diff(remoteSV: Map<number, number>): Item[] {
    const out: Item[] = [];
    for (const [c, a] of this.clients) {
      const rc = remoteSV.get(c) ?? 0;
      for (const it of a) if (it.clockEnd > rc) out.push(it);
    }
    return out;
  }

  apply(items: Item[]): void {
    const queue = [...items.map(cloneItem), ...this.pending];
    this.pending = [];
    let progressed = true;
    while (progressed && queue.length) {
      progressed = false;
      const rest: Item[] = [];
      for (const it of queue) {
        if (this.ready(it)) {
          if (this.nextClock(it.id.client) > it.id.clock) continue; // 幂等
          this.integrate(it);
          progressed = true;
        } else rest.push(it);
      }
      queue.length = 0; queue.push(...rest);
    }
    this.pending = queue;
  }

  private ready(it: Item): boolean {
    for (const d of [it.origin, it.rightOrigin]) {
      if (d && this.nextClock(d.client) <= d.clock) return false;
    }
    return this.nextClock(it.id.client) >= it.id.clock;
  }
}

function cloneItem(i: Item): Item {
  const n = new Item(i.id, i.origin, i.rightOrigin, i.content);
  n.deleted = i.deleted;
  return n;
}

收敛性测试

function test() {
  const a = new Doc(1);
  const b = new Doc(2);

  // A 和 B 从空文档开始,并发编辑
  a.insert(0, "Hello");
  b.insert(0, "World");

  // 交换增量(顺序故意反着来)
  const updA = a.diff(b.getStateVector());
  const updB = b.diff(a.getStateVector());
  a.apply(updB);
  b.apply(updA);

  console.log("A:", a.toString());
  console.log("B:", b.toString());
  console.assert(a.toString() === b.toString(), "❌ 未收敛");

  // 再来一轮:A 删除,B 插入
  a.delete(0, 3);
  b.insert(2, "XYZ");
  a.apply(b.diff(a.getStateVector()));
  b.apply(a.diff(b.getStateVector()));
  console.assert(a.toString() === b.toString(), "❌ 第二轮未收敛");
  console.log("最终一致:", a.toString());
}
test();

跑起来你会看到:无论怎么交换顺序、重复 apply 多少次,两边最后一定一样。这就是 CRDT 给你的那份数学保证。


写在最后

CRDT 不是银弹。它用可观的空间开销和实现复杂度,换来一个非常具体的能力:去掉中心定序者

如果你的产品就是个中心化的在线编辑器,用户永远在线,OT 甚至简单的锁 + 版本号可能都够用。但如果你在做本地优先的应用、需要真正的离线编辑、需要 P2P、或者需要服务端能无脑水平扩展——那 CRDT 是目前唯一成熟的答案。

理解它最好的方式,就是自己写一遍。哪怕只写到本文这个最小版本,你对"分布式一致性"的直觉都会不一样。

祝你的字符都能收敛。

推荐文章

JavaScript设计模式:观察者模式
2024-11-19 05:37:50 +0800 CST
robots.txt 的写法及用法
2024-11-19 01:44:21 +0800 CST
如何在Vue3中处理全局状态管理?
2024-11-18 19:25:59 +0800 CST
Linux 网站访问日志分析脚本
2024-11-18 19:58:45 +0800 CST
程序员茄子在线接单