BLOG · #Engineering

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

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

English version: Local-first data: snapshots, deltas, and the two kinds of state。本文是交易类 App 架构系列第三篇。

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

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

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

“同步”之所以难,大多来自把这两类数据塞进同一套机制。把它们分开,各自都变得可解:实时数据是一条流(见上一篇),而基础数据是一个形态格外简单的复制问题。

目录为什么该放在设备上

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

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

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

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

机制本身很小。

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

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

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

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

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

把快照打包进安装包

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

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

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

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

什么绝对不能以本地为准

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

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

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

让本地这一面以只读为主

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

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

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

什么时候可以砍掉这些

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

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

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

参考


上一篇:订单可靠性 · 下一篇:跨平台的边界