·约 11 分钟阅读·DeepSeek

本地优先软件:为什么你的应用应该能离线工作(以及它的代价)

云为默认的应用在延迟、可用性、数据所有权和锁定上如何辜负你;local-first software 与 offline-first 到底是什么意思;诚实面对那些难的部分(冲突解决、CRDT vs 最后写入胜出、同步引擎、多设备、迁移、没有服务器的认证);小应用的 SQLite 实践栈;以及本地优先在什么时候是杀鸡用牛刀。

devopsself-hosting

我给自己写软件,多年来一直不假思索地沿用同一套架构:一台服务器、服务器上的一个数据库,加一个通过 HTTP 跟它讲话的瘦客户端。大多数团队都会顺手这么做。直到我在一趟网络很差的火车上工作了一周,眼看着自己一半的工具变成废铁,我才认真去想一个早该想的问题。本地优先软件(local-first software) 就是我最后得到的答案:应用把数据留在你的设备上,把网络当作增强,而不是依赖。这篇文章讲的是:为什么 offline-first 通常是小工具的更好默认,一个 sync engine 和一个 CRDT 究竟能给你带来什么,以及——同样重要地——它到底要付出什么代价,因为诚实的答案是「比那些大会演讲里承认的要多」。

云为默认的应用到底哪里有问题

所谓云为默认的应用,就是服务器是你数据唯一存在的地方,而客户端只是望向它的一个窗口。对于一个共享的、永远在线的产品,这个设计说得过去;但对于一个人或一个小团队运行的长尾软件,它是个糟糕的默认值。

四种代价每次都会出现。

  • 延迟。 每一次交互都是一次往返。连接好的时候,每一次敲键或点击就有 50 毫秒的死时间;连接差的时候就只剩一个转圈。客户端在服务器点头之前渲染不出你自己的数据,所以哪怕服务器很快,应用也比一本纸质笔记本更迟钝。
  • 可用性。 服务器挂了,或者你的连接断了,或者厂商出了事故,应用就一点功能都没有了。数据没丢,但够不着——实际上是一回事。一个本地优先的应用会退化成「能用,只是不同步」。
  • 所有权。 你的数据活在别人的数据库里,受别人的条款约束。你通常能导出,但导出的格式会丢掉那些让它有用的关联。默认情况下你是租户,不是主人。
  • 锁定。 一旦 schema、API 和认证都活在厂商的云里,离开就意味着重写应用。这种不对称正是重点:进来容易,出去昂贵。

这些都不新鲜,也都不是必然的。它们是一个我们大多从没自觉做过的设计选择所开出的账单。

本地优先软件到底是什么意思

本地优先把默认反过来。事实来源是你设备上的那份副本,服务器(如果有)只负责帮设备们找到彼此。经典描述来自那篇同名的 Ink & Switch 文章,简短版就是一组属性:

  • 你的数据存在你设备上的一个存储里,你能读、能复制、能备份。
  • 读写直接打在这个本地存储上,所以是瞬时的,而且完全不需要网络。
  • 同步是你加上去的一个功能,不是一个必须先满足、否则什么都干不了的硬性要求。
  • 如果多台设备改了同一个东西,软件会收敛到一个一致状态,而不是丢数据或者拒绝合并。

最后一条属性是所有难度的所在,我会讲到;但前三条才是重点,而且很便宜。注意它实际上的结果:offline-first 不是你去打开的一个模式,而是「把存储放在用户身边、让同步稍后追上」的自然结果。

难的部分:冲突解决、CRDT,与最后写入胜出

只要两台设备能编辑同一条记录,你就需要冲突解决(conflict resolution)。这一节区分了演示和产品,也是大多数本地优先项目悄悄死掉的地方。

最后写入胜出:便宜,但有损

最简单的策略是最后写入胜出(LWW):每次写带一个时间戳,两版碰面时最新的赢。它只有几行代码,总能收敛,而且错得足够频繁。设备之间的时钟并不一致,所以「最新」可能是任意的;而且它会无声地丢弃输掉的那次编辑——没有报错,没有提示。对于一个单用户、从不在两台设备上同时改同一条笔记的应用,LWW 没问题。对任何协作性的东西,它就是「多此一举的数据丢失」。

CRDT:不需要裁判也能收敛

CRDT(无冲突复制数据类型)是一种数据结构,它的设计让你无论以什么顺序合并副本,都能到达同一个状态。与 LWW 不同,合并不再无声地丢弃编辑:计数器相加,集合求并,一个设计良好的文本类型会同时保留两边插入的内容。这个属性叫强最终一致性,确实强大——它正是当对等节点够不到中央仲裁者时,协作编辑还能成立的原因。

下面是诚实的账。CRDT 给你收敛、干净的离线合并,以及关键路径上不需要中央服务器。它的代价是:每一项的元数据更大、需要回收的墓碑垃圾、实现起来微妙且容易搞错的文本类型,以及一个跟你已有的行列对不齐的数据模型。人们常常漏掉的关键点是:CRDT 不是同步引擎。它只是同步引擎可以采用的一种合并策略,只解决你五个问题里的一个。当你确实有对共享结构的并发编辑时——协作文本、共享队列、购物清单——再用 CRDT。别因为听起来时髦就用它。

同步引擎:让它变得可用的那一层

同步引擎(sync engine) 是在副本之间搬动改动的机械装置:追踪上次同步以来变了什么、传输、应用,并解决冲突。2026 年的好消息是:你很少需要从零写一个。可选的有基于 SQLite 的引擎,如 cr-sqlite 和 ElectricSQL;面向文档的 Automerge 和 Yjs;以及 Replicache、PowerSync 这类通用工具。它们交换的单位不同——操作日志、表级 diff 或文档更新——诚实的建议是:在你有数据要迁移之前,就选一个冲突模型跟你数据匹配的。

设备 A 本地存储 读写打在这里 永远可用 同步引擎 在线时搬运改动 设备 B 本地存储 读写打在这里 永远可用 应用永远不被它阻塞
本地优先的形态:每台设备读写自己的存储,同步只是一件后台的事,能追上就追上。

构建一个离线优先的应用:本地 SQLite,上面加同步

对于一个小应用,实践栈并不花哨,而且是我反复推荐的那一套:设备上用 SQLite,加一条你能掌控的同步路径。SQLite 已经是地球上最好的本地存储——单个文件、事务性、可查询,而且每一台手机、浏览器和笔记本里都自带它。如果你读过我关于 SQLite 作为小应用默认数据库 的论证,这就是向上挪一层的同一个论证。

最简单且正确的设计是纯本地:装上 SQLite,再加一个显式的导入/导出,让用户以一个文件的形式拥有自己的数据。对一个个人工具来说,这就是一个完整、站得住脚的产品,我发布过不止一个。

-- 每一行都带上足够以后合并用的元数据
CREATE TABLE note (
  id          TEXT PRIMARY KEY,   -- UUID,在设备上生成
  title       TEXT NOT NULL,
  body        TEXT NOT NULL,
  updated_at  INTEGER NOT NULL,   -- 设备的逻辑时钟,不是墙上时间
  device_id   TEXT NOT NULL,      -- 谁写的,用来打破平局
  deleted     INTEGER NOT NULL DEFAULT 0
);

注意那些只为同步而存在的列:updated_at、device_id,以及一个软删除标志(硬 DELETE 对一个从没见过这行的对等节点来说是不可见的)。这三样是让 LWW 收敛的最低要求。如果你想要不丢编辑的合并,就在上面叠一个 CRDT 或操作日志,每行的 schema 成本随之上升——这就是这个交换,说清楚点。

一个朴素的 LWW 合并小到可以直接展示:

type Note = { id: string; updated_at: number; device_id: string; deleted: number };

// 最新的逻辑时钟获胜;device_id 确定性地打破平局,
// 于是每个副本无论以什么顺序合并,都会得到同一个答案。
function pick(a: Note, b: Note): Note {
  if (a.updated_at !== b.updated_at) return a.updated_at > b.updated_at ? a : b;
  return a.device_id > b.device_id ? a : b;
}

这就是诚实的最后写入胜出:收敛正确、确定性,但仍然有损。device_id 的平局打破,就是「最终一致」和「随机不一致」之间的差别。

那些没人发推的不光鲜部分

一旦超过一台设备,还有三个问题会冒出来,它们是本地优先是真活儿的原因:

  • 多设备。 离线越久,两台设备偏得越远,而合并成本随差异增长:保留多少历史、什么时候做快照、一台离线一个月的设备如何在不下载全部内容的情况下重新加入。
  • schema 迁移。 一个云应用只迁移一个数据库。一个本地优先应用要迁移每一台设备,包括那些离线数月、揣着旧数据的。迁移变成了跨版本的兼容性问题,你也失去了「在服务器上跑一下就行」的奢侈。
  • 没有服务器的认证。 如果应用要离线可用,它就必须能离线认证——对着一个设备持有的密钥或一份缓存的凭据,而不是一次实时会话检查。这会把你推向本地密钥存储、签名操作,以及一个验证签名而非信任会话 cookie 的同步服务器。对大多数小应用来说,这比你愿意碰的密码学要多,也正是「干脆不要服务器」往往更便宜的原因。

这些没有一个是致命的。它们全都是持续的维护,而账单会在糟糕的那一周到来,而不是在演示的时候。

本地优先什么时候是杀鸡用牛刀

我不会假装它总是答案。本地优先在下面这些情况下是杀鸡用牛刀:

  • 数据其实是实时共享的。 多人游戏、实时看板、聊天室——一个中央权威更简单,而且你本来就想这么做。
  • 数据必须是权威且由服务器掌控的。 支付、库存计数,任何客户端不可信的场合。服务器就该故意拥有那份真相。
  • 没有离线场景。 如果应用本来离开网络就没用(流媒体、对远程语料库的搜索),本地优先什么都买不到,还得搭上一整套同步机械。
  • 团队就一个人,数据也不大。 有时候一个服务器加一条 curl,确实比一个同步引擎加一套冲突模型代码更少。

我见过最多的失败模式恰恰是反面:团队给一个从来不需要离线工作的应用加上了同步引擎,然后为一个用户永远在线的产品,在冲突上耗了几个月。

我的建议

从纯本地开始。把数据放进设备上的 SQLite,让读写瞬时完成,再加一个 export,让用户拥有一个文件。这就是本地优先 80% 的价值——延迟、可用性、数据所有权(data ownership)——只花一小部分复杂度,而且可逆。

只有当你有具体、说得出口的需求时,才加同步层:你自己在用的两台设备,或者第二个编辑同一份数据的人。真到那时候,先定冲突模型,再选库。对「每条记录单写者」的数据用最后写入胜出;只对共享结构上真正的并发编辑用 CRDT;能用成熟同步引擎就别自己造。如果你自托管同步端点,就让它保持无聊,放在跟其他一切同一扇门后面——它只是我已经在跑的那套栈里的一个对等节点,不是一种新宗教。

本地优先不免费,也不是万能。但对于构成我真正在用的那大部分软件的小工具来说,它就是「一个我拥有的应用」和「一个我租来的应用」之间的差别。这个交换,每次都值得。