←返回墨迹
/陈墨
从零实现一个迷你 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:给每个字符分配唯一的逻辑时间戳,插入时同时记录「插在谁后面」,合并时按时间戳排序解决交叉。骨架仍是今天这两样东西——
- 全序的时间戳(决定谁赢)
- 满足三律的合并(决定怎么赢)
完整的可运行代码(含一个 30 行的伪网络层,模拟断网重连)放在了 GitHub 仓库
mini-crdt,总共不到 150 行。读懂它们,再去读 Yjs 的论文会顺畅很多。