CheinTian / notes
Clipping · 6 分钟

逆向工程 Linear 同步引擎 (LSE)

github.com wzhudev

逆向工程 Linear 同步引擎 (LSE)

项目地址: wzhudev/reverse-linear-sync-engine ⭐ 2.5k stars | 134 forks Linear CTO Tuomas Artman 评价:「可能是现存最好的文档——无论是内部还是外部的」「比 Linear 内部发布的文档更正确、更完整」


一、项目背景与动机

作者从事协作软件开发(富文本编辑器、电子表格),需要一个满足以下三个条件的同步引擎:

  1. 支持任意数据模型 — 可适配各种业务场景
  2. 功能丰富 — 部分同步、权限控制、撤销/重做、离线可用、编辑历史
  3. 开发者体验好 — ORM 风格的模型定义,无需同步引擎专业知识

作者的判断:OT 以复杂著称,CRDT 常引入元数据开销且难以支持部分同步和权限控制。Linear 的方案提供了一个优雅的替代选择——更接近 OT 的思路,但通过中央服务器排列事务顺序,避免了传统 OT 的复杂性。


二、LSE 三大支柱

plaintext
┌─────────────────────────────────────────────────────┐
│                    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

plaintext
  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 模型装饰器

typescript
@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 引导流程

plaintext
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 后:

  1. instant 策略的模型通过 ModelRegistry 构造函数实例化
  2. 加入 Object Pool(SyncClient.modelLookup,UUID → 模型对象的映射)
  3. 先初始化 → 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 请求:

  1. 有 partial index key 的 → loadSyncBatch(发送到 /sync/batch)
  2. 有 SyncGroup 的 → loadPartialModels(发送到 /sync/bootstrap?type=partial)
  3. 其他 → loadFullModels

六、事务(Transaction)

6.1 属性变更流程

plaintext
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 事务队列四阶段

plaintext
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 流程

plaintext
收到增量包
  │
  ├─ 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 维护已完成事务的栈
  • 撤销:反向重放事务;重做:重新应用事务

九、架构总览

plaintext
┌──────────────────────────────────────────────────────────────────────┐
│                         客户端 (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)

十二、数据流总结

plaintext
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 批量请求 → 加载并缓存

十三、关键设计洞察

  1. OT 而非 CRDT:Linear 依赖中央服务器确定事务全序,避免了 CRDT 的元数据开销和部分同步难题
  2. 乐观更新 + 增量确认:客户端内存立即更新,但本地数据库仅在收到服务端增量包后才更新,保证了一致性
  3. 事务变基:解决了多客户端并发编辑时的冲突问题,保证「最后写入者胜出」
  4. 分层加载:instant / lazy / partial 三级加载策略平衡了首屏性能和按需加载
  5. 部分索引:通过三层嵌套引用索引实现精确的按需查询,避免全量加载
  6. 离线容错:事务缓存 + 自动重发,断网不丢操作
  7. Schema Hash:元数据哈希检测,自动触发 IndexedDB 迁移
  8. local 策略:预留了无需服务端改动的本地模型开发路径

十四、仓库结构

路径 说明
code/ 带详细注释的 Linear 混淆源码
imgs/ README 使用的图表和截图
README.md 详细逆向工程文章
SUMMARY.md 10 分钟精简摘要
reverse-lse.excalidraw 架构 Excalidraw 图

声明:作者与 Linear 团队无关联,未咨询过他们。可能存在与实际实现不一致之处,欢迎通过 Issue 或 PR 纠正。