逆向工程 Linear 同步引擎 (LSE)
逆向工程 Linear 同步引擎 (LSE)
项目地址: wzhudev/reverse-linear-sync-engine ⭐ 2.5k stars | 134 forks Linear CTO Tuomas Artman 评价:「可能是现存最好的文档——无论是内部还是外部的」「比 Linear 内部发布的文档更正确、更完整」
一、项目背景与动机
作者从事协作软件开发(富文本编辑器、电子表格),需要一个满足以下三个条件的同步引擎:
- 支持任意数据模型 — 可适配各种业务场景
- 功能丰富 — 部分同步、权限控制、撤销/重做、离线可用、编辑历史
- 开发者体验好 — ORM 风格的模型定义,无需同步引擎专业知识
作者的判断:OT 以复杂著称,CRDT 常引入元数据开销且难以支持部分同步和权限控制。Linear 的方案提供了一个优雅的替代选择——更接近 OT 的思路,但通过中央服务器排列事务顺序,避免了传统 OT 的复杂性。
二、LSE 三大支柱
┌─────────────────────────────────────────────────────┐
│ Linear 同步引擎 │
├─────────────┬─────────────────┬──────────────────────┤
│ Model 模型 │ Transaction 事务 │ Delta Packet 增量包 │
│ 数据实体定义 │ 操作打包与发送 │ 服务端广播变更通知 │
└─────────────┴─────────────────┴──────────────────────┘
2.1 Model(模型)
- 实体如
Issue、Team、Organization、Comment - 拥有可观察属性和引用关系,通过 MobX 实现视图自动更新
- 存储在以 UUID 为键的 **Object Pool(对象池)**中
- 支持从 IndexedDB 或服务端加载,支持懒加载/部分加载
2.2 Transaction(事务)
- 对模型的操作(增删改归档)封装为事务,发送到服务端独占执行
- 服务端执行失败时,客户端可回滚(reversible)
- 离线时事务缓存在 IndexedDB,重新连接后自动重发
- 携带 sync id(单调递增整数)确保全局顺序
- 支持撤销/重做
2.3 Delta Packet(增量包)
- 服务端执行事务后,向所有连接的客户端广播增量包(含发起方)
- 包含多个 sync action,各有自己的 sync id
- 增量包可能与原始事务不同,因为服务端可能产生副作用(如生成历史记录)
三、核心概念:sync id 与 SyncGroup
3.1 sync id / lastSyncId
sync id: 1 2 3 4 5 6 7 ...
↑ ↑ ↑
事务 A 事务 B 事务 C
↑
lastSyncId = 7
(数据库的"版本号")
- sync id 是跨整个数据库(不仅是单个工作区)的单调递增整数,表示所有事务的全序
- lastSyncId 即最新的事务 ID,充当数据库版本号
- 客户端比较本地
lastSyncId与服务端的,检测是否有遗漏的增量包 - LSE 倾向 OT 而非 CRDT:依赖中央服务器排列事务顺序(total order vs partial order)
3.2 SyncGroup(同步组)
- 权限控制机制:
subscribedSyncGroups包含 UUID,对应用户 ID、所属团队、预定义角色 - 所有工作区共享一个
lastSyncId计数器,但用户只能接收其所属 SyncGroup 的增量包 - 本质上是「与特定 User 或 Team 关联的模型集合」
四、模型定义与元数据
4.1 ModelRegistry
静态类,存储所有模型元数据:
modelLookup— 名称 → 构造函数modelPropertyLookup— 模型 → 属性列表modelReferencedPropertyLookup— 模型 → 引用属性列表__schemaHash— 所有模型和属性元数据的哈希值,用于判断 IndexedDB 是否需要迁移
4.2 模型装饰器
@ClientModel("Issue") // 注册模型名称、构造函数、schema 版本、属性名到 ModelRegistry
class Issue extends Model {
@Property() // 使属性可观察,注册到 ModelRegistry
title: string;
@Reference(() => User, "assignedIssues", { nullable: true, indexed: true })
assignee: User; // 自动注册 reference(ID)+ referenceModel(对象)
}
@Property()— 通过M1(observabilityHelper)使属性可观察@Reference()— 注册两个属性:reference(持久化 ID)+referenceModel(非持久化,getter/setter 访问对象)@OneToMany()— 用于LazyReferenceCollection属性
4.3 五种加载策略
| 策略 | 行为 | 示例 |
|---|---|---|
instant |
引导时加载(默认) | 大多数模型 |
lazy |
非引导时加载,需要时一次性获取 | ExternalUser |
partial |
按需加载,只从服务端获取子集 | DocumentContent |
explicitlyRequested |
仅在显式请求时加载 | DocumentContentHistory |
local |
仅存储在本地 IndexedDB | 目前无模型使用 |
local策略理论上支持无需服务端改动即可开发新功能——新模型先以local启动,就绪后切换到其他策略。
4.4 七种属性类型
| 类型 | 说明 | 示例 |
|---|---|---|
property |
模型自有属性 | Issue.title |
ephemeralProperty |
不持久化 | User.lastUserInteraction |
reference |
持有另一模型的 ID,可懒加载 | Team.subscription |
referenceModel |
随 reference 自动创建,getter/setter 访问对象 | — |
referenceCollection |
引用数组 | Team.templates |
backReference |
reference 的反向,随被引用模型删除而删除 | Issue.favorite |
referenceArray |
多对多关系 | Project.members → Users |
4.5 可观察性(M1)
使用 Object.defineProperty 创建 getter/setter:
- Setter → 在
__mobx上创建 MobX box 存储值 → 调用propertyChanged(oldValue, newValue) - Getter → 从 MobX box 读取值
propertyChanged记录旧值,后续用于创建UpdateTransaction
五、引导与懒加载
5.1 引导流程
App 启动
│
├─ 1. StoreManager 为每个模型创建 PartialStore 或 FullStore
├─ 2. Database 连接 IndexedDB,创建数据库/表,执行迁移
├─ 3. 确定引导类型(full / local / partial)
├─ 4. 执行对应引导(full → 从服务端获取模型)
├─ 5. 数据写入 IndexedDB
├─ 6. 需立即使用的模型加载到内存,激活可观察性
└─ 7. 建立 WebSocket 连接,接收增量包
5.2 IndexedDB 结构
两类数据库:
| 数据库 | 内容 |
|---|---|
linear_databases |
元数据(每个用户每个工作区一条):name、schemaHash、schemaVersion |
linear_(hash) |
工作区数据:模型表 + 两个特殊表 |
特殊表:
_meta— 每个模型的持久化状态 + 数据库元数据(lastSyncId、firstSyncId、backendDatabaseVersion、updatedAt、subscribedSyncGroups)_transaction— 缓存的未发送/排队事务
5.3 全量引导
请求 https://client-api.linear.app/sync/bootstrap?type=full&onlyModels=...
- 响应是 JSON 对象流 — 每行一个模型实例(含
__class字段) - 最后一行是
_metadata_=,包含method、lastSyncId、subscribedSyncGroups、databaseVersion、returnedModelsCount - Linear 后续优化:拆分为多个请求(
splitToCacheableRequests)提升缓存性能
5.4 水合(Hydration)与对象池
数据写入 IndexedDB 后:
instant策略的模型通过ModelRegistry构造函数实例化- 加入 Object Pool(
SyncClient.modelLookup,UUID → 模型对象的映射) - 先初始化 →
updateFromData水合 →attachToReferencedProperties解析引用
5.5 懒水合(Lazy Hydration)
Partial Index(部分索引):
- 当模型定义
Reference时设置indexed: true,LSE 创建索引 PartialIndexHelper计算直达和嵌套引用,最多三层深度- 例:
Comment可通过issueId-<id>(直达)或issue.cycleId-<id-of-the-cycle>(嵌套)查询
网络水合触发条件:
coveringPartialIndexValues缺失- 部分索引在本地部分存储中未找到
canSkipNetworkHydration不返回 true
BatchModelLoader:将多个懒水合网络请求合并为单个 GraphQL 请求:
- 有 partial index key 的 →
loadSyncBatch(发送到/sync/batch) - 有
SyncGroup的 →loadPartialModels(发送到/sync/bootstrap?type=partial) - 其他 →
loadFullModels
六、事务(Transaction)
6.1 属性变更流程
issue.assignee = user;
issue.save();
│
├─ 1. Setter 拦截赋值 → propertyChanged → markPropertyChanged
│ 记录旧值到 modifiedProperties,内存模型立即更新
├─ 2. save() 创建 UpdateTransaction 捕获变更
├─ 3. 事务加入请求队列,缓存到 IndexedDB 的 __transactions 表
├─ 4. TransactionQueue 调度批量发送
├─ 5. 成功后从缓存中移除
└─ 6. 等待包含匹配 lastSyncId 的增量包
关键:客户端操作不直接修改 IndexedDB 模型表。 只有在收到服务端增量包后才更新本地数据库。
6.2 事务类型
| 类型 | 说明 |
|---|---|
BaseTransaction |
基类 |
CreationTransaction |
新增模型 |
UpdatingTransaction |
更新属性 |
DeletionTransaction |
删除模型 |
ArchivalTransaction |
归档模型 |
UnarchiveTransaction |
取消归档 |
LocalTransaction |
简单包装,不执行操作 |
6.3 事务队列四阶段
createdTransactions queuedTransactions executingTransactions
(新创建) ──→ (等待执行) ──→ (已发送,等待响应)
│ │
│ microtask 服务端响应
↓ ↓
commitCreated 移除 + 清除缓存
│
└──→ completedButUnsyncedTransactions
(已完成但未收到增量包 — 用于事务变基)
批次调度规则:
- 同一事件循环中的事务共享
batchIndex - 基于事务限制、相同
batchIndex且独立性、GraphQL 变更大小限制进行批量
6.4 事务执行
TransactionExecutor 将批次中每个事务的 graphQLMutationPrepared 合并为单个 GraphQL mutation。
响应包含每个 mutation 的 lastSyncId,事务的 syncInNeededForCompletion 设为响应中最大的 lastSyncId。
6.5 持久化事务重启恢复
重启时,缓存的事务被反序列化并通过 fromSerializedData 重放,恢复内存模型状态。
小风险:非幂等事务重发可能出错(如「不能删除不存在的模型」)。
七、增量包(Delta Packet)
7.1 WebSocket 连接
引导后建立。握手回调比较服务端 lastSyncId 与本地值,检测遗漏的更新。SyncClient 监听 SyncMessage 通道接收增量包。
7.2 Sync Action 类型
| 代码 | 含义 |
|---|---|
I |
插入 |
U |
更新 |
A |
归档 |
D |
删除 |
C |
覆盖(Covering) |
G |
变更同步组 |
S |
变更同步组(与 G 的区别不明确) |
V |
取消归档 |
7.3 applyDelta 流程
收到增量包
│
├─ 1. 检测 SyncGroup 变更 — 用户被添加到组时触发部分引导
├─ 2. DependentsLoader 加载特定 action 的依赖
├─ 3. 新 SyncGroup 及其依赖数据写入本地 DB
├─ 4. 遍历 sync actions:
│ TransactionQueue.modelUpserted 取消匹配 UUID 的 CreationTransaction
│ 离开 SyncGroup 的模型被移除
└─ 5. 更新 lastSyncId / firstSyncId
7.4 事务变基(Transaction Rebasing)
客户端做出变更时,内存模型立即更新,但本地数据库直到增量包到达才更新。
问题:如果另一客户端的变更在本方事务完成前到达怎么办?
解决:completedButUnsyncedTransactions 队列保存等待增量包的事务。增量到达时,LSE 通过比较模型状态检查是否需要变基,确保客户端的意图变更得以保留。
八、撤销与重做
- 事务完成时保存
changeSnapshot(包含旧值),用于反转 UndoManager维护已完成事务的栈- 撤销:反向重放事务;重做:重新应用事务
九、架构总览
┌──────────────────────────────────────────────────────────────────────┐
│ 客户端 (Browser) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Model │←─→│ SyncedStore │←─→│ TransactionQueue│ │
│ │ (MobX) │ │ (Object Pool)│ │ (缓存+批量发送) │ │
│ └──────────┘ └──────────────┘ └───────┬────────┘ │
│ ↑ ↑ │ GraphQL Mutation │
│ │ │ ↓ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ View │ │ Database │ │TransactionExe│ │
│ │ (React) │ │ (IndexedDB) │ │ cutor │ │
│ └──────────┘ └──────────────┘ └──────┬───────┘ │
│ ↑ │ │
│ │ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ WebSocket 连接 │ │
│ └──────────────┬───────────────────┘ │
└──────────────────────────────────┼───────────────────────────────────┘
│ Delta Packets
↓
┌──────────────────────────────────────────────────────────────────────┐
│ 服务端 (Linear API) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ 执行事务 │──→│ 递增 sync id │──→│ 广播 Delta Pack │ │
│ │ │ │ │ │ ets │ │
│ └──────────┘ └──────────────┘ └────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
十、API 端点
| 端点 | 用途 |
|---|---|
/sync/bootstrap |
全量或部分引导;参数:type、onlyModels、firstSyncId、syncGroups、modelsHash |
/sync/batch |
按索引键懒加载模型;body 含 firstSyncId + requests 数组 |
/sync/user_sync_groups |
预请求 SyncGroup(较新的优化) |
| GraphQL mutations | 如 issueUpdate(id: "...", input: $issueUpdateInput) { lastSyncId } |
十一、关键技术栈
| 技术 | 用途 |
|---|---|
| MobX | 可观察性与响应式更新 |
| IndexedDB | 本地持久化层 |
| WebSocket | 实时增量包推送 |
| TypeScript 装饰器 | 模型与属性注册 |
| GraphQL | 服务端通信(mutations + queries) |
| Fractional Indexing | 排序(如 sortOrder: -84.71) |
十二、数据流总结
1. App 启动 → 装饰器注册模型 → ModelRegistry 填充
2. 引导 → IndexedDB 准备 → 从服务端获取模型 → 写入 IndexedDB → 水合到内存 → Object Pool 填充
3. 用户编辑属性 → 内存模型立即更新 → markPropertyChanged 记录旧值 → save() 创建事务 → 入队+缓存
4. TransactionQueue 批量事务 → TransactionExecutor 发送 GraphQL mutation → 服务端响应 lastSyncId
5. 服务端通过 WebSocket 广播增量包 → 所有客户端接收 sync actions → applyDelta 更新本地 DB + 内存 → 如需则事务变基
6. 懒水合触发 → PartialIndexHelper 计算查询参数 → BatchModelLoader 批量请求 → 加载并缓存
十三、关键设计洞察
- OT 而非 CRDT:Linear 依赖中央服务器确定事务全序,避免了 CRDT 的元数据开销和部分同步难题
- 乐观更新 + 增量确认:客户端内存立即更新,但本地数据库仅在收到服务端增量包后才更新,保证了一致性
- 事务变基:解决了多客户端并发编辑时的冲突问题,保证「最后写入者胜出」
- 分层加载:
instant/lazy/partial三级加载策略平衡了首屏性能和按需加载 - 部分索引:通过三层嵌套引用索引实现精确的按需查询,避免全量加载
- 离线容错:事务缓存 + 自动重发,断网不丢操作
- Schema Hash:元数据哈希检测,自动触发 IndexedDB 迁移
- local 策略:预留了无需服务端改动的本地模型开发路径
十四、仓库结构
| 路径 | 说明 |
|---|---|
code/ |
带详细注释的 Linear 混淆源码 |
imgs/ |
README 使用的图表和截图 |
README.md |
详细逆向工程文章 |
SUMMARY.md |
10 分钟精简摘要 |
reverse-lse.excalidraw |
架构 Excalidraw 图 |
声明:作者与 Linear 团队无关联,未咨询过他们。可能存在与实际实现不一致之处,欢迎通过 Issue 或 PR 纠正。