# 本地优先：快照、增量与两种状态

> 很多同步问题之所以变难，是因为把两类性质相反的数据塞进了同一套机制。把变化缓慢的目录和变化飞快的数值分开，目录就变成一个已解决的问题：发一份快照，再按版本同步增量。

- Canonical (HTML): https://kaguc.com/blog/local-first-sync-zh/
- Date: 2026-08-28


*English version: [Local-first data: snapshots, deltas, and the two kinds of state](/blog/local-first-sync/)。本文是[交易类 App 架构](/blog/trading-app-architecture-zh/)系列第三篇。*

一个面对庞大领域的应用，手里有两类数据，而它们的性质几乎相反：

**基础数据**是“存在哪些东西”的目录：标的、交易所、币种、精度规则。它体量大、变化慢、以追加为主。用户期待随时能即时搜索它，包括在没有网络的时候。

**实时数据**是“这些东西当前是多少”：价格、持仓、余额。每一条都很小、时刻在变，而且一旦陈旧就毫无价值。

“同步”之所以难，大多来自把这两类数据塞进同一套机制。把它们分开，各自都变得可解：实时数据是一条流（见[上一篇](/blog/realtime-data-pipeline-zh/)），而基础数据是一个形态格外简单的复制问题。

## 目录为什么该放在设备上

“搜索必须随时即时”这条要求，网络调用满足不了。原因不是网络调用平均很慢，而是它的**最坏情况**没有上界，而搜索恰恰是按最坏情况被评判的：在电梯里，在地铁上。

所以让设备持有目录，网络从依赖变成增强。这就是本地优先在实践中的含义：**本地副本已经足够好到可以直接工作，网络的职责是让它更新鲜，而不是让它存在。**

这会把常见的失败叙事倒过来。传统客户端在离线时是“坏了”；本地优先的客户端在离线时只是“旧了”，而一个变化缓慢的目录旧一点，往往根本观察不到。这带来的体感差距很大，而你付出的代价很小，因为这份数据本来就不是高速变化的。

## 快照加增量，以单调版本为键

机制本身很小。

服务端为目录维护一个**单调递增的版本号**，任何改动都会推高它。客户端记录自己已经推进到的版本，启动时只问一个问题：**自版本 n 以来，变了什么？** 服务端返回增量，客户端应用它们，并记下新的水位线。

这个问题就是协议的全部，而它有几个值得点名的性质：

- **可续传。** 一次被中断的同步不会损失任何东西，你从最后一个完整应用的版本继续。所以要把“应用这批增量”和“更新水位线”放在同一个事务里，否则你迟早会跳过一条增量。
- **幂等。** 只要增量表达成“写入或删除标记”而不是原地修改，重复应用一段已有区间就是无害的。
- **验证成本低。** 两个整数就能判断一个客户端是不是最新的。

这就是为什么目录属于缓存失效里**容易**的那一侧。失效之所以难，是因为你得猜一份缓存是否仍然有效；而在这里你从不猜，版本号给出精确答案，并且数据以追加为主，所以答案通常是“加上这几行”。

有两个版本不能混为一谈：**数据版本**（内容有多新鲜）和**结构版本**（本地存储是什么形状）。App 升级会改结构，服务端变更会推高数据版本。分开存储、分开迁移，否则某次升级会让你丢掉一整个数据库，而你原本只需要给它加一列。

## 把快照打包进安装包

你可以让每个用户都从版本零开始，首次启动时下载整个目录：几十万行数据，走移动网络，而且要下完他才能搜索。

也可以把一份快照打包进安装包，让用户从这份快照构建时的版本开始。首次启动、首次搜索、离线可用、没有转圈。

这笔交易是安装体积换首次体验。对于一份产品运转真正必需的目录，快照通常胜出。它是一次刻意的交换：把字节挪到用户**只下载一次**、在后台、由专门为此建造的应用商店完成的环节，去换掉产品里最不容错的那个时刻上的一次脆弱网络请求。

此后目录就只收增量。一年后才安装的用户会拿到更大的增量，这是这套设计唯一的成本，也是你需要定期重新切一份快照的原因。

## 什么绝对不能以本地为准

设备可以拥有目录，但不能拥有账本。

余额、持仓、订单状态是服务端的真相。可以缓存它们用于显示，但要把缓存当作**远端事实的一次渲染**，并遵守两条规则：重连和冷启动时对账；永远不让本地值参与任何有后果的决策。本地状态回答的是“我显示什么”，永远不是“什么是真的”。

界线由一个问题划出：**如果这个值是错的，后果是一块过期的屏幕，还是一个错误的动作？** 过期的屏幕是缓存问题；错误的动作是正确性问题，而正确性问题住在服务端。

## 让本地这一面以只读为主

上面这一切之所以简单，是因为复制是**单向**的：服务端写，客户端读。没有冲突，因为只有一个写入者。

一旦客户端也开始写同一份数据，你就继承了分布式系统里真正困难的那部分：同一条记录的两次编辑、没有全局时钟、还必须收敛。这时你需要冲突解决，比如带版本向量的最后写入胜出、CRDT、或者服务端合并，并且你得接受某些解决结果会以用户能察觉的方式出错。

通常有办法让本地写入不走这条路：把用户产生的状态（自选列表、备注）建模成**它自己**的一个小集合，带自己的同步，而不是建模成对被复制目录的修改。大的那份走单向复制，小的那份走刻意设计的双向同步。

## 什么时候可以砍掉这些

整套设计由三个性质逼出：目录**大**、**变化慢**、**需要离线**。去掉其中任何一个，更简单的做法就是对的：

- 目录小到一次请求就能取完？取回来放内存里缓存。
- 一直在变？那它不是基础数据，按流来处理。
- 从不需要离线？用一个搜索接口，排序本来就是服务端更擅长的事。

值得避开的失败形态是：为几百行数据搭一套带版本追踪的增量同步，而它本来只需要一个请求。快照加增量是等目录大到“重新下载它”开始被用户抱怨时，你才升级到的方案。


## 参考

- M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan，["Local-first software: You own your data, in spite of the cloud"](https://www.inkandswitch.com/local-first/)，Ink & Switch / Onward! 2019——术语的出处及其背后的七条理想。
- M. Kleppmann，[*Designing Data-Intensive Applications*](https://dataintensive.net/)，O'Reilly，2017——第 5 章（复制）与第 11 章（流处理）是目录同步与行情流设计的背景。
- J. Kreps，["The Log: What every software engineer should know about real-time data's unifying abstraction"](https://web.archive.org/web/20190913020020/https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying)，LinkedIn Engineering，2013（存档）——日志作为每一条都有意义的追加序列，以及快照 + 偏移量作为复制原语。
- M. Shapiro, N. Preguiça, C. Baquero, M. Zawirski，["Conflict-free Replicated Data Types"](https://inria.hal.science/inria-00609399)，*SSS* 2011——一旦两个写者编辑同一条记录，你就继承了什么。

---

*上一篇：[订单可靠性](/blog/order-reliability-zh/) · 下一篇：[跨平台的边界](/blog/cross-platform-boundary-zh/)*

