编程 本地优先同步引擎深度拆解:从三种 CRDT 流派到 Eg-walker,为什么 2026 年的前端敢把数据库塞进浏览器

2026-08-08 23:11:28 +0800 CST views 8

本地优先同步引擎深度拆解:从三种 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 的元信息开销纯属浪费。更好的模型是:

  1. 客户端有一份本地数据副本(IndexedDB / OPFS 里的关系型存储)
  2. 客户端写 mutator(一段可在两端执行的纯函数),本地立即执行 → 乐观状态
  3. mutation 同时发给服务端,服务端重新执行同一段逻辑(带完整权限校验),写进真正的 Postgres
  4. 服务端通过 logical replication 捕获数据变更,算出「哪些客户端的哪些查询受影响」,只推增量行
  5. 客户端收到权威数据后,丢弃本地乐观状态,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" ← 一致,且单词没交错

跑一下会发现:两个副本结果完全一致,且 worldthere 各自保持完整,没有交错。这就是 YATA 双边界的价值。

但这个实现有个致命性能问题findIndex 是 O(n),integrate 里还嵌套调用,整体接近 O(n²)。真实的 Yjs 用了三个关键优化:

  1. 双向链表 + ID 索引 Map,把 findIndex 降到 O(1)
  2. Item 块合并:连续输入的字符(同一 peer、连续 clock、相邻位置)合并成一个 Itemcontent 存整个字符串。用户打一段 1000 字的话,可能只产生 1 个 Item 而不是 1000 个。 这是 Yjs 内存效率的最大来源。
  3. 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

  1. 所有 move 操作按时间戳排成全序,本地维护一个操作日志
  2. 收到一个时间戳「早于」某些已应用操作的远端 move 时,先撤销(undo)所有比它晚的操作
  3. 应用这个新操作(do),此时做环检测:如果这次移动会成环,就直接丢弃这个操作(这是合法的,因为所有副本用同样的规则判断,会得到同样的结论)
  4. 再按顺序重放(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 的方案:

  1. 持久化的只有两样东西:最终文本内容(一个普通的 rope / 字符串),以及一张事件图(Event Graph)——记录每个操作及其因果父节点的 DAG,操作本身用「简单坐标」表示(插入位置 + 内容),不带 CRDT 元信息。
  2. 正常线性编辑:直接应用到文本上,事件图追加一条边。开销 ≈ 普通编辑器。
  3. 只有当出现真正的并发分叉时:临时构建一个 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 落库并广播给同房间其他连接。这里藏着三个生产经验:

  1. 持久化存二进制 update,不要存 JSON。 很多人图省事用 doc.toJSON() 存库,这会丢掉所有历史和因果信息。下次一个离线客户端带着旧版本回来同步,直接乱套。
  2. 攒批写入。 一个活跃文档每秒能产生几十个 update,逐个落库会打爆 IO。攒 500ms 合并成一个 mergeUpdates 再写,吞吐差一个数量级。
  3. 空房间延迟卸载。 用户刷新页面会瞬间断开重连,立刻卸载会导致反复从数据库加载。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:

  1. 确定性:不能在函数体内用 Date.now() / Math.random() / crypto.randomUUID()。因为 rebase 会重放同一个 mutation 多次,非确定性会导致每次结果不同,客户端与服务端状态永久分歧。所有时间戳和随机值必须由调用方生成后放进 args(注意上面代码里的 args.at)。
  2. 幂等:同一个 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 后端就痛苦了),还要为每种权限规则写解析逻辑。

三种应对:

  1. 文档级权限(最常见):把粒度降到「整个文档可读/可写」,WebSocket 握手时鉴权。简单,但表达不了字段级权限。
  2. 服务端跑 CRDT + 语义校验:服务端 applyUpdate 到一份影子文档,diff 出变更的字段路径,逐条比对权限表,不合规就拒绝并回一个「纠正 update」。正确但重。
  3. 拆文档:把不同权限域的数据拆成不同 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 把这篇文章压缩成八句话

  1. 同步引擎的价值不是炫技,是把乐观更新、离线、多端、冲突这四件脏活从业务代码里抽走。
  2. 收敛性的数学基础是半格(交换、结合、幂等),CRDT 的全部魔法和全部代价都源于此。
  3. 时钟用 HLC,别用墙钟。
  4. 写操作语义决定路线:序列插入删除 → CRDT;字段赋值 → 服务端权威 + IVM。给 CRUD 系统上 Yjs 是当代最常见的过度设计。
  5. CRDT 的内存问题已被块合并 + 列式编码 + Eg-walker 三级火箭解决,其中 Eg-walker 最锋利:元信息只在合并时临时存在,用完就扔。
  6. 架构分五层,出问题先定位层次。权限是纯 CRDT 路线的结构性软肋,最实用的解法是拆文档。
  7. 性能优化最大的单点收益是「本地优先渲染,不等网络」,其次是反序列化搬进 Worker。
  8. 上线前先做逃生舱,并用属性测试跑几千轮随机交错。

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 等成熟实现。

推荐文章

使用临时邮箱的重要性
2025-07-16 17:13:32 +0800 CST
css模拟了MacBook的外观
2024-11-18 14:07:40 +0800 CST
如何在Vue3中定义一个组件?
2024-11-17 04:15:09 +0800 CST
18个实用的 JavaScript 函数
2024-11-17 18:10:35 +0800 CST
程序员茄子在线接单