跳到主要内容
返回墨迹
/陈墨

从零实现一个迷你 CRDT:以二人协同编辑为例

不看论文公式,用 150 行 TypeScript 实现一个可运行的 last-write-wins 注册表与 grow-only 集合,理解 CRDT 「合并必然收敛」的直觉。

为什么需要 CRDT

两个人同时编辑同一段文字,断网,再重连——谁的版本算数?最朴素的答案是「最后写入胜出」(LWW),按时间戳取最大。但物理时钟不可靠:A 机器的 10:01 可能早于 B 机器的 10:00。

CRDT(无冲突复制数据类型)的思路是放弃物理时间,改用可合并的逻辑状态:每个副本独立修改,任意顺序合并,最终必然收敛到同一结果。不靠协调服务器,不靠锁。

今天我们从零写两个最小的 CRDT,把「合并必然收敛」变成肌肉记忆。

第一站:LWW-Register(带逻辑时钟的寄存器)

用一个单调递增的计数器代替时间戳,再加一个副本 ID 打破平局:

type Timestamp = [counter: number, peerId: string];

function compare(a: Timestamp, b: Timestamp): number {
  return a[0] - b[0] || (a[1] < b[1] ? -1 : 1);
}

class LWWRegister<T> {
  private ts: Timestamp = [0, ''];
  constructor(private value: T) {}

  set(value: T, peerId: string) {
    if (compare([this.ts[0] + 1, peerId], this.ts) > 0) {
      this.ts = [this.ts[0] + 1, peerId];
      this.value = value;
    }
  }

  merge(remote: { value: T; ts: Timestamp }) {
    if (compare(remote.ts, this.ts) > 0) {
      this.ts = remote.ts;
      this.value = remote.value;
    }
  }

  get() { return this.value; }
}

merge 是关键:只比较时间戳,不比较到达顺序。A、B 两个副本不管以什么顺序互相同步,最终谁的逻辑时间戳大,谁的内容留下。收敛性由 compare 的全序保证。

第二站:G-Set(只增不减的集合)

class GSet<T> {
  private items = new Set<T>();

  add(item: T) { this.items.add(item); }

  merge(remote: GSet<T>) {
    for (const item of remote.items) this.items.add(item);
  }

  values() { return [...this.items]; }
}

只增不减,所以合并就是并集——并集满足交换律、结合律、幂等律,这三律正是 CRDT 收敛的三根支柱。任何满足它们的 merge,都可以在任意网络延迟、任意重复投递下收敛。

从玩具到文字编辑

真正的协同文本编辑(如 Yjs、Automerge)用的是 RGA / YATA 一类的序列 CRDT:给每个字符分配唯一的逻辑时间戳,插入时同时记录「插在谁后面」,合并时按时间戳排序解决交叉。骨架仍是今天这两样东西——

  1. 全序的时间戳(决定谁赢)
  2. 满足三律的合并(决定怎么赢)

完整的可运行代码(含一个 30 行的伪网络层,模拟断网重连)放在了 GitHub 仓库 mini-crdt,总共不到 150 行。读懂它们,再去读 Yjs 的论文会顺畅很多。