Local-first 与 CRDT 实践:用 Yjs 做协同编辑不再依赖后端合并冲突

团队最近要在内部工具里加一个协同编辑功能:一份需求文档,几个人可以同时打开、同时改。产品那边给的场景很具体——两个人对着同一份文档讨论,一个改标题一个改正文,不希望互相覆盖,也不希望改完还要手动刷新看对方改了什么。我领了这个活,第一反应是按老办法来:前端本地维护一份文档状态,保存的时候提交给后端,后端做版本号校验,版本不一致就返回冲突,前端拿最新版本做合并或者提示用户。

这套思路写起来很顺手,毕竟乐观锁这个模式我们在库存扣减、订单状态流转这些场景里已经用得很熟了。我先画了个方案:文档表加一个 version 字段,每次保存 SELECT 出当前版本,提交时带上客户端读到的版本号,后端用 UPDATE ... WHERE version = ? 判断有没有被别人改过。改动量小的时候,比如只是拿它做"防止两个人同时点保存互相覆盖",这个方案完全够用。但产品要的是"同时编辑",不是"排队编辑"——两个人在同一分钟里各自改了文档的不同段落,这种情况乐观锁直接判定冲突,把后改的那个人的提交拒掉,让他自己去合并。

传统方案在多人同时编辑下的具体麻烦

我把这个方案往细了推演了一遍,越推演越觉得不对劲。

第一个问题是合并规则怎么写。如果只存一个纯文本字段,两个人的修改都是对同一个字符串的编辑,后端能拿到的只有两份完整的文本内容,没有"谁在第几行加了什么字"这种操作粒度的信息。要么简单粗暴地"后提交的整体覆盖前面的",这样先改的人的内容会丢;要么走文本 diff 算法拼一个三方合并,类似 Git 处理冲突的思路——但 Git 合并冲突的时候是允许把选择权交还给人工的,协同文档场景里用户不会愿意每次保存都看一个冲突标记框自己去挑。真要做自动合并,行级 diff 只能处理"改的不是同一行"这种幸运情况,一旦两个人改的是同一句话里的不同词,合并算法很容易把两边的修改都吞掉,或者拼出一句语法都不通的话。

第二个问题是体验。乐观锁这套流程本质上是"提交才知道有没有冲突",这意味着用户改完点保存,等接口返回,才发现被别人抢先了,要弹出提示、要么覆盖要么放弃自己这次编辑。网络稍微差一点,这个等待就更难受——本地明明已经改完了,却要等一个网络请求告诉自己"能不能生效"。这和我们平时用文档协作工具的体验完全不一样,那些工具是你打字的瞬间对方就看到了变化,没有"提交-冲突-重试"这个环节。

第三个问题是离线。产品提了一句"出差路上没网也能改",这一下把乐观锁方案直接判了死刑——版本号校验依赖服务端有一份权威状态,离线的时候前端拿不到最新版本号,也没法在断网期间安全地累积修改,等联网了这些本地攒下来的改动怎么和别人这段时间的改动合并,又是同样的合并规则难题,而且这次是攒了一整段离线期的改动,冲突面只会更大。

方案画到这一步,我意识到问题不在于合并规则写得不够精细,而在于"由一个中心节点在提交时刻做仲裁"这个模型本身,跟"多人同时、甚至离线地改同一份数据"这个需求是拧着的。于是我把这块需求先放下,去查了查协同编辑类产品是怎么处理这个问题的,这才认真看了一圈 CRDT。

CRDT 解决的是什么

CRDT 的全称是 Conflict-free Replicated Data Type,直译过来是"无冲突复制数据类型"。它的核心思路和乐观锁完全反着来:不是在提交的时候检测冲突再决定怎么处理,而是把数据结构设计成——不管操作以什么顺序到达、不管几个副本各自执行了什么样的本地修改序列,只要最终每个副本收到了全部的操作,它们就会自动收敛到同一个结果,不需要任何一方去做仲裁。

这句话听起来有点抽象,但用一个计数器的例子就能讲清楚。假设我们要实现一个多端同步的点赞计数器,最朴素的写法是每个客户端存一个整数,加一操作就是 count = count + 1,然后把新值同步给别的客户端。这个写法在多人同时点赞时立刻出问题:客户端 A 和客户端 B 同时基于 count = 5 各自加一,都算出 count = 6,同步过去以后不管谁的值覆盖了谁,最终结果都是 6,而不是应该的 7——一次点赞凭空丢了。

CRDT 版本的计数器不这么存。它给每个客户端分配一个独立的槽位,自己只累加自己槽位里的数字:

1class GCounter {
2  constructor(id, replicas) {
3    this.id = id;
4    this.counts = Object.fromEntries(replicas.map((r) => [r, 0]));
5  }
6
7  increment() {
8    this.counts[this.id] += 1;
9  }
10
11  // 合并另一个副本传过来的状态,每个槽位各自取较大值
12  merge(remoteCounts) {
13    for (const id in remoteCounts) {
14      this.counts[id] = Math.max(this.counts[id], remoteCounts[id]);
15    }
16  }
17
18  value() {
19    return Object.values(this.counts).reduce((a, b) => a + b, 0);
20  }
21}

客户端 A 和 B 各自维护自己那个槽位的累加,互不干扰;同步的时候只需要把整个 counts 对象发过去,对方按槽位取较大值合并。不管 A 的操作先到还是 B 的操作先到,不管中间同步了几次,最后把两边的 counts 合并出来的 value() 一定是准确的总和。这里面藏着 CRDT 的两个关键性质:合并操作是可交换的(merge(A, B)merge(B, A) 结果一样)、是幂等的(同一份状态合并两次和合并一次结果一样)。正因为这两条性质成立,合并的顺序、合并的次数都不影响最终结果,不需要一个中心节点按固定顺序处理操作。

数组和文本这类更复杂的数据结构,CRDT 的实现思路是类似的:不直接存"这个位置现在是什么字符",而是给每个插入的字符分配一个全局唯一且带有序信息的标识(通常结合客户端 ID 和一个逻辑时钟),删除也不是真的抹掉,而是打一个墓碑标记。两个客户端各自插入的字符即使物理上写入的时间不同,靠着这套标识就能推算出一个所有副本都认同的相对顺序,插入和删除操作两两之间天然可交换,不需要谁去裁决"你的修改应该排在我前面还是后面"。真正把这套东西实现得又快又稳、还处理好垃圾回收的库,我打算直接用现成的,而不是自己在生产项目里造轮子——这就是 Yjs。

Yjs 的基本用法

Yjs 是一个专门做 CRDT 的 JavaScript 库,核心是一个 Y.Doc,可以理解成一份共享文档的容器,里面能挂载几种共享数据类型。

1import * as Y from 'yjs';
2
3const doc = new Y.Doc();
4
5// 共享文本,适合富文本或代码编辑器
6const ytext = doc.getText('content');
7ytext.insert(0, 'Hello');
8ytext.insert(5, ' world');
9console.log(ytext.toString()); // "Hello world"
10
11// 共享 Map,适合存文档的元信息、表单字段
12const ymap = doc.getMap('meta');
13ymap.set('title', '需求文档');
14ymap.set('updatedBy', 'alice');
15
16// 共享 Array,适合列表、看板的卡片顺序
17const yarray = doc.getArray('cards');
18yarray.push(['卡片 A', '卡片 B']);
19yarray.insert(1, ['插在中间的卡片']);

这几种类型内部都是按 CRDT 的方式实现的,insertdeleteset 这些操作在底层会生成对应的操作日志,Yjs 把这套日志编码成一段很紧凑的二进制更新(update),可以随时通过 Y.encodeStateAsUpdate(doc) 导出、通过 Y.applyUpdate(doc, update) 应用到另一个副本上。真正把这些 update 在多个客户端之间搬运的活,交给 provider 去做。最常用的是 y-websocket,起一个中转服务器,所有客户端连上去广播更新:

1import { WebsocketProvider } from 'y-websocket';
2
3const provider = new WebsocketProvider(
4  'wss://sync.internal-tools.dev',
5  'doc-req-2451', // 房间名,一般用文档 id
6  doc
7);
8
9provider.awareness.setLocalStateField('user', {
10  name: 'alice',
11  color: '#4f7cff',
12});

这里要澄清一个容易理解错的地方:这个 WebSocket 服务器只负责转发二进制更新包,它不需要理解文档内容、不做任何合并逻辑、不判断谁的修改优先——真正的合并计算完全发生在客户端本地,Y.applyUpdate 把远端传来的操作应用进本地的 Y.Doc,结果自动收敛。服务器挂了,或者两个客户端暂时都连不上,已经连上的客户端之间的实时同步不受影响;要是连内网都出不去,还可以换成 y-webrtc 走点对点连接,完全不经过服务器。

离线场景要靠本地持久化撑住,y-indexeddb 负责把 Y.Doc 的状态写进浏览器的 IndexedDB:

1import { IndexeddbPersistence } from 'y-indexeddb';
2
3const persistence = new IndexeddbPersistence('doc-req-2451', doc);
4
5persistence.on('synced', () => {
6  console.log('本地缓存已加载完毕');
7});

有了这一层,用户断网之后照样能打开文档、照样能编辑——所有操作先写进本地的 Y.Doc,IndexeddbPersistence 自动把变更落盘,重新联网后 WebsocketProvider 会把断网期间攒下的更新一起同步出去,同时拉取这段时间里别人的修改,双方各自应用对方的更新,自动收敛到同一个状态,不需要任何人工介入判断谁的版本更"新"。

值得注意的是,Y.Text/Y.Array 这种序列结构和 Y.Map 这种键值结构,合并策略并不完全一样。序列结构靠位置标识让插入删除天然可交织;Y.Map 存的是键到值的映射,同一个 key 被两个客户端并发修改时没法"交织",Yjs 对它采用的是后写覆盖(last-writer-wins),依据的是操作附带的逻辑时钟而不是墙上时钟,离线时长、网络延迟都不会打乱这个判断依据。这意味着如果两个人同时把 ymap.set('title', ...) 改成不同的值,最终只会留下其中一个,不会拼出一个奇怪的合成结果。我们设计文档结构的时候特意把这条规则考虑进去了:像标题、状态这种"整体替换"语义的字段用 Y.Map 存没问题,并发改动概率低、就算覆盖了也容易发现;但正文这种需要多人同时敲字的内容,必须用 Y.Text,如果图省事直接拿 Y.Map 存一整段字符串,两人同时编辑就会变成谁的网络快谁的版本笑到最后,等于把 CRDT 的优势又亲手扔掉了。

把它接到一个真实的编辑框里

光是操作 Y.Text 还不够直观,协同编辑通常要接到一个真正的富文本编辑器上。我们内部这个需求文档功能用的是一个简单的 textarea 改造成的编辑框,先不引入完整的富文本方案,搭配 y-textarea 这类绑定或者手写一个轻量的双向绑定就能跑起来:

1import * as Y from 'yjs';
2import { WebsocketProvider } from 'y-websocket';
3import { IndexeddbPersistence } from 'y-indexeddb';
4
5function setupCollabEditor(docId, textareaEl) {
6  const doc = new Y.Doc();
7  const ytext = doc.getText('content');
8
9  new IndexeddbPersistence(docId, doc);
10  const provider = new WebsocketProvider(
11    'wss://sync.internal-tools.dev',
12    docId,
13    doc
14  );
15
16  // 初次加载时,把 Yjs 里已有的内容渲染到输入框
17  textareaEl.value = ytext.toString();
18
19  // 本地输入 -> 写入 Y.Text,这里用最简单的整体 diff 策略
20  textareaEl.addEventListener('input', () => {
21    const newValue = textareaEl.value;
22    const oldValue = ytext.toString();
23    if (newValue === oldValue) return;
24
25    // 找到从头开始第一个不同的位置和从尾开始第一个不同的位置
26    let start = 0;
27    while (
28      start < oldValue.length &&
29      start < newValue.length &&
30      oldValue[start] === newValue[start]
31    ) {
32      start++;
33    }
34    let endOld = oldValue.length;
35    let endNew = newValue.length;
36    while (
37      endOld > start &&
38      endNew > start &&
39      oldValue[endOld - 1] === newValue[endNew - 1]
40    ) {
41      endOld--;
42      endNew--;
43    }
44
45    doc.transact(() => {
46      if (endOld > start) ytext.delete(start, endOld - start);
47      if (endNew > start) ytext.insert(start, newValue.slice(start, endNew));
48    });
49  });
50
51  // 远端更新 -> 同步回输入框,注意要避开自己刚触发的那次 input
52  ytext.observe(() => {
53    const remoteValue = ytext.toString();
54    if (textareaEl.value !== remoteValue) {
55      const cursor = textareaEl.selectionStart;
56      textareaEl.value = remoteValue;
57      textareaEl.setSelectionRange(cursor, cursor);
58    }
59  });
60
61  return { doc, provider };
62}

这段代码里最关键的是 doc.transact 包裹增删操作——用一个事务把一次输入产生的删除加插入合并成一次更新广播出去,而不是拆成两次网络包,能显著减少同步开销。观察远端变化用的是 ytext.observe,任何客户端(包括自己)对 ytext 的修改都会触发这个回调,所以需要判断一下当前值和远端值是否一致,避免自己打字的时候被自己的回调打断光标位置。真要接专业的富文本编辑器,社区有现成的绑定,比如 y-prosemirrory-quill,原理是一样的,只是把"整体 diff textarea"换成了编辑器自己的操作事件。

多人光标位置这种不需要持久化、只是临时展示的信息,Yjs 提供了单独的 awareness 协议来处理,不走 Y.Doc 的操作日志:

1provider.awareness.on('change', () => {
2  const states = provider.awareness.getStates();
3  states.forEach((state, clientId) => {
4    if (clientId === doc.clientID) return;
5    renderRemoteCursor(state.user, state.cursor);
6  });
7});
8
9textareaEl.addEventListener('selectionchange', () => {
10  provider.awareness.setLocalStateField('cursor', textareaEl.selectionStart);
11});

awareness 状态在客户端断开时会自动清除,不会永久累积进文档历史,这一点和正文内容的 CRDT 更新是分开处理的。

产品那边后来又提了个需求:能不能看到文档的历史修改记录,类似"谁在什么时候改了什么"。这个需求正好能顺手用上 Yjs 自带的快照能力。Y.encodeStateAsUpdate 导出的是完整状态,而 Y.snapshot(doc) 拿到的是某个时间点的一个轻量引用,配合 Y.encodeSnapshot 序列化保存下来,以后可以用 Y.diffUpdate 或者直接把两个快照分别应用到临时文档上做对比,还原出某个时间段内谁改了哪一段:

1import * as Y from 'yjs';
2
3const snapshots = [];
4
5function takeSnapshot(doc, author) {
6  snapshots.push({
7    time: Date.now(),
8    author,
9    snapshot: Y.snapshot(doc),
10  });
11}
12
13function diffSince(doc, fromSnapshot) {
14  const update = Y.encodeStateAsUpdate(doc);
15  return Y.diffUpdate(update, Y.encodeStateVector(fromSnapshot.doc));
16}

实际项目里我们没有做到这么细,只是每隔几分钟在后端对活跃文档存一次快照,前端要看历史版本时把某个快照单独 applyUpdate 到一个临时的 Y.Doc 上渲染出来,不需要真的做逐字段的 diff 高亮,这一步留到用户反馈说需要的时候再加。

Local-first 带来的实际差异

按这套方案接完之后,和最初的乐观锁方案对比最直观的差异是响应速度——用户敲字的瞬间就是本地的 Y.Doc 在改,渲染回输入框不经过任何网络往返,输入延迟只取决于本地计算,跟有没有网络完全无关。同步给别人是异步发生的事,不阻塞自己的操作。这一点跟传统"保存才提交"的模式是根本不同的两种数据流向。

离线可用性是另一个明显的差异。断网状态下打开文档,IndexeddbPersistence 已经把上次同步的内容缓存在本地,用户能照常看、照常改,Y.Doc 会一直记录本地产生的操作;重新联网之后,WebsocketProvider 自动补发这段时间的更新、也自动拉取别人的更新,双方合并,不需要用户点一个"解决冲突"的按钮,也不需要后端做任何特殊处理。我们内部试用的时候特意断了几次 Wi-Fi 模拟这个场景,合并结果符合预期,没出现内容互相覆盖的情况。

多人同时编辑同一段落这种最初让乐观锁方案卡壳的场景,现在也不再是问题,因为字符级的插入删除在 CRDT 层面天然可以交织合并,不需要判断"这次改动和那次改动是不是冲突"——它们本来就不是互斥关系,该怎么合并,数据结构自己就算出来了。

为了确认这个结论不是我自己一厢情愿,我写了个小脚本模拟极端情况:两个 Y.Doc 各自离线编辑同一段文本几十次,把产生的更新故意打乱顺序再逐个应用到对方身上,最后对比两边 ytext.toString() 的结果。跑了几十轮乱序场景,两边的最终文本始终一致,顺序打乱只影响中间过程,不影响收敛结果。这个小实验比看文档里的理论描述更让我放心把这套方案用到实际业务里。

要正视的代价

这套方案不是没有成本。最直接的一个是历史操作日志的存储。Yjs 为了支持任意顺序的合并,需要保留每次插入操作的元信息(比如带上客户端 ID 和逻辑时钟的位置标识),即便对应的字符后来被删除了,也只是打上墓碑标记而不是真的从内部结构里抹掉,这意味着一份被频繁编辑的文档,底层状态会比它呈现出来的文本内容大得多,长期高频编辑的文档需要定期做垃圾回收(Yjs 提供了 gc 相关的配置和 API),否则内存和存储都会慢慢涨上去。这块我们打算先在服务端定时对不活跃文档做一次快照压缩,把历史墓碑清理掉,只留最终状态和少量最近的操作记录用于审计。

权限控制也变复杂了。乐观锁方案里,谁能不能保存,后端在提交那一刻做一次校验就够了;CRDT 场景里,数据的写入路径变成了"本地先写、异步广播",权限检查如果还想卡在提交时刻做,等于失去了 local-first 的意义。目前我们的做法是把权限判断挪到 provider 层——y-websocket 的服务端在转发更新之前先校验这个连接对应的用户有没有这份文档的编辑权限,没有权限的连接只能只读订阅、broadcast 到它的更新会被拦截。这样能挡住"没权限的人往文档里写内容",但没法做到更细粒度的"这个字段只有某个角色能改"——字段级权限得在共享数据结构的设计阶段就把敏感字段拆到单独的 Y.Map,配合应用层的权限判断来展示,不能指望 CRDT 本身帮你做业务规则的裁决。

还有一类数据模型完全不适合往 CRDT 里塞——任何需要强一致的场景,比如库存扣减、余额扣款、抢购下单这类"谁先到谁生效、总量不能超"的业务,CRDT 保证的是"最终收敛到一个一致状态",不保证这个状态满足业务上的约束(比如库存不能为负)。两个客户端同时对同一个库存 CRDT 计数器做递减,离线状态下都以为自己扣减成功,联网合并后总扣减量完全可能超过实际库存。这类场景该用乐观锁、该用数据库事务的地方还是得老老实实用,不能因为 CRDT 解决了协同编辑的痛点就把它当成万能的并发方案到处套。

调研过程中我也顺手看了看 Automerge,这是另一个走 CRDT 路线的库,数据模型更贴近"给普通 JS 对象做版本控制"这个思路,API 上更像操作一份带历史的 JSON 文档,内置的文档压缩和分支合并能力也做得更完整。但我们这次选 Yjs 是出于两个很具体的理由:一是 Yjs 专门针对文本编辑做了大量优化,Y.Text 的插入删除性能和内存占用在长文档、高频编辑场景下表现更稳定;二是生态里现成的编辑器绑定(y-prosemirrory-quilly-codemirror)已经很成熟,不用自己去接一层适配层。如果我们要做的不是文本协同,而是偏向"一份结构化数据要支持分支、要支持类似 Git 的版本对比",Automerge 的模型可能会更顺手,这个取舍没有绝对答案,得看具体是文本编辑主导还是结构化数据版本管理主导。

什么场景适合上这套方案

这次选型定下来后再看,协同文档、看板卡片排序、表单草稿这类场景特别契合 CRDT——它们的共同点是"多人可能同时改,但改动本身可以自然叠加,不需要互相排斥"。两个人往看板里各自拖了一张卡片,两份拖动操作合并后卡片顺序可能和某一方期望的略有出入,但不会丢数据、不会报错,用户能接受这种"结果自动收敛但未必和自己预想的完全一致"的体验。表单草稿也类似,几个人协作填一份复杂表单,各自负责不同字段,CRDT 能保证没人的填写内容被覆盖掉。

反过来,涉及严格顺序、精确计数、资金流转这类要求"要么全对、要么直接拒绝"的场景,还是应该留给传统的中心化事务模型。判断的标准其实回到了最开始画方案时的那个感觉:如果多人同时操作,业务上能不能接受"两边的修改都保留、系统自动想办法拼在一起";能接受的,CRDT 和 local-first 这条路值得走;不能接受的,乐观锁、悲观锁、数据库事务该用还是用,不必强行套一层 CRDT 的壳子。这次我们这份需求文档的协作功能,已经排进了下个迭代,y-websocket 的服务端和权限校验这块还要再打磨一轮,再往后应该会推广到看板功能上试试。