回到文章

前端2026.10.24

Linear 为什么这么快:数据库放在浏览器里,先改本地再同步

3212 字 · 11 分钟读完

目录

原文是 Dennis Brotzky 的 How's Linear so fast? A technical breakdown。作者没在 Linear 工作过,内容是他从 Linear 的产物、官方博客和演讲里拆出来的。下面的技术栈和数字都以原文为准,我加的是自己的理解。

Linear 为什么这么快:数据库在浏览器里

Linear 快的核心原因是它把数据库放在了浏览器的 IndexedDB 里,所有修改先应用到本地,再异步推到服务端,服务端通过 WebSocket 把增量广播给其他客户端。

普通 CRUD 应用改个标题大概是这样:

js
async function updateIssue(issue, title) {
  showSpinner()
  const res = await fetch(`/api/issues/${issue.id}`, {
    method: 'PATCH',
    body: JSON.stringify({ title }),
  })
  setIssue(await res.json())
  hideSpinner()
}

点一下要等一个来回,原文估算 300ms 左右。Linear 里是这样:

js
issue.title = '新标题'
issue.save()

第一行改的是内存里的 MobX observable,界面同步重渲染;save() 只是把这次改动排进事务队列,同步引擎攒一批再发给服务端。用户感知到的耗时就是几毫秒。

作者一句话说得很直白:你要对抗的最大瓶颈是网络。所以能不让用户等网络,就别让他等。

Linear 联合创始人 Tuomas Artman 在演讲里说过,他写的第一行代码就是同步引擎。

首屏怎么做到秒开

本地有数据,操作当然快。但第一次打开还是得下载代码。原文说 Linear 现在仍然有大约 21 MB minify 后的 JS,它的做法是拆小、提前拿、拿过就缓存:

  • 构建管线换了四次:Parcel → Rollup → Vite → Rolldown。只面向现代浏览器,不打 polyfill、不转 ES5。按 Linear 官方博客的数据,发出去的代码少了 50%,Safari 下活跃 issue 视图的首次绘制快了 59%。
  • modulepreload 解决瀑布流:chunk 拆成几百个后,浏览器要解析完一个才知道下一个要什么。Linear 在 index.html 里把关键路径上的 chunk(比如 vendor-mobx、SyncWebSocket、DatabaseManager)全部列成 <link rel=modulepreload crossorigin>,并行下载。
  • Service Worker 预缓存约 1200 个资源:停在登录页的几秒钟里,整个应用就被后台拉完了。之后的访问直接从 Service Worker 缓存读,离线也能看、能建、能改 issue。
  • 每个 npm 包单独一个 vendor chunk:升级一个依赖只让这一个 chunk 失效,不会把整个 vendor.js 废掉。
  • 应用外壳内联在 HTML 里:关键 CSS 和一小段启动脚本直接写在 <head>,从 localStorage 读出侧边栏宽度、颜色、暗色模式,先把加载画面画出来,bundle 还没到。

我觉得最有意思的是最后一条:先渲染,再鉴权。常规流程是验 session → 拿用户 → 拿 workspace → 渲染,动辄一两秒。Linear 的启动脚本只看 localStorage.ApplicationStore 存不存在,存在就直接把 IndexedDB 里缓存的 workspace 画出来。真正的 token 在 cookie 里,如果过期了,下一个请求(WebSocket 握手、同步、HTTP 都算)会拿到 401,再跳登录页。

这和改数据用的是同一个思路:默认走成功路径,后台再校验。

Linear 同步引擎的三根柱子

Linear 的同步引擎靠三件事做到快:数据启动时就在内存里、修改不等网络、服务端回来的增量只更新对应的那一格。原文没有细讲内部实现,想看细节可以读文末 wzhudev 的逆向分析。

1. 数据已经在那了。 启动时从 IndexedDB 把数据灌进内存里的 MobX 对象池,UI 查询直接打对象池,没有"加载 issue 中"这个状态。数据量最大的 Issue 和 Comment 两张表是按需懒加载的,所以启动耗时跟 workspace 的结构有关,跟大小关系不大。原文说一万个 issue 的 workspace 和一百个的启动差不多快。

2. 修改不等网络。 改一个状态,几乎同时发生三件事:更新 MobX observable、写进 IndexedDB 里持久化的事务队列、排队发往服务端。服务端拒绝的话 observable 回滚,界面闪一下。但大部分非法操作在创建事务之前就被拦掉了,所以很少见。刷新页面事务也不会丢,因为队列在 IndexedDB 里。

3. 一个增量只改一格。 服务端推回来的确认(自己的或别人的改动)是很小的 JSON,写到对应的 MobX observable 上。每个 model 的每个属性都是单独的 observable,读它的组件包在 observer() 里,所以改一个字段只重渲染读这个字段的组件。批量改 50 个 issue,就是 50 个格子重渲染,列表本身不动。

三件事少一件都不行:没有本地库,首屏要等网络;没有乐观写,保存要转圈;没有细粒度 observable,更新会卡。

同步失败和冲突怎么处理

Linear 同步失败时,靠持久化在本地的事务队列重发或回滚;两个人改了同一个字段,按服务端最后处理的那次写入为准(last-writer-wins)。

先改本地、后同步,绕不开这两个问题。原文只提了一句"服务端拒绝就回滚",下面的细节来自 wzhudev 的逆向分析,不是官方文档。

请求失败分两种。

  • 断网、请求没发出去:事务已经写进 IndexedDB 的 __transactions 表,网络恢复后自动重发。关掉浏览器也不会丢,下次启动读回来重放。
  • 服务端拒绝(没权限、数据不合法):事务调 rollback,把内存里 MobX model 的改动撤掉,界面闪回原值。

有个细节:IndexedDB 里 Issue 这些数据表要等服务端确认后才写,先落盘的只有事务队列。所以回滚只需要撤内存,不用清本地库。

如果客户端落后太多,比如 lastSyncId 丢了、本地 model 过期,就不追增量了,直接全量重新拉一次。

冲突:服务端最后处理的那次写入赢。

服务端每处理一个事务,就发一个递增的 syncId。同一个字段,谁的 syncId 大谁就是最终值。算的是谁后到服务端,不是谁先点,网络慢的人先点反而可能赢。

拿改负责人举例,我改成 Bob,同事改成 Alice。

同事先到服务端:

text
服务端:  100 = Alice(同事) → 101 = Bob(我)
我的屏幕:Bob → 收到 100,rebase,仍显示 Bob → 收到 101 确认 → Bob

Alice 那条增量到的时候,我的事务还在队列里。Linear 会对队列里的事务做 rebase:把事务里记的"原值"换成 Alice,屏幕继续显示我的 Bob。这样界面不会闪,万一我的事务被拒,回滚也是回到 Alice。

我先到服务端:

text
服务端:  100 = Bob(我) → 101 = Alice(同事)
我的屏幕:Bob → 收到 100,事务完成 → 收到 101 → Alice

这次我输了,负责人从 Bob 变成 Alice,没有任何提示,只能在 issue 历史里看到。

什么情况算冲突?

冲突按字段算,不按整条 issue 算。更新事务只带改过的字段,改负责人的请求体里就只有一个 assigneeId,所以:

  • 不冲突:同一个 issue 的不同字段。 我改负责人、同事改状态,服务端各改各的,两边都保留。
  • 不冲突:issue 描述。 描述用的是 ProseMirror + Yjs(CRDT),两个人改同一段也是按字符合并,写的都留着。
  • 冲突:同一个单值字段。 负责人、状态、优先级、标题这类,后到服务端的覆盖先到的,就是上面 Bob 和 Alice 的情况。
  • 说不准:标签这类数组字段。 issue 的标签存成一个 labelIds 数组,算同一个字段。如果事务发的是整个数组,我加标签 A、同事加标签 B,后到的会把先到的覆盖掉;如果发的是"加一个 A"这种增量,两个都会留下。逆向分析没写标签改动是怎么序列化的,我也没验证过。

另外,重放不幂等的事务可能报错,比如去删一个已经不存在的对象。

交互和动画也在省时间

引擎再快,如果点三层菜单才能改个状态,用户还是觉得慢。Linear 把键盘当主输入:单个字母改当前 issue,两个字母组合做导航,⌘K 打开命令面板。命令面板搜的也是本地 MobX 对象池,不走网络。快捷键在界面上到处都标着,新手也能用鼠标完成所有操作。

动画上 Linear 很克制,基本只动 transform 和 opacity,偶尔动 background-color、border-color,列表项完全不加过渡。这和我之前在 CSS 动画入门 里记的原则一致:只触发合成的属性最便宜,width、margin 这类会触发布局的别动。

时长全部收在 CSS 变量里:

css
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;

注意第一个是 0s:高亮、弹层出现时没有动画,消失时才淡出 150ms。出现是用户在等的,所以要快;消失是用户已经不关心的,慢一点没关系。

不自己写同步引擎,能先抄哪几招

自己做一套同步引擎成本很高,大部分团队不会做。但原文里有几招是现在就能用的:

  1. 乐观更新:SWR 和 TanStack Query 都支持,先改缓存再发请求,失败了回滚。

    js
    // SWR:先改本地缓存,第三个参数 false 表示先别重新请求
    mutate(`/api/issues/${id}`, { ...issue, title }, false)
    await fetch(`/api/issues/${id}`, { method: 'PATCH', body: JSON.stringify({ title }) })
    mutate(`/api/issues/${id}`)
    

    TanStack Query 写法多几行,但回滚是显式的:onMutate 里先存一份快照再改缓存,onError 用快照恢复,onSettled 不管成败都重新拉一次对齐服务端。

    js
    const queryKey = ['issue', id]
    
    useMutation({
      mutationFn: (title) => patchIssue(id, { title }),
      onMutate: async (title) => {
        await queryClient.cancelQueries({ queryKey }) // 防止进行中的请求把乐观值盖掉
        const previous = queryClient.getQueryData(queryKey)
        queryClient.setQueryData(queryKey, (old) => ({ ...old, title }))
        return { previous }
      },
      onError: (_err, _title, ctx) => queryClient.setQueryData(queryKey, ctx.previous),
      onSettled: () => queryClient.invalidateQueries({ queryKey }),
    })
    
  2. 关键 chunk 加 modulepreload,别让浏览器一层层发现依赖。

  3. 启动画面需要的状态放 localStorage,内联脚本先画壳,比如主题色、侧边栏宽度,避免闪一下。

  4. 别让全局初始化挡住所有页面:用户信息、实验开关这类请求放到真正需要的页面里等,别在根布局里一起等。

原文最后提了一点:Linear 没用 Next.js,也没用 TanStack,就是客户端渲染 + 自己的同步引擎,心智模型反而简单。

常见问题

Linear 用的是 CRDT 吗? issue 的状态、负责人这类字段不是,冲突按服务端最后处理的写入为准,过程见上面「同步失败和冲突怎么处理」。多人同时编辑的 issue 描述用的是 ProseMirror + Yjs,这部分才是 CRDT。

Linear 离线能用吗? 能。离线时可以读、建、改 issue,改动排在本地事务队列里,联网后再发出去。

workspace 很大的时候也这么快吗? 不一定。Linear 在 2021 年的 Improving performance at scale 里承认过,让大部分用户觉得快的那些设计,也是部分大 workspace 变慢的原因。当时客户端启动要把整个 workspace 的数据拉下来存进 IndexedDB,再在内存里建好对象图才渲染,数据一多就慢。上面说的 Issue/Comment 懒加载就是后来为这个做的。

相关链接

原文:

Linear 官方:

同步引擎实现细节:

中文资料:

延伸: