English version: Local-first data: snapshots, deltas, and the two kinds of state。本文是交易类 App 架构系列第三篇。
一个面对庞大领域的应用,手里有两类数据,而它们的性质几乎相反:
基础数据是“存在哪些东西”的目录:标的、交易所、币种、精度规则。它体量大、变化慢、以追加为主。用户期待随时能即时搜索它,包括在没有网络的时候。
实时数据是“这些东西当前是多少”:价格、持仓、余额。每一条都很小、时刻在变,而且一旦陈旧就毫无价值。
“同步”之所以难,大多来自把这两类数据塞进同一套机制。把它们分开,各自都变得可解:实时数据是一条流(见上一篇),而基础数据是一个形态格外简单的复制问题。
目录为什么该放在设备上
“搜索必须随时即时”这条要求,网络调用满足不了。原因不是网络调用平均很慢,而是它的最坏情况没有上界,而搜索恰恰是按最坏情况被评判的:在电梯里,在地铁上。
所以让设备持有目录,网络从依赖变成增强。这就是本地优先在实践中的含义:本地副本已经足够好到可以直接工作,网络的职责是让它更新鲜,而不是让它存在。
这会把常见的失败叙事倒过来。传统客户端在离线时是“坏了”;本地优先的客户端在离线时只是“旧了”,而一个变化缓慢的目录旧一点,往往根本观察不到。这带来的体感差距很大,而你付出的代价很小,因为这份数据本来就不是高速变化的。
快照加增量,以单调版本为键
机制本身很小。
服务端为目录维护一个单调递增的版本号,任何改动都会推高它。客户端记录自己已经推进到的版本,启动时只问一个问题:自版本 n 以来,变了什么? 服务端返回增量,客户端应用它们,并记下新的水位线。
这个问题就是协议的全部,而它有几个值得点名的性质:
- 可续传。 一次被中断的同步不会损失任何东西,你从最后一个完整应用的版本继续。所以要把“应用这批增量”和“更新水位线”放在同一个事务里,否则你迟早会跳过一条增量。
- 幂等。 只要增量表达成“写入或删除标记”而不是原地修改,重复应用一段已有区间就是无害的。
- 验证成本低。 两个整数就能判断一个客户端是不是最新的。
这就是为什么目录属于缓存失效里容易的那一侧。失效之所以难,是因为你得猜一份缓存是否仍然有效;而在这里你从不猜,版本号给出精确答案,并且数据以追加为主,所以答案通常是“加上这几行”。
有两个版本不能混为一谈:数据版本(内容有多新鲜)和结构版本(本地存储是什么形状)。App 升级会改结构,服务端变更会推高数据版本。分开存储、分开迁移,否则某次升级会让你丢掉一整个数据库,而你原本只需要给它加一列。
把快照打包进安装包
你可以让每个用户都从版本零开始,首次启动时下载整个目录:几十万行数据,走移动网络,而且要下完他才能搜索。
也可以把一份快照打包进安装包,让用户从这份快照构建时的版本开始。首次启动、首次搜索、离线可用、没有转圈。
这笔交易是安装体积换首次体验。对于一份产品运转真正必需的目录,快照通常胜出。它是一次刻意的交换:把字节挪到用户只下载一次、在后台、由专门为此建造的应用商店完成的环节,去换掉产品里最不容错的那个时刻上的一次脆弱网络请求。
此后目录就只收增量。一年后才安装的用户会拿到更大的增量,这是这套设计唯一的成本,也是你需要定期重新切一份快照的原因。
什么绝对不能以本地为准
设备可以拥有目录,但不能拥有账本。
余额、持仓、订单状态是服务端的真相。可以缓存它们用于显示,但要把缓存当作远端事实的一次渲染,并遵守两条规则:重连和冷启动时对账;永远不让本地值参与任何有后果的决策。本地状态回答的是“我显示什么”,永远不是“什么是真的”。
界线由一个问题划出:如果这个值是错的,后果是一块过期的屏幕,还是一个错误的动作? 过期的屏幕是缓存问题;错误的动作是正确性问题,而正确性问题住在服务端。
让本地这一面以只读为主
上面这一切之所以简单,是因为复制是单向的:服务端写,客户端读。没有冲突,因为只有一个写入者。
一旦客户端也开始写同一份数据,你就继承了分布式系统里真正困难的那部分:同一条记录的两次编辑、没有全局时钟、还必须收敛。这时你需要冲突解决,比如带版本向量的最后写入胜出、CRDT、或者服务端合并,并且你得接受某些解决结果会以用户能察觉的方式出错。
通常有办法让本地写入不走这条路:把用户产生的状态(自选列表、备注)建模成它自己的一个小集合,带自己的同步,而不是建模成对被复制目录的修改。大的那份走单向复制,小的那份走刻意设计的双向同步。
什么时候可以砍掉这些
整套设计由三个性质逼出:目录大、变化慢、需要离线。去掉其中任何一个,更简单的做法就是对的:
- 目录小到一次请求就能取完?取回来放内存里缓存。
- 一直在变?那它不是基础数据,按流来处理。
- 从不需要离线?用一个搜索接口,排序本来就是服务端更擅长的事。
值得避开的失败形态是:为几百行数据搭一套带版本追踪的增量同步,而它本来只需要一个请求。快照加增量是等目录大到“重新下载它”开始被用户抱怨时,你才升级到的方案。
参考
- M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan,“Local-first software: You own your data, in spite of the cloud”,Ink & Switch / Onward! 2019——术语的出处及其背后的七条理想。
- M. Kleppmann,Designing Data-Intensive Applications,O’Reilly,2017——第 5 章(复制)与第 11 章(流处理)是目录同步与行情流设计的背景。
- J. Kreps,“The Log: What every software engineer should know about real-time data’s unifying abstraction”,LinkedIn Engineering,2013(存档)——日志作为每一条都有意义的追加序列,以及快照 + 偏移量作为复制原语。
- M. Shapiro, N. Preguiça, C. Baquero, M. Zawirski,“Conflict-free Replicated Data Types”,SSS 2011——一旦两个写者编辑同一条记录,你就继承了什么。