本地优先同步引擎深度拆解:从三种 CRDT 流派到 Eg-walker,为什么 2026 年的前端敢把数据库塞进浏览器
一句话摘要:过去二十年,我们默认「数据在服务器,前端是个远程终端」。2026 年这个假设正在被推翻——不是因为情怀,而是因为交互延迟预算被压到了 16ms、AI Agent 开始并发写同一份文档、而用户的设备终于强到可以跑一个真正的数据库。本文从 Local-First 的工程动机讲起,逐层拆开 CRDT 的内部结构、Eg-walker 如何把内存开销砍掉一个数量级、Zero/ElectricSQL 这类「服务端权威 + 增量视图维护」路线为何是另一种解,最后给出完整可运行代码、五层架构模型、性能调优清单和选型决策树。
一、背景:为什么「请求-响应」在 2026 年开始不够用
1.1 延迟预算这笔账,算过的人都沉默了
先做一道小学算术题。一次典型的「点击 → 看到结果」的交互,在传统 SPA 架构下要走完这些步骤:
用户点击 → 事件处理(1ms) → fetch → 上行 RTT/2(同城15ms / 跨区80ms / 跨洋180ms)
→ 网关鉴权限流(3~10ms) → 业务处理(5~30ms) → 数据库事务(2~50ms)
→ 下行 RTT/2 → JSON 解析(1~5ms) → React 重渲染(4~16ms)
同城最理想情况:约 50ms。跨区:150ms 起。跨洋:400ms 不稀奇。而人类感知「即时」的门槛,在交互设计领域公认是 100ms;要做到「像本地软件一样跟手」,需要压到 一帧以内,16.7ms。
这就是残酷之处:只要你的写操作需要一次网络往返才能确认,你就永远做不到跟手。 不管你用 HTTP/3 还是 WebSocket,不管你的后端是 Go 还是 Rust,不管你的数据库是不是 NVMe,光速不给你面子。
工程师的第一反应是「乐观更新」。但手写乐观更新是一种慢性中毒:
// 手写乐观更新的典型样子——看着还行,实际是个雷区
async function toggleTodo(id: string) {
const prev = store.todos.get(id); // 1. 存快照
store.todos.set(id, { ...prev, done: !prev.done }); // 2. 乐观改
try {
await api.patch(`/todos/${id}`, { done: !prev.done }); // 3. 发请求
} catch (e) {
store.todos.set(id, prev); // 4. 回滚
toast.error('操作失败');
}
}
这段代码在 demo 里跑得好好的,上线三个月后会长出五种病:并发回滚错乱(连点两次,第一次失败时回滚到的是第二次点击之后的快照)、服务端衍生状态丢失(服务端顺带写的 completedAt 乐观值里没有,UI 闪一下)、多端不同步(本地乐观值覆盖了远端新值)、离线直接躺平(断网时请求全挂、回滚全炸)、每个接口都要重写一遍(50 个接口就是 50 份手写回滚逻辑,没人维护得动)。
同步引擎(Sync Engine)的本质,就是把上面这五个病,从「每个业务开发者各自解决」变成「基础设施统一解决」。
1.2 三股力量把 Local-First 从理想推成刚需
Ink & Switch 在 2019 年提出 Local-First 的七条理想(不慢、多设备、离线可用、协作、长期保存、默认安全隐私、用户拥有最终控制权)时,业界普遍当成一篇有情怀的论文。2026 年它变成工程刚需,靠的是三股完全世俗的力量:
第一,设备端性能溢出了。 中端手机内存 12GB 起步;浏览器有了 OPFS 做真正的随机写、WASM SIMD 跑高性能编解码、SharedArrayBuffer 上多线程。把几十万行数据塞进客户端,从「疯了吧」变成了「就这?」。
第二,协作从功能变成了默认预期。 用户被 Figma、Notion、Linear 养刁了。你做一个 B 端表单系统,产品经理会问「为什么两个人不能同时编辑」。协作不再是加分项,是缺失项。
第三,也是 2026 年最新的变量——AI Agent 成了「第二类用户」。 当产品接入 Agent,它会在后台以每秒数十次的频率批量修改文档/任务/表格,同时人类也在改同一份数据。这不是「偶尔冲突」,是持续性的高频并发写。传统「后写覆盖 + 提示用户刷新」在这种负载下直接崩溃——用户会发现自己刚敲的字被 Agent 抹掉了。
结论:并发写冲突从边缘 case 变成了主干路径。这才是同步引擎在 2026 年集体爆发的真正原因。
二、核心概念:同步引擎到底在同步什么
2.1 一个精确的定义
同步引擎 = 客户端本地数据副本 + 本地查询能力 + 变更捕获与传播机制 + 冲突收敛策略 + 服务端权威与持久化。
关键在于最后两项。前三项很多状态管理库都做了(比如带持久化的 Redux),但冲突收敛策略和服务端权威才是分水岭。
一个合格的同步引擎必须回答三个问题:
| 问题 | 传统架构的答案 | 同步引擎的答案 |
|---|---|---|
| 写操作何时被认为成功? | 服务端 200 之后 | 写进本地存储那一刻 |
| 两个副本分叉了怎么办? | 后写覆盖,或报错让用户重试 | 有确定性的合并算法,必然收敛 |
| 客户端只能看到部分数据怎么办? | 每次查询都问服务端 | 声明式订阅(shape/query),服务端推增量 |
2.2 收敛性:强最终一致的数学要求
所有同步引擎的正确性根基,是 SEC(Strong Eventual Consistency,强最终一致性):
任意两个副本,只要接收到了相同的操作集合(不论顺序、不论重复),它们的状态必然完全相同。
这比「最终一致性」强,因为它不要求「等一段时间静默期」,只要求「收到同一批操作」。要满足 SEC,合并函数必须同时具备三个代数性质:
- 交换律
merge(a, b) = merge(b, a)—— 操作乱序到达没关系 - 结合律
merge(merge(a,b), c) = merge(a, merge(b,c))—— 分批合并没关系 - 幂等性
merge(a, a) = a—— 重复投递没关系
数学上这叫半格(join-semilattice),merge 就是求最小上界。满足这三条,你就能在一个丢包、乱序、重传的网络上安全同步,不需要任何全局协调。 这就是 CRDT 的全部魔法——它没有发明新物理,只是把「冲突」的定义域限制到了一个数学上一定有解的子集里。
反过来,CRDT 的代价也全部来自这个限制:为了让合并可交换,必须为数据附加大量元信息(唯一 ID、逻辑时钟、墓碑),而这些元信息就是后面所有性能问题的根源。这个 trade-off 请记住,第五节会反复回到它。
2.3 逻辑时钟:一切的地基
在讲具体算法前,必须先把时间讲清楚,因为分布式系统里没有「现在」。
墙钟(Date.now())不可用:设备时间会漂移、会被用户手动改、跨时区、NTP 校准会跳变。用墙钟做冲突裁决,等于把数据一致性押在用户的系统设置上。
实践中有三层选择:
Lamport 时钟——最简单:一个单调递增的计数器 {counter, peerId},本地事件 counter++,收到远端事件时 counter = max(local, remote)。比较时 counter 优先,counter 相同则用 peerId 打破平局。
注意 peerId 打破平局这一步,它把偏序变成了全序,这是「确定性收敛」的必要条件——所有副本用同样的规则比较,得到同样的结果。
向量时钟 / Version Vector——记录「我见过每个 peer 的多少个操作」,能判断因果关系(谁在谁之前、是否并发):
type VersionVector = Map<string, number>; // peerId -> 已知的最大 counter
/** a ≤ b:a 的所有进度都被 b 覆盖 */
function isDominated(a: VersionVector, b: VersionVector): boolean {
for (const [peer, ctr] of a) if ((b.get(peer) ?? 0) < ctr) return false;
return true;
}
const isConcurrent = (a: VersionVector, b: VersionVector) =>
!isDominated(a, b) && !isDominated(b, a);
版本向量是增量同步的关键:客户端把自己的 VV 发给服务端,服务端只回传 VV 之外的操作,而不是整个文档。Yjs 的 encodeStateVector / encodeStateAsUpdate(doc, sv)、Loro 的 export({ mode: 'update', from: version }) 都是这个思路。
HLC(Hybrid Logical Clock)——把物理时间的高位和逻辑计数器拼在一起,既保证因果一致,又让时间戳大致对应真实时间(方便做 LWW 和人类可读的历史):
class HybridLogicalClock {
private wallMs = 0;
private logical = 0;
constructor(private readonly peerId: string) {}
now(): string {
const phys = Date.now();
if (phys > this.wallMs) { this.wallMs = phys; this.logical = 0; }
else this.logical += 1; // 物理时间没前进,靠逻辑位推进
// 定长编码,保证字符串字典序 == 时间序,可直接当数据库主键排序
return `${String(this.wallMs).padStart(15,'0')}:` +
`${String(this.logical).padStart(5,'0')}:${this.peerId}`;
}
observe(remote: string): void {
const [w, l] = remote.split(':');
const rw = Number(w), rl = Number(l);
const maxWall = Math.max(this.wallMs, rw, Date.now());
if (maxWall === this.wallMs && maxWall === rw) this.logical = Math.max(this.logical, rl) + 1;
else if (maxWall === this.wallMs) this.logical += 1;
else if (maxWall === rw) this.logical = rl + 1;
else this.logical = 0;
this.wallMs = maxWall;
}
}
HLC 是工程上的甜点:字符串定长可比较,可以直接进 SQLite 索引做范围扫描,而且时钟漂移最多影响排序精度,不影响正确性。如果你要自己造同步引擎,HLC 基本是默认选择。
三、三条技术路线:OT、CRDT、服务端权威 + IVM
很多文章把「协同编辑」直接等同于 CRDT,这是错的。2026 年的生产系统里,实际上并存三条路线,各有明确的适用边界。
3.1 路线一:OT(Operational Transformation)
Google Docs 走的路。核心思路是:操作不变,位置变。
当 A 在位置 0 插入 "X",B 同时在位置 5 删除 1 个字符,服务端要把 B 的操作「变换」成 A 已经插入后的坐标系(位置 5 → 位置 6)。
OT 的优点:数据结构极简,文档就是一个字符串,没有任何元信息膨胀,内存和存储开销最小。
OT 的致命缺点:变换函数的正确性证明极其困难。业界公认的事实是,大多数 P2P(无中心服务器)OT 算法的原始论文都被后来证明存在正确性缺陷。Google Docs 之所以能用,是因为它有一个中心服务器强制线性化所有操作,把 N 路并发降级成了「每个客户端只需要跟服务端的历史做变换」,复杂度从 O(N²) 降到 O(N)。
结论:如果你有一个强中心服务器、只做纯文本/富文本、且愿意为算法正确性投入大量测试,OT 依然是内存效率最高的方案。否则,别自己写 OT。
3.2 路线二:CRDT
不需要中心服务器,靠数据结构自身的代数性质保证收敛。代价是元信息膨胀。这是本文的主线,第四、五节详细展开。
3.3 路线三:服务端权威 + 增量视图维护(IVM)
这是 2026 年被严重低估、但在业务型应用里最实用的一条路。代表作是 Rocicorp 的 Zero 和 ElectricSQL。
它的核心洞察是:大部分业务应用根本不需要 CRDT。
想想项目管理工具。用户的操作是「把任务 A 改成 Done」「把任务 B 拖到 Sprint 2」——语义是整字段替换,不是「在第 37 个字符后插入一个字」。两个人同时把同一个任务改成不同状态,正确的产品行为就是「后提交的赢」,而不是合并成一个既 Done 又 In Progress 的叠加态。
对这类场景,CRDT 的元信息开销纯属浪费。更好的模型是:
- 客户端有一份本地数据副本(IndexedDB / OPFS 里的关系型存储)
- 客户端写 mutator(一段可在两端执行的纯函数),本地立即执行 → 乐观状态
- mutation 同时发给服务端,服务端重新执行同一段逻辑(带完整权限校验),写进真正的 Postgres
- 服务端通过 logical replication 捕获数据变更,算出「哪些客户端的哪些查询受影响」,只推增量行
- 客户端收到权威数据后,丢弃本地乐观状态,rebase 尚未确认的 mutation
第 5 步是精髓,Replicache 首创、Zero 继承的 rebase 模型:
// 客户端本地状态 = 服务端权威快照 + 依次重放所有「未确认」的 mutation
function computeLocalView(
authoritative: Snapshot,
pendingMutations: Mutation[],
): Snapshot {
let view = authoritative;
for (const m of pendingMutations) {
view = applyMutator(view, m); // 与服务端执行的是同一段代码
}
return view;
}
// 服务端确认了 mutationID <= N 的所有变更后
function onServerPush(newSnapshot: Snapshot, confirmedUpTo: number) {
pending = pending.filter(m => m.id > confirmedUpTo); // 丢掉已确认的
render(computeLocalView(newSnapshot, pending)); // 剩下的重放一遍
}
这套模型的好处是巨大的:回滚逻辑完全自动(服务端拒绝的 mutation 不会进权威快照,rebase 时自然消失,业务代码一行 try/catch 都不用写);权限校验只在服务端做一份,客户端 mutator 被篡改也没用;数据模型就是普通关系表,可以用 SQL、JOIN、建索引,不需要为 CRDT 重设计 schema。
Zero 在此之上再加一层 ZQL + 客户端增量视图维护:客户端查询同时在本地和服务端运行,zero-cache 通过 Postgres 逻辑复制感知变更,用 IVM 只重算受影响的结果推给对应客户端:
export const queries = defineQueries({
issues: {
// 客户端调用时先打本地库拿即时结果,同时把 (queryName, args) 发给 zero-cache
openByAssignee: defineQuery(
z.object({ assigneeID: z.string() }),
({ args: { assigneeID }, ctx }) =>
zql.issue
.where('assigneeID', assigneeID)
.where('status', '!=', 'closed')
.where('orgID', ctx.orgID) // ctx 由服务端注入,客户端改不了
.related('comments', q => q.orderBy('createdAt', 'desc').limit(20))
.orderBy('updatedAt', 'desc').limit(100),
),
},
});
注意 ctx 的设计:参数由客户端提供(不可信),上下文由服务端注入(可信)。这解决了 CRDT 体系里最难受的问题——细粒度权限。
3.4 选型的第一性原则
我把这个判断浓缩成一句话:
看你的写操作,语义上是「在序列中的某个位置插入/删除」,还是「把某个字段设成某个值」。
- 前者(文本、富文本、白板路径、代码编辑器)→ 必须 CRDT 或 OT,因为位置本身是需要被合并的信息。
- 后者(任务状态、表单字段、配置项、订单)→ 优先服务端权威 + IVM,CRDT 是过度设计。
- 两者都有(Notion 那种:块结构 + 块内文本)→ 混合:块内文本用 CRDT,块的属性/顺序用 LWW 或服务端权威。
绝大多数团队的错误,是给一个 CRUD 系统上了 Yjs,然后花半年时间跟内存膨胀、权限缺失、服务端无法读取文档内容这三件事搏斗。
四、CRDT 内部拆解:从计数器到列表,难度是指数级上升的
4.1 两种风格:状态型 vs 操作型
- CvRDT(状态型):传播完整状态,接收方
merge。对网络零要求(丢包重传随便),但状态大时带宽爆炸。 - CmRDT(操作型):传播操作。带宽小,但要求传输层保证「恰好一次 + 因果序投递」。
- δ-CRDT(增量状态型):现代方案,传播状态的增量片段,兼顾两者。Yjs、Loro、Automerge 都属于这一类的变体。
4.2 热身:三个简单 CRDT 的完整实现
LWW-Register / LWW-Map——业务系统里 90% 场景够用:
interface LwwCell<T> { value: T; ts: string } // ts 用 HLC
class LwwMap<T> {
private cells = new Map<string, LwwCell<T>>();
constructor(private readonly clock: HybridLogicalClock) {}
set(key: string, value: T): void {
this.cells.set(key, { value, ts: this.clock.now() });
}
get(key: string): T | undefined {
return this.cells.get(key)?.value;
}
merge(other: LwwMap<T>): void {
for (const [key, remote] of other.cells) {
const local = this.cells.get(key);
// 字符串比较即可,因为 HLC 是定长字典序编码
if (!local || remote.ts > local.ts) {
this.cells.set(key, remote);
this.clock.observe(remote.ts);
}
}
}
}
注意粒度:LWW 是按 key 生效的。两个人同时改同一个对象的不同字段,两边的修改都会保留(因为是不同 key);改同一个字段才会有一方被覆盖。这个粒度对业务系统通常刚好。Figma 的多人协作在属性层面走的就是类似思路——服务端权威 + 属性级 LWW,而不是对整个文档做通用 CRDT。
OR-Set(Observed-Remove Set)——解决「加了又删、删了又加」的经典难题:
class OrSet<T> {
private adds = new Map<T, Set<string>>(); // element -> 添加时产生的 tag 集合
private removes = new Map<T, Set<string>>();
constructor(private readonly clock: HybridLogicalClock) {}
add(el: T): void {
if (!this.adds.has(el)) this.adds.set(el, new Set());
this.adds.get(el)!.add(this.clock.now());
}
/** 删除时只删「我当前观察到的那些 tag」——Observed-Remove 的精髓 */
remove(el: T): void {
const observed = this.adds.get(el);
if (!observed) return;
if (!this.removes.has(el)) this.removes.set(el, new Set());
for (const tag of observed) this.removes.get(el)!.add(tag);
}
has(el: T): boolean {
const r = this.removes.get(el) ?? new Set();
// 只要存在一个「加了但没被删」的 tag,元素就在集合里
for (const tag of this.adds.get(el) ?? []) if (!r.has(tag)) return true;
return false;
}
merge(other: OrSet<T>): void {
const union = (dst: Map<T, Set<string>>, src: Map<T, Set<string>>) => {
for (const [el, tags] of src) {
if (!dst.has(el)) dst.set(el, new Set());
for (const x of tags) dst.get(el)!.add(x);
}
};
union(this.adds, other.adds);
union(this.removes, other.removes);
}
}
为什么需要 tag? A 删除 "x" 的同时 B 重新添加 "x"。若只记「x 被删了」,B 的添加会被误杀;有了 tag,B 的添加带新 tag,而 A 的删除只覆盖它当时看到的旧 tag,并发的添加自然胜出——符合直觉。
代价也很明显:removes 里的 tag 就是墓碑(tombstone),永远不能安全删除——你不知道会不会有个断网三个月的客户端带着旧 tag 回来。墓碑积累,是所有 CRDT 的原罪。
4.3 硬骨头:列表 CRDT 与「交错」问题
到了有序列表(也就是文本),难度陡增。
核心难题:位置索引在并发下毫无意义。A 在索引 5 插入时,B 可能已经在索引 2 删了三个字符,A 的「5」在 B 的文档里指向完全不同的地方。
共同解法:不用索引,给每个字符一个永久不变的唯一 ID,插入时描述的是「插在 ID=X 的元素之后」,而不是「插在第 5 位」。
第一代思路:Logoot / LSEQ(基于稠密序标识符)
给每个字符分配一个可无限细分的小数位置标识(比如 [3, 7, 12]),要在两个位置之间插入,就生成一个字典序介于两者之间的新标识。
问题:交错(interleaving)灾难。 两个人在同一位置并发插入一整个单词:
初始: "Hello |"(光标在末尾)
A 输入: "world"
B 输入: "there"
期望结果: "Hello worldthere" 或 "Hello thereworld"
Logoot 实际可能产生: "Hello wtohreerled" ← 两个单词的字符交叉了
这是真实会发生的用户可见 bug,也是现代文本 CRDT 都不用这一类算法的原因。
第二代:RGA / YATA(基于因果链接)
RGA(Replicated Growable Array),Automerge 的基础。每个元素记录 originLeft(它插入时紧邻的左边元素 ID)。合并时,如果多个元素声称插在同一个 origin 之后,按 ID 降序排(新的在前)。
YATA(Yet Another Transformation Approach),Yjs 的算法。比 RGA 更进一步,每个元素同时记录 origin(左邻)和 rightOrigin(右邻),插入时用这两个边界限定「合法插入区间」,然后在区间内按 ID 排序。双边界大幅降低了交错概率。
下面是一个可运行的 YATA 精简实现,去掉了 Yjs 的所有性能优化(块合并、Search Marker),只保留算法骨架:
interface Item {
id: string; // "peerId:clock"
content: string; // 单字符
originLeft: string | null; // 插入时的左邻 ID
originRight: string | null; // 插入时的右邻 ID
deleted: boolean; // 墓碑标记,不做物理删除
}
class YataText {
items: Item[] = []; // 保持「已收敛的全序」
private clock = 0;
constructor(private readonly peerId: string) {}
toString(): string { // 可见文本 = 过滤掉墓碑
return this.items.filter(i => !i.deleted).map(i => i.content).join('');
}
/** 可见索引 → items 数组物理下标(墓碑也占位) */
private phys(index: number): number {
let visible = 0;
for (let p = 0; p < this.items.length; p++) {
if (visible === index) return p;
if (!this.items[p].deleted) visible++;
}
return this.items.length;
}
insert(index: number, text: string): Item[] {
const created: Item[] = [];
for (const ch of text) {
const p = this.phys(index);
const item: Item = {
id: `${this.peerId}:${++this.clock}`,
content: ch,
originLeft: p > 0 ? this.items[p - 1].id : null,
originRight: p < this.items.length ? this.items[p].id : null,
deleted: false,
};
this.integrate(item);
created.push(item);
index++;
}
return created;
}
delete(index: number, len: number): void {
for (let n = 0; n < len; n++) {
const p = this.phys(index);
if (p < this.items.length) this.items[p].deleted = true; // 只打墓碑
}
}
/** YATA 核心:把一个 item 放到确定性的位置上 */
integrate(item: Item): void {
if (this.items.some(i => i.id === item.id)) return; // 幂等:重复投递忽略
const at = (id: string | null, dflt: number) =>
id ? this.items.findIndex(i => i.id === id) : dflt;
const leftIdx = at(item.originLeft, -1);
const rightIdx = at(item.originRight, this.items.length);
// 只在 (leftIdx, rightIdx) 这个「合法插入区间」内扫描竞争者
let dest = leftIdx + 1, scan = leftIdx + 1;
while (scan < rightIdx) {
const other = this.items[scan];
const oLeft = at(other.originLeft, -1);
// 竞争者左边界更靠左 → 它「跨越」了我,我排它前面
if (oLeft < leftIdx) break;
// 完全并发 → 用 ID 全序打破平局,保证所有副本结果一致
if (oLeft === leftIdx && item.id < other.id) break;
dest = ++scan;
}
this.items.splice(dest, 0, item);
}
}
// ── 验证收敛性 ──
const A = new YataText('A'), B = new YataText('B');
for (const it of A.insert(0, 'Hello ')) B.integrate(it);
const opsA = A.insert(6, 'world'); // A 在末尾打 world
const opsB = B.insert(6, 'there'); // B 同时在末尾打 there
for (const it of opsB) A.integrate(it);
for (const it of opsA) B.integrate(it);
console.log(A.toString()); // "Hello worldthere"
console.log(B.toString()); // "Hello worldthere" ← 一致,且单词没交错
跑一下会发现:两个副本结果完全一致,且 world 和 there 各自保持完整,没有交错。这就是 YATA 双边界的价值。
但这个实现有个致命性能问题:findIndex 是 O(n),integrate 里还嵌套调用,整体接近 O(n²)。真实的 Yjs 用了三个关键优化:
- 双向链表 + ID 索引 Map,把
findIndex降到 O(1) - Item 块合并:连续输入的字符(同一 peer、连续 clock、相邻位置)合并成一个
Item,content存整个字符串。用户打一段 1000 字的话,可能只产生 1 个 Item 而不是 1000 个。 这是 Yjs 内存效率的最大来源。 - Search Marker:缓存最近访问位置,利用「编辑具有局部性」的特点,把索引定位从 O(n) 降到近似 O(1)。
第三代:Fugue(最大化非交错)
Matthew Weidner 的 Fugue 算法(论文 The Art of the Fugue,arXiv:2305.00583)从理论上证明了什么叫「最大化非交错(maximal non-interleaving)」,并给出了满足该性质的算法。Loro 的文本 CRDT 就基于 Fugue。
直观理解:YATA 大部分情况下不交错,但存在构造性反例(尤其是多轮往返的并发编辑)。Fugue 把结构建模成一棵树并严格定义左右 origin 的语义,把交错可能性降到理论最低。
4.4 可移动树:被忽视的硬需求
文本讲完了,还有一个在 Notion 类产品里天天遇到的结构:树的移动。
难点在于一个非常刁钻的并发场景:
初始树: A B
| |
A1 B1
并发操作:
Peer 1: 把 A 移到 B 下面 → B > A > A1
Peer 2: 把 B 移到 A 下面 → A > B > B1
朴素合并的结果:A 的父是 B,B 的父是 A → 成环,A 和 B 从树上脱离,数据丢失
Martin Kleppmann 在 A highly-available move operation for replicated trees(2021)给出的解法叫 undo-do-redo:
- 所有 move 操作按时间戳排成全序,本地维护一个操作日志
- 收到一个时间戳「早于」某些已应用操作的远端 move 时,先撤销(undo)所有比它晚的操作
- 应用这个新操作(do),此时做环检测:如果这次移动会成环,就直接丢弃这个操作(这是合法的,因为所有副本用同样的规则判断,会得到同样的结论)
- 再按顺序重放(redo)刚才撤销的那些操作
注意第 3 步「直接丢弃」的合理性:因为丢弃规则本身是确定性的(基于全序时间戳和相同的环检测算法),所有副本都会丢弃同一个操作,收敛性依然成立。用户看到的效果是「有一个人的移动操作没生效」,这远好于「树结构损坏」。
Loro 的 Moveable Tree 就是这一套思路的工业实现,并额外解决了「同一层级内的兄弟节点排序」问题(用 Fractional Index + 定期重平衡)。
五、性能危机与 Eg-walker:CRDT 的第二次进化
5.1 元信息膨胀有多可怕
回到第二节埋下的伏笔:CRDT 的代价是元信息。我们来算一笔具体的账。
一个「朴素实现」的文本 CRDT,每个字符要存:
| 字段 | 大小(JS 对象,含指针和对象头) |
|---|---|
content 单字符 | ~16 B(字符串对象) |
id (peerId + clock) | ~40 B(字符串)或 ~16 B(数值对) |
originLeft | ~40 B |
originRight | ~40 B |
deleted + 链表指针 | ~40 B |
| JS 对象头 + 属性表 | ~56 B |
| 合计 | 约 230 B/字符 |
一个 1 字节的字符,膨胀了 230 倍。 一篇 10 万字的文档,原文 300KB,CRDT 结构占 20MB+。再加上历史里的墓碑,一个被反复修改几年的文档能吃掉几百 MB——浏览器直接崩给你看。
这就是 2020 年前后大家吐槽「CRDT 不实用」的根源。而现代 CRDT 库(Yjs / Automerge 2 / Loro)已经把这个数字压到了接近原文大小的 1~3 倍。它们是怎么做到的?
5.2 优化手法一:块合并(Run-Length 思维)
人类打字有极强的局部性:连续敲 hello,产生的是同一 peer、连续 clock、物理相邻的 5 个 item。它们可以合并成一个:
interface Block {
startId: { peer: number; clock: number }; // 起始 ID
content: string; // "hello",长度即 length
originLeft: ItemId | null; // 只需记第一个字符的左邻
originRight: ItemId | null;
deletedRanges: Uint32Array; // 块内被删的区间,而非每字符一个 flag
}
// 合并条件:同 peer、clock 连续、物理相邻、删除状态一致
效果是数量级的:一段连续输入的 1000 字符,从 1000 个 item(约 230KB)变成 1 个 block(约 2KB)。 只有在别人在你的文字中间插入时,块才会被 split。
这也解释了一个反直觉的现象:同样是 10 万字,「一个人从头写到尾」的文档比「十个人交替编辑」的文档小一个数量级,因为后者的块被切得粉碎。
5.3 优化手法二:列式编码(Columnar Encoding)
Automerge 2.0 引入、Loro 也采用的手法。传统序列化是「行式」的——一个 item 的所有字段挨着存。列式则是把所有 item 的同一字段放在一起:
行式: [id1,content1,left1,right1][id2,content2,left2,right2]...
列式: [id1,id2,id3,...][content1,content2,...][left1,left2,...]
为什么列式好?因为同一列的数据高度相似,压缩率极高:
clock列几乎是等差数列 → Delta 编码 + LEB128 变长整数后,每个值常常只占 1 字节甚至更少peerId列大量重复 → RLE(游程编码),[A,A,A,A,B,B]变成[(A,4),(B,2)]originLeft列往往就是「前一个 item 的 id」→ 用一个标记位表示「同上一个 +1」,几乎不占空间
/** Delta + RLE 编码一列单调递增的 clock(varint 部分省略) */
function encodeClockColumn(clocks: number[]): number[] {
const out: number[] = [];
let prev = 0, runValue = 0, runLen = 0;
const flush = () => { if (runLen) out.push(runValue, runLen); };
for (const c of clocks) {
const delta = c - prev; prev = c;
if (delta === runValue) runLen++;
else { flush(); runValue = delta; runLen = 1; }
}
flush();
return out; // 连续输入时 delta 恒为 1 → 整列坍缩成 [1, n] 两个数
}
实测在真实文档上,列式 + Delta + RLE 再过一遍通用压缩,能把带完整历史的 CRDT 快照压到与原始文本同一量级。Automerge 团队公布过一组常被引用的数据:一个包含约 26 万次编辑操作的论文文档,完整历史压缩后仅几百 KB,而最终文本本身约 100KB——带着全部历史,只比纯文本大一点点。
5.4 优化手法三:Eg-walker——最激进的那一刀
前两个优化是「把 CRDT 结构压小」。Eg-walker(Event Graph Walker,由 Joseph Gentle 与 Martin Kleppmann 提出,实现于 diamond-types,Loro 做了改造吸收)走的是完全不同的路:干脆不要常驻的 CRDT 结构。
核心洞察在于一个被长期忽视的事实:
CRDT 的那堆元信息(originLeft / originRight / 墓碑),只在「合并并发编辑」时才有用。而在绝大多数时间里,文档根本没有并发分叉。
一个团队协作文档,99% 的时间是线性的编辑历史(我改完你改,你改完他改)。只有在极短的并发窗口里,才需要真正的 CRDT 合并逻辑。为了那 1% 的场景,让 99% 的时间都背着 200 倍的内存开销,这笔账是亏的。
Eg-walker 的方案:
- 持久化的只有两样东西:最终文本内容(一个普通的 rope / 字符串),以及一张事件图(Event Graph)——记录每个操作及其因果父节点的 DAG,操作本身用「简单坐标」表示(插入位置 + 内容),不带 CRDT 元信息。
- 正常线性编辑:直接应用到文本上,事件图追加一条边。开销 ≈ 普通编辑器。
- 只有当出现真正的并发分叉时:临时构建一个 CRDT 结构,只覆盖「分叉点到合并点」这一小段历史,用它算出正确的合并结果,然后把这个临时结构扔掉。
传统 CRDT:
内存 = O(全部历史操作数) ← 永久背着
Eg-walker:
内存 = O(当前文档长度) + O(事件图元数据)
合并时临时峰值 = O(并发分叉区间的操作数) ← 用完就扔
骨架大致是这样:
fn merge_remote(doc: &mut Document, remote_ops: Vec<Op>, remote_ver: Version) {
let fork = doc.graph.common_ancestor(&doc.version, &remote_ver);
let local_branch = doc.graph.ops_between(fork, doc.version);
if local_branch.is_empty() {
doc.apply_linear(remote_ops); // 快路径:无分叉,直接线性应用
return;
}
// 慢路径:只为这段分叉临时建 CRDT 结构,算出等价的可线性应用序列
let mut tmp = TransientCrdt::from_fork(fork, &local_branch, &remote_ops);
doc.apply_linear(tmp.resolve());
// tmp 离开作用域即释放 —— 元信息不进入长期内存
}
代价是什么? 合并时需要回放分叉区间的历史——两个人各自离线编辑三个月,这个区间会很大,合并有明显 CPU 尖峰;而且「时间旅行」变成了重放而非直接读状态。
对绝大多数在线协作场景(分叉窗口以秒计),这个 trade-off 是压倒性划算的:相比把元信息永久驻留内存的传统实现,稳态内存可以低一个数量级,而在真实编辑轨迹的重放测试中速度反而更快(因为常态路径上根本没有 CRDT 开销)。
5.5 优化手法四:Shallow Snapshot(Git 浅克隆思维)
Loro 提供的能力:导出快照时可以只保留最近若干版本的历史,更早的历史压成一个基线状态。就像 git clone --depth=1。
import { LoroDoc } from 'loro-crdt';
const doc = new LoroDoc();
// ... 大量编辑 ...
// 完整快照:含全部历史,支持回溯到任意时间点
const full = doc.export({ mode: 'snapshot' });
// 浅快照:以当前 frontiers 为基线,丢弃更早的历史明细
const shallow = doc.export({ mode: 'shallow-snapshot', frontiers: doc.frontiers() });
console.log(full.byteLength, shallow.byteLength);
// 长期文档上,shallow 常常只有 full 的几十分之一
生产建议:给新加入的客户端下发浅快照(加载快),服务端保留完整历史(供审计和版本回溯)。这是「客户端轻、服务端全」的经典分层。
六、代码实战:三种路线的可运行实现
6.1 Yjs:最成熟的生态,最少的惊喜
Yjs 的优势是生态——编辑器绑定(ProseMirror / TipTap / Slate / CodeMirror / Monaco / Quill)、传输层(WebSocket / WebRTC)、持久化(IndexedDB / LevelDB / Redis)应有尽有。
客户端:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
const doc = new Y.Doc();
// 1) 本地持久化:断网也能打开,且加载不用等网络
const idb = new IndexeddbPersistence('doc-room-42', doc);
await new Promise<void>(r => idb.once('synced', () => r()));
// 2) 网络同步
const provider = new WebsocketProvider('wss://sync.example.com', 'doc-room-42', doc, {
params: { token: getAuthToken() },
});
const text = doc.getText('content');
const meta = doc.getMap('meta');
// 3) 事务:把一批修改打包成一个 update,减少网络包和 undo 粒度
// 第二个参数 origin 用来区分本地/远端来源
doc.transact(() => {
text.insert(0, 'Hello, local-first world');
meta.set('updatedAt', Date.now());
meta.set('updatedBy', currentUserId);
}, 'local-edit');
// 4) 精确监听:拿到的是 delta,可直接喂给编辑器做增量渲染
text.observe(event => console.log('delta:', event.delta));
// 5) Awareness:光标、在线状态。它是「非持久化」通道,不进文档历史
provider.awareness.setLocalStateField('user', { name: currentUserName, color: '#f80' });
provider.awareness.on('change', () =>
renderCursors(Array.from(provider.awareness.getStates().values())));
// 6) Undo:只撤销自己的操作,不会误撤别人的
const undoManager = new Y.UndoManager(text, { trackedOrigins: new Set(['local-edit']) });
document.addEventListener('keydown', e => {
if (e.metaKey && e.key === 'z') e.shiftKey ? undoManager.redo() : undoManager.undo();
});
服务端用 y-protocols/sync 实现:连接建立时先发 SyncStep1(自己的 state vector),对方据此只回传差集;收到的 update 落库并广播给同房间其他连接。这里藏着三个生产经验:
- 持久化存二进制 update,不要存 JSON。 很多人图省事用
doc.toJSON()存库,这会丢掉所有历史和因果信息。下次一个离线客户端带着旧版本回来同步,直接乱套。 - 攒批写入。 一个活跃文档每秒能产生几十个 update,逐个落库会打爆 IO。攒 500ms 合并成一个
mergeUpdates再写,吞吐差一个数量级。 - 空房间延迟卸载。 用户刷新页面会瞬间断开重连,立刻卸载会导致反复从数据库加载。30 秒宽限期能消掉绝大部分抖动。
6.2 Loro:需要版本控制能力时的更优解
Loro 的差异化在于把 Git 的心智模型带进了 CRDT:
const doc = new LoroDoc();
doc.setPeerId(1n);
const text = doc.getText('article');
text.insert(0, '第一版内容');
doc.commit(); // 显式提交,形成一个版本节点
const v1 = doc.frontiers(); // 记住这个版本(类似 git tag)
text.insert(text.length, ',追加了第二段');
doc.commit();
doc.checkout(v1); // 时间旅行:回到 v1
console.log(doc.getText('article').toString()); // "第一版内容"
doc.checkoutToLatest();
// 从历史版本分叉出独立线,改完再合并——CRDT 保证不会「冲突失败」
const branch = LoroDoc.fromSnapshot(doc.export({ mode: 'snapshot' }));
branch.setPeerId(2n);
branch.checkout(v1);
branch.detach(); // 脱离主线,允许在历史点上写
branch.getText('article').insert(5, '(分支补充)');
branch.commit();
doc.import(branch.export({ mode: 'update' }));
const delta = doc.export({ mode: 'update', from: peerVersionVector }); // 增量同步
什么时候选 Loro 而不是 Yjs? 我的判断标准:
- 需要版本历史 / 时间旅行 / 分支作为产品功能(不只是 undo)→ Loro
- 需要 Rust / Swift / 移动原生端(不只是浏览器)→ Loro(loro-ffi 覆盖多语言)
- 需要可移动树(大纲、块结构、文件夹拖拽)→ Loro(原生支持,Yjs 需要自己在 Map 上搭一层)
- 只做浏览器富文本编辑器、要现成的编辑器绑定 → Yjs(生态压倒性优势)
6.3 关系型路线:mutator + rebase 的完整实现
对业务系统,我更推荐这条路。下面用一个精简的自研实现展示核心机制(生产上直接用 Zero 或 Replicache,但理解原理才知道怎么排错)。
共享的 mutator 定义(客户端和服务端跑同一份代码):
// shared/mutators.ts
export interface Tx {
get<T>(key: string): Promise<T | undefined>;
put<T>(key: string, value: T): Promise<void>;
del(key: string): Promise<void>;
/** 服务端注入的可信身份;客户端侧是本地登录态 */
readonly auth: { userId: string; orgId: string };
}
export const mutators = {
// 注意 args.at:时间戳由调用方生成并传入,不在函数体内取
async updateIssueStatus(tx: Tx, args: { id: string; status: string; at: number }) {
const issue = await tx.get<Issue>(`issue/${args.id}`);
if (!issue) return; // 幂等:不存在就跳过
if (issue.orgId !== tx.auth.orgId) throw new Error('forbidden');
await tx.put(`issue/${args.id}`, {
...issue, status: args.status, updatedAt: args.at, updatedBy: tx.auth.userId,
});
},
async createIssue(tx: Tx, args: { id: string; title: string; at: number }) {
// 用客户端生成的 ID(UUID/ULID),不依赖服务端自增主键
if (await tx.get(`issue/${args.id}`)) return; // 幂等:重放安全
await tx.put(`issue/${args.id}`, {
id: args.id, title: args.title, status: 'todo',
orgId: tx.auth.orgId, createdBy: tx.auth.userId, createdAt: args.at,
});
},
};
mutator 必须满足两条铁律,违反了就会出诡异 bug:
- 确定性:不能在函数体内用
Date.now()/Math.random()/crypto.randomUUID()。因为 rebase 会重放同一个 mutation 多次,非确定性会导致每次结果不同,客户端与服务端状态永久分歧。所有时间戳和随机值必须由调用方生成后放进args(注意上面代码里的args.at)。 - 幂等:同一个 mutation 应用两次,结果必须一样。上面用「先查存在性」实现。
客户端引擎:
class SyncClient {
private authoritative = new Map<string, unknown>(); // 服务端确认的权威快照
private pending: PendingMutation[] = []; // 未被确认的
private nextMutationId = 1;
private cookie: string | null = null;
/** 视图 = 权威快照 + 依次重放所有 pending */
private view() {
const v = new Map(this.authoritative);
for (const m of this.pending) applyMutator(v, m);
return v;
}
async mutate(name: string, args: unknown) {
this.pending.push({ id: this.nextMutationId++, name, args });
await this.persistPending(); // 落 IndexedDB,刷新页面不丢
this.render(this.view()); // 立即出效果,0ms
this.schedulePush(); // 异步推送,失败自动重试
}
private async push() {
const res = await fetch('/api/push', { method: 'POST',
body: JSON.stringify({ clientId: this.clientId, mutations: this.pending }) });
if (!res.ok) return; // 失败不回滚,pending 还在,下次重试
await this.pull();
}
private async pull() {
const { patch, cookie, lastMutationId } =
await (await fetch(`/api/pull?cookie=${this.cookie ?? ''}`)).json();
for (const op of patch) { // 服务端只回增量
if (op.op === 'put') this.authoritative.set(op.key, op.value);
else if (op.op === 'del') this.authoritative.delete(op.key);
}
this.cookie = cookie;
// 关键:丢弃已被确认的 mutation,剩下的重放
this.pending = this.pending.filter(m => m.id > lastMutationId);
await this.persistPending();
this.render(this.view());
}
}
注意 push 失败时不回滚这个设计。 这是与手写乐观更新最大的区别:网络失败不是业务失败,pending 队列原封不动,下次重试即可。只有当服务端成功执行并确认(或明确拒绝并推进 lastMutationId)后,它才会离开队列。断网两小时回来,所有操作按序补发,用户全程无感。
服务端:
app.post('/api/push', async (req, res) => {
const { clientId, mutations } = req.body;
const auth = await authenticate(req);
await db.transaction(async trx => {
// 读取该客户端已处理到的 mutationId,实现 exactly-once
const row = await trx('client_state').where({ clientId }).forUpdate().first();
let lastId = row?.lastMutationId ?? 0;
for (const m of mutations) {
if (m.id <= lastId) continue; // 重复投递,跳过
if (m.id !== lastId + 1) break; // 有缺口,等下次补齐(严格保序)
try {
await mutators[m.name](makeTx(trx, auth), m.args); // 同一份业务逻辑
} catch (e) {
// 业务拒绝(如权限不足):跳过执行,但仍推进 ID
logger.warn({ mutation: m, err: e }, 'mutation rejected');
}
lastId = m.id;
}
await trx('client_state').insert({ clientId, lastMutationId: lastId })
.onConflict('clientId').merge();
});
res.json({ ok: true });
});
「业务拒绝时跳过执行但推进 ID」这一行是整个设计的点睛之笔:客户端 rebase 时发现这个 mutation 已被确认(ID 推进了),会把它移出 pending 队列,但权威快照里并没有它的效果——于是那次非法操作在 UI 上自动消失,业务代码里不需要写任何显式回滚。
七、架构分析:同步引擎的五层模型
把上面所有东西抽象一下,任何一个同步引擎都可以拆成五层。排查线上问题时,第一步就是定位问题落在哪一层。
L5 服务端权威层 持久化、权限校验、数据校验、审计、多租户隔离
L4 传输层 WebSocket/SSE、断线重连、背压、批处理、压缩
L3 收敛层 CRDT merge / mutator rebase / 逻辑复制 + IVM
L2 本地查询层 响应式查询、增量视图维护、索引、订阅粒度
L1 本地存储层 IndexedDB / OPFS + SQLite WASM / 内存
7.1 L1 本地存储:2026 年的选型已经变了
| 方案 | 写入吞吐 | 查询能力 | 适用 |
|---|---|---|---|
| 纯内存 | 极高 | 手写遍历 | 小数据、可接受刷新丢失 |
| IndexedDB | 中(事务开销大) | 只有索引扫描,无 JOIN | 通用,兼容性最好 |
| OPFS + SQLite WASM | 高 | 完整 SQL | 数据量大、查询复杂 |
| OPFS 裸文件 | 最高 | 自己实现 | 存 CRDT 二进制快照 |
2026 年的实践共识是:CRDT 二进制快照走 OPFS 裸文件,结构化业务数据走 SQLite WASM(OPFS 后端),IndexedDB 只在需要兼容老浏览器时用。理由很直接——IndexedDB 的事务模型在高频小写入下开销极大,而 OPFS 的 createSyncAccessHandle() 在 Worker 里可以做真正的同步随机读写,性能是另一个量级。
7.2 L2 本地查询:订阅粒度决定性能上限
这是最容易做砸的一层。典型翻车现场:
// ❌ 反面教材:任何一个字段变化都会触发全量重渲染
doc.on('update', () => {
setState(doc.toJSON()); // 整个文档序列化成 JS 对象
});
一个 5MB 的文档,每次按键都 toJSON() 一遍,主线程直接锁死。正确做法是细粒度订阅 + 增量 delta:
// ✅ 只订阅关心的容器,且拿到的是 delta 而非全量
doc.getMap('tasks').observeDeep(events => {
const dirtyIds = new Set<string>();
for (const e of events) {
const taskId = e.path[1]; // path 形如 ['tasks', 'task-123', 'title']
if (typeof taskId === 'string') dirtyIds.add(taskId);
}
for (const id of dirtyIds) rerenderTask(id); // 只重渲染脏节点
});
更进一步是 IVM(增量视图维护):把查询编译成数据流算子图,数据变化时只沿受影响的边传播。这正是 Zero 的 ZQL 在客户端做的事。手写一个简化版:
/** 增量维护的「过滤 + 排序」视图:单行变更 O(log n) 而非 O(n) */
applyRowChange(before: T | null, after: T | null) {
const wasIn = before ? this.matched.has(before.id) : false;
const isIn = after ? this.predicate(after) : false;
if (!wasIn && !isIn) return; // ← 绝大多数变更在这里被短路掉
if (wasIn) this.dropFromSorted((before ?? after)!.id);
if (!isIn) {
this.matched.delete(before!.id);
} else { // 进入集合,或排序键变了 → 二分插回
this.matched.set(after!.id, after!);
this.sorted.splice(binarySearchInsertPos(this.sorted, after!, this.cmp), 0, after!);
}
this.onChange(this.sorted);
}
关键是那行 if (!wasIn && !isIn) return;——绝大多数变更与绝大多数视图无关,短路掉就是性能。
7.3 L3 冲突收敛:权限是 CRDT 最大的软肋
这里必须讲一个很多人踩过的坑。
纯 CRDT 架构下,服务端通常只是一个「二进制中继站」——它不理解文档内容,只负责转发和存储 update。 这带来一个安全问题:
如果一个用户只有「评论权限」,没有「编辑正文权限」,你怎么在服务端拦住他发来的、修改正文的 update?
服务端要拦住它,就必须解析 CRDT 二进制、理解语义、判断这个 update 改了哪些字段——意味着服务端要跑一份完整的 CRDT 实现(Node.js 跑 Yjs 还行,Go / Java 后端就痛苦了),还要为每种权限规则写解析逻辑。
三种应对:
- 文档级权限(最常见):把粒度降到「整个文档可读/可写」,WebSocket 握手时鉴权。简单,但表达不了字段级权限。
- 服务端跑 CRDT + 语义校验:服务端
applyUpdate到一份影子文档,diff 出变更的字段路径,逐条比对权限表,不合规就拒绝并回一个「纠正 update」。正确但重。 - 拆文档:把不同权限域的数据拆成不同 CRDT 文档(正文一个、评论一个、元数据一个),用文档级权限组合出字段级效果。这是我最推荐的做法——简单、可靠、性能好。
而在 mutator + 服务端权威路线里,这个问题根本不存在:mutator 在服务端执行,tx.auth 是可信的,权限判断就是几行普通业务代码。这是关系型路线相对 CRDT 的最大结构性优势。
7.4 L4 传输层:三个必须处理的现实
背压。 一个用户离线两天回来,服务端要推几万条变更。不做流控,客户端会被淹没、主线程卡死。做法是维护一个发送队列,每次 flush 前检查 ws.bufferedAmount(WebSocket 原生的背压信号,很多人不知道它存在),超过阈值(比如 1MB)就暂停,等缓冲区排空再继续。
批处理。 用户快速打字每秒产生几十个 update。逐个发送 = 几十个 WebSocket 帧 + 几十次服务端处理。攒 50~100ms 合并一次,网络包数量降一个数量级,用户完全感知不到(因为本地 UI 早就更新了)。
Awareness 与文档数据分离。 光标位置、在线状态是高频、易失、不需要持久化的。绝对不要把它们写进 CRDT 文档——否则文档历史里会塞满几百万条光标移动记录。用独立的 ephemeral 通道传,服务端不落库。
7.5 L5 服务端:部分同步是绕不开的坎
客户端不可能同步全部数据。一个有 10 万条工单的组织,客户端只需要「我负责的 + 我关注的 + 当前视图里的」。
两种主流机制:
- Shape / 声明式订阅(ElectricSQL 风格):客户端声明一个类似
WHERE assignee = me AND status != 'closed'的形状,服务端持续维护这个形状的增量。 - Query-driven 动态同步(Zero 风格):客户端写普通查询,引擎自动推断需要哪些数据、自动订阅、不需要时自动 GC。
第二种体验更好但实现难度高一个数量级:要处理动态 shape 变更——用户切换筛选条件时,订阅集合要平滑迁移,已有数据不能重传。
服务端捕获变更,强烈推荐 Postgres 逻辑复制,而不是触发器或轮询:
-- 创建逻辑复制槽,同步引擎从这里消费 WAL
SELECT pg_create_logical_replication_slot('sync_engine_slot', 'pgoutput');
CREATE PUBLICATION sync_engine_pub FOR TABLE issues, comments, projects;
-- 关键:REPLICA IDENTITY FULL 才能在 UPDATE 事件里拿到「变更前的完整行」
-- 没有它,IVM 无法判断一行是「进入」还是「离开」某个查询结果集
ALTER TABLE issues REPLICA IDENTITY FULL;
REPLICA IDENTITY FULL 这一行是血泪教训。默认只有主键会出现在 UPDATE 的 old tuple 里,IVM 拿不到旧值,就无法判断「这行原本是否匹配某个查询」,导致视图静默更新错误。代价是 WAL 体积增大,所以只对参与同步的表开。
八、性能优化:从 3 秒白屏到 80ms 可交互
这一节是实操清单,按投入产出比排序。
8.1 冷启动:把加载路径拆开
用户打开一个大文档,白屏 3 秒。时间去哪了?
读 IndexedDB 快照 400ms
反序列化 CRDT 结构 1200ms ← 大头,且阻塞主线程
构建初始 DOM 300ms
等 WebSocket 首次同步 800ms ← 完全没必要等
首次渲染 300ms
三刀下去:
第一刀:不要等网络。 本地快照加载完立即渲染,网络同步在后台进行。这一刀直接砍掉 800ms。
// ❌ 串行等待
await idbPersistence.whenSynced;
await wsProvider.whenSynced; // 这行是罪魁祸首
render();
// ✅ 本地优先,网络异步
await idbPersistence.whenSynced;
render(); // 先给用户看到东西
wsProvider.on('synced', () => { /* 增量更新,不阻塞 */ });
第二刀:反序列化搬进 Worker。 CRDT 反序列化是纯 CPU 密集操作,放主线程就是白屏。搬到 Worker,主线程可以先渲染骨架屏。
// worker.ts
import * as Y from 'yjs';
self.onmessage = (e) => {
if (e.data.type !== 'load') return;
const doc = new Y.Doc();
Y.applyUpdate(doc, e.data.snapshot);
// 只把「首屏需要的部分」传回主线程,剩余内容分片传
self.postMessage({ type: 'first-screen',
text: doc.getText('content').toString().slice(0, 5000) });
};
第三刀:浅快照。 用 Loro 的 shallow snapshot,或对 Yjs 做定期历史压缩,让客户端加载的字节数从几 MB 降到几百 KB。
优化后:
读 OPFS 浅快照 80ms
Worker 反序列化 200ms(不阻塞主线程)
骨架屏渲染 16ms ← 用户此时已看到界面
首屏内容渲染 60ms
────────────────────────────
可交互 ~80ms
网络同步 后台完成,无感
8.2 编辑期:别让每次按键都全量重算
| 优化点 | 做法 | 收益 |
|---|---|---|
| 渲染粒度 | 用 delta 做增量 DOM 更新,不重建整棵树 | 大文档下 10~50x |
| 事务合并 | doc.transact() 包裹批量修改 | update 数量降 10x |
| 网络批处理 | 50~100ms 攒批发送 | 网络包降 10~30x |
| 索引缓存 | Search Marker / 位置缓存 | 定位 O(n) → 近似 O(1) |
| 大文档分片 | 按章节拆子文档,按需加载 | 内存降到 1/N |
大文档分片值得展开。一个 500 页的文档没必要把整个 CRDT 装进内存,做法是按结构切成多个子文档,用一个轻量「目录文档」记录结构:
// 目录文档轻量,永远加载;章节文档按需加载,滚出视口后卸载
const outline = rootDoc.getArray<{ id: string; title: string }>('chapters');
const loaded = new Map<string, Y.Doc>();
async function ensureChapter(id: string): Promise<Y.Doc> {
let d = loaded.get(id);
if (d) return d;
d = new Y.Doc();
const snap = await opfs.read(`chapter-${id}.bin`);
if (snap) Y.applyUpdate(d, snap);
new WebsocketProvider(WS_URL, `chapter-${id}`, d); // 每章一个 room
loaded.set(id, d);
return d;
}
function unloadChapter(id: string) {
const d = loaded.get(id);
if (!d) return;
opfs.write(`chapter-${id}.bin`, Y.encodeStateAsUpdate(d)); // 存盘再卸
d.destroy();
loaded.delete(id);
}
注意跨文档操作的语义损失:把内容从第 3 章拖到第 7 章,涉及两个 CRDT 文档,无法在一个原子事务里完成。并发时可能出现「两边都有」或「两边都没有」。生产上的处理是:跨文档移动走服务端事务,牺牲一点即时性换正确性。这是分片必须交的税,设计时就要跟产品讲清楚。
8.3 服务端优化
热文档常驻内存 + LRU。 反复从数据库加载并反序列化是服务端最大开销。用 LRU 缓存活跃文档,冷文档淘汰前落盘。
Update 合并压缩(Compaction)。 数据库里存的是 append-only 的 update 流,长期越积越多。定期把某个文档的几万条 update 全部 applyUpdate 进一个临时 Y.Doc,再 encodeStateAsUpdate 压成一条,原子替换回去。这个任务必须小心并发:压缩期间若有新 update 写入,要么用行锁,要么用「只压缩到某个已知版本、之后的保留」的策略。我见过因为压缩与写入竞态导致文档内容回退的线上事故。
水平扩展的房间亲和性。 同一个文档的所有连接必须落在同一个进程,否则要跨进程同步内存中的 doc。用一致性哈希把 docId 映射到实例,网关按 docId 路由;或者用 Redis Pub/Sub 做跨实例广播,代价是延迟增加和消息放大。
九、选型决策树与生产踩坑清单
9.1 决策树
你的写操作语义是什么?
├─ 「在序列中插入/删除」(文本、富文本、白板、代码)
│ ├─ 版本历史/分支/时间旅行是产品功能? → Loro
│ ├─ 需要 Rust/Swift/移动原生端? → Loro / Automerge
│ ├─ 需要现成编辑器绑定(TipTap 等)? → Yjs(生态压倒性)
│ └─ 强中心服务端 + 极致内存 + 算法能力? → OT(但请三思)
├─ 「设置某个字段为某个值」(业务 CRUD、任务、订单、配置)
│ ├─ 已有 Postgres,想保留 SQL 和权限体系? → Zero / ElectricSQL
│ ├─ 想最小依赖、自己掌控? → 自研 mutator + rebase(6.3 节)
│ └─ 只需多端同步,冲突极少? → LWW-Map + HLC,两百行搞定
└─ 两者都有(Notion 式块编辑器)
└─ 块内文本 CRDT + 块属性/顺序 LWW 或服务端权威
9.2 十条生产踩坑清单
1. 用墙钟做冲突裁决。 用户改了系统时间,他的所有编辑要么永远赢、要么永远输。必须用 HLC 或 Lamport。
2. 服务端存 JSON 而不是二进制 update。 doc.toJSON() 存库看着方便,却丢掉了因果信息;一个离线三天的客户端回来同步,会把旧状态当新编辑推上去,导致内容回退。血泪教训:存二进制。
3. 把 Awareness 写进文档。 光标位置进了 CRDT 历史,文档一周长到几百 MB。ephemeral 数据必须走独立通道。
4. mutator 里用 Date.now() / Math.random() / randomUUID()。 rebase 会重放 mutation,非确定性导致每次结果不同,客户端与服务端状态永久分歧。随机值和时间戳一律由调用方生成后放进 args。
5. 忘记 REPLICA IDENTITY FULL。 逻辑复制的 UPDATE 事件拿不到旧行,IVM 无法判断「进入/离开」,视图静默出错。极难排查,因为只在特定筛选条件下出现。
6. 没做增量落库。 每个 update 一次 DB 写,一个活跃文档就能打爆 IOPS。攒批 500ms 是基本操作。
7. 没有背压控制。 离线用户回来,服务端一口气推几万条,客户端卡死几十秒。用 ws.bufferedAmount 做流控。
8. 权限只在客户端校验。 纯 CRDT 中继架构下,服务端不理解内容 = 无法拦截越权写。要么拆文档,要么在服务端跑 CRDT 做语义校验。永远不要假设客户端可信。
9. 无限增长的墓碑和历史。 用了两年的文档,历史比内容大几十倍。服务端定期 compaction,客户端下发浅快照。
10. 没有「逃生舱」。 出问题时要能:导出可读格式、强制从服务端重拉全量、丢弃某个客户端的本地状态重建。上线前先把这三个按钮做好,否则出事那天你只能手工改数据库。
9.3 一个容易被忽略的问题:调试
CRDT 的 bug 极难复现,因为它依赖特定的操作交错顺序。生产上必须做两件事。
记录操作日志,出问题时能拿到完整操作序列在本地重放。
跑属性测试(Property-based Testing),随机生成操作序列和网络分区场景,断言最终收敛:
import fc from 'fast-check';
fc.assert(fc.property(
fc.array(fc.record({
peer: fc.integer({ min: 0, max: 2 }),
action: fc.oneof(
fc.record({ t: fc.constant('insert'), pos: fc.nat(50), s: fc.string({ maxLength: 5 }) }),
fc.record({ t: fc.constant('delete'), pos: fc.nat(50), len: fc.integer({ min: 1, max: 5 }) }),
fc.record({ t: fc.constant('sync'), to: fc.integer({ min: 0, max: 2 }) }),
),
}), { maxLength: 300 }),
(script) => {
const peers = [newDoc('A'), newDoc('B'), newDoc('C')];
for (const step of script) applyStep(peers, step);
syncAllUntilQuiescent(peers); // 互相同步到静默
return peers.every(p => p.toString() === peers[0].toString());
},
), { numRuns: 5000 });
这个测试跑 5000 次,比 100 个手写单测都管用。 我见过的 CRDT 集成 bug,九成是被这类随机测试逮到的——人力构造根本想不到那些交错。
十、总结与展望
10.1 把这篇文章压缩成八句话
- 同步引擎的价值不是炫技,是把乐观更新、离线、多端、冲突这四件脏活从业务代码里抽走。
- 收敛性的数学基础是半格(交换、结合、幂等),CRDT 的全部魔法和全部代价都源于此。
- 时钟用 HLC,别用墙钟。
- 写操作语义决定路线:序列插入删除 → CRDT;字段赋值 → 服务端权威 + IVM。给 CRUD 系统上 Yjs 是当代最常见的过度设计。
- CRDT 的内存问题已被块合并 + 列式编码 + Eg-walker 三级火箭解决,其中 Eg-walker 最锋利:元信息只在合并时临时存在,用完就扔。
- 架构分五层,出问题先定位层次。权限是纯 CRDT 路线的结构性软肋,最实用的解法是拆文档。
- 性能优化最大的单点收益是「本地优先渲染,不等网络」,其次是反序列化搬进 Worker。
- 上线前先做逃生舱,并用属性测试跑几千轮随机交错。
10.2 三个正在发生的变化
第一,服务端权威路线正在吞掉 CRDT 的份额。 CRDT 声量最大,但真正落地到业务系统的越来越多是 Zero / ElectricSQL 这类「保留 Postgres + 逻辑复制 + IVM」的方案。原因很现实:企业不愿为了协作功能放弃 SQL、放弃现有权限体系、放弃后端直接读写数据的能力。CRDT 会回归它最擅长的领域——真正的富文本和空间型协作。
第二,AI Agent 会催生新的冲突语义。 现有合并算法都假设编辑是稀疏、局部的人类行为。当 Agent 以每秒几十次的频率批量重写整段内容时,字符级 CRDT 合出来的结果可能语法上收敛、语义上是垃圾(半句人写的 + 半句 AI 写的拼在一起)。我判断会出现语义感知的合并策略:把「Agent 的一次整段替换」当作不可分割的意图单元处理,而不是拆成几千个字符操作去合并。这块还没有成熟方案,是个值得押注的方向。
第三,本地存储的天花板还在往上抬。 OPFS + SQLite WASM 已经让浏览器里跑真正的数据库成为常规操作,等 WASM GC 和 Memory64 全面落地还会再上一个量级。「客户端是个瘦终端」这个假设,会在未来三年内彻底失效。
10.3 给准备动手的人的最后一句话
如果你现在要做技术选型,我的建议非常具体:
先花两天,用本文 6.3 节那两百行 mutator + rebase 的代码,把你的核心场景跑通。 如果它够用——大概率够用——就不要引入 CRDT。如果你在这个过程中发现自己在反复手写「合并两个用户对同一段文本的编辑」,那才是引入 CRDT 的信号。
工程上最贵的错误,从来不是选错了库,而是为一个你其实没有的问题,付了五年的复杂度利息。
本文代码为讲解算法与架构思路而写,省略了部分边界与错误处理,请勿直接复制到生产环境。生产落地请使用 Yjs / Loro / Automerge / Zero / ElectricSQL 等成熟实现。