文章

从一次任务修改开始,理解 Linear 的本地优先同步引擎

一套可操作的同步引擎实验室:亲手制造离线编辑、并发冲突、增量丢包与撤销,理解 Model、Transaction、Delta Packet 和 Rebase 如何协作。

从一次任务修改开始,理解 Linear 的本地优先同步引擎

很多人把“同步”理解为:用户修改数据,客户端发给服务器,服务器再通知其他客户端。这个描述没有错,但它跳过了最难的部分——断网时修改放在哪里、两个客户端同时改同一个字段怎么办、页面刷新后未发出的操作如何恢复,以及服务器返回的数据为什么可能和客户端提交的数据不同。

下面的互动实验室把这些问题压缩到一个任务 ENG-42 上。你可以修改标题或负责人,切断任意客户端的网络,模拟丢包,再观察内存、IndexedDB、事务队列和服务器权威状态如何变化。

在新窗口打开实验室

这套实验室从哪里来

它依据 Wenzhao Hu 的开源研究项目 Reverse Engineering Linear’s Sync Engine 重新设计。原研究从打包后的客户端代码出发,梳理了 Model、启动加载、事务、增量包、冲突处理与撤销等机制;研究内容采用 CC BY 4.0 许可。

互动实验室是为教学编写的独立模拟器,没有复制研究项目中展示的 Linear 代码,不连接 Linear,也不会上传输入内容。它帮助我们理解一种可能的系统设计,不代表 Linear 当前、完整或官方确认的内部实现。

先看完整闭环

一次编辑大致会经历五个阶段:

  1. Model 接住修改。 Issue 不只是一段 JSON。模型还知道属性类型、对象引用、加载策略,以及哪些变化需要被观察。
  2. Transaction 保存用户意图。 客户端立即更新界面,同时把修改写进可持久化队列。网络暂时不可用时,操作仍留在本地。
  3. 服务器确定顺序。 服务器校验并执行事务,为结果分配单调递增的 sync ID。这个顺序成为所有客户端共同接受的时间线。
  4. Delta Packet 广播事实。 服务器把权威变化发给所有客户端,包括最初提交修改的客户端。增量里还可能有历史记录等服务器产生的副作用。
  5. Rebase 合并未完成的本地意图。 客户端先接受新的权威基线,再把仍未确认的本地事务重放到基线上。

可以把这条链路记成:

Model → Transaction → Server → Delta → Rebase

其中,界面展示的是“服务器已经确认的状态”与“本地尚未确认的意图”叠加后的结果。正是这层叠加,让应用在网络不稳定时仍显得即时。

Model:同步系统理解的不是任意 JSON

同步引擎需要知道对象之间的关系。例如 Issue 保存的可能只是 assigneeId,但界面希望直接读取负责人姓名。模型元数据会告诉系统:这个字段引用 User;User 是否应该在启动时加载;对象能否从内存池或 IndexedDB 找到;缺失时是否需要请求服务器。

研究中归纳了多种加载策略,包括立即加载、懒加载、部分加载、显式请求和仅本地数据。它们解决的是同一个取舍:如果启动时把整个工作区读入内存,首次打开会很慢;如果什么都不预取,每一次点击又会等待网络。

实验室第二、三和第四课分别展示模型元数据、启动过程与懒加载。特别留意 Object Pool:同一个对象在内存里只保留一个可复用实例,引用关系因此更稳定,观察更新也更集中。

Bootstrap:启动顺序本身就是一致性协议

一个新客户端不能只下载一份数据然后开始工作。它通常需要按顺序完成:

  1. 建立本地数据库和对象仓库;
  2. 获取服务器快照及其对应的最后一个 sync ID
  3. 把快照持久化到 IndexedDB;
  4. 将立即需要的模型装入内存;
  5. 接收快照之后产生的增量消息。

快照和增量之间必须无缝衔接。假设快照停在 1042,而客户端连接实时通道时服务器已经到 1045,那么 1043—1045 必须补齐。单调递增的同步编号让客户端能够发现缺口,而不是假设“连接成功就代表数据完整”。

Transaction:乐观更新为什么仍然可靠

点击保存时,如果应用等待服务器往返后才更新界面,每次操作都会带上网络延迟。本地优先设计会先修改内存,让反馈立即出现,同时生成一笔事务并写入本地存储。

事务记录的是用户意图,例如“把标题改成 A”,而不只是最终对象快照。持久化队列带来两个直接收益:刷新页面不会轻易丢掉未提交操作;断网重连后可以继续发送。服务器还应识别重复事务,使重试不会把同一个操作执行两遍。

你可以在实验室中将客户端 A 设为离线,修改标题并保存。此时内存显示乐观结果,IndexedDB 的同步版本不动,待发事务变为 1。恢复连接后,队列才会逐步清空。

Delta Packet:确认消息为什么要回到发起者

服务器的响应不只是“保存成功”。它可能补充更新时间、活动历史、权限计算结果或其他派生变化。因此,发起者也需要接收并应用服务器广播的增量包,才能回到权威状态。

增量包带着新的 sync ID。客户端将它写入 IndexedDB,更新内存模型,并从队列里移除已确认事务。若编号不连续,客户端就知道自己漏了消息,需要补拉缺失区间或获取新快照。

实验室的“丢弃下一包”开关会故意制造这种情况。再次重连时,客户端会根据版本差异补齐状态。

Rebase:并发冲突不是简单地选一个版本

设想 A 和 B 都从 1042 开始。A 离线后把标题改成“离线草稿”;与此同时,B 在线把标题改成“团队定稿”,服务器将它记为 1043。A 恢复后先收到 1043,但本地还有一笔自己的未确认事务。

客户端会先把 1043 作为新基线,再重放 A 的本地意图;随后服务器执行 A 的事务并生成 1044。对于同一字段,最终结果表现为服务器顺序中的最后一次写入获胜。对于不同字段,两笔修改则可能都被保留。

这种机制没有消灭冲突,而是给冲突建立了确定顺序。更复杂的富文本协作常使用 OT 或 CRDT;任务管理系统里大量操作是结构化字段更新,服务器排序加客户端 Rebase 可以用较低复杂度获得稳定行为。

Undo:撤销是一笔新事务

单机软件可以把内存退回旧状态,但多人协作系统不能私自删除大家已经见过的历史。撤销应当生成一笔反向事务:如果刚才把负责人从 Alice 改为 Bob,Undo 就提交“把负责人改回 Alice”。

这笔新事务仍要经过服务器排序、增量广播和客户端应用。其他设备因此看到的是一条可解释的新变化,而不是时间线突然被改写。实验室最后一课会依次提交修改与反向事务,你可以在服务器历史计数和事件流中看到两次独立操作。

该怎样使用这套互动课

第一次使用时,按顶部八节课顺序播放,先观察动画,再对照“状态剖面”的内存、数据库和队列。第二次可以自由组合故障:让 A 离线、在 B 修改、打开慢速网络,再恢复 A。重点不是记住动画,而是随时回答三个问题:

  • 当前界面显示的是权威状态,还是叠加了本地意图的乐观状态?
  • 哪些操作已经写入服务器时间线,哪些仍只存在于客户端队列?
  • 如果此刻刷新、断网或漏掉一包,客户端依据什么恢复?

当这三个问题都能从 sync ID、事务队列和增量记录中找到答案,本地优先同步的核心结构就清楚了。

延伸阅读

本文由作者按照 CC BY 4.0 进行授权