离线优先是数据模型的决定,不是一个缓存功能
大多数应用在最后才把离线支持硬塞进去,然后发现这一直是个架构问题。这里讲的是真正必须改变的东西,以及你不得不在其中做选择的冲突规则。
"让它能离线用"听起来像是一个冲刺周期里能加上的功能。并不是。它改变了你的数据存放在哪里、以及由谁来决定什么是真的,而这会牵动一切。
好消息是这套工作已经被研究得很透彻。坏消息是事后加装的成本是一开始就做进去的好几倍。
两种架构
默认架构是服务端权威。屏幕反映服务器。一个操作发出请求、等待、在响应到达时更新。离线意味着坏掉,因为没有东西可显示,也没有地方写入。
另一种是本地优先。设备有自己的数据库,界面从它读取。写操作立刻进入本地存储,屏幕据此更新。一个后台进程在有连接时与服务器对账。
这个倒置就是全部:网络不再挡在用户和他们的数据之间。 其余一切都由此而来。
真正会改变的东西
每条记录都需要一个能离线生成的标识
如果由服务器分配 ID,设备在离线时就无法创建任何东西,除非先编一个临时 ID,之后再改写所有引用。请从第一天起就在客户端生成 ID,用 UUID 或类似方案。这一个决定在前期很便宜,事后补装则很痛苦。
写入变成队列,而不是调用
一个操作把意图追加到一个持久的本地队列,然后立刻返回。一个同步进程负责把队列排空。这意味着队列必须能在应用被杀死后存活、必须带退避地重试、并且必须是幂等的,因为你一定会把某些操作发送两次。
删除不再是删除
如果一台设备在离线时删除了一行,而同步只发送现存的东西,服务器就永远不会知道,还会热心地把它发回来。你需要墓碑:把删除记录为一个带时间戳的事实,保留足够久,让每台设备都能看到。
界面必须显示同步状态
一旦写入可以在本地成功、之后才失败,用户就需要知道。不是给所有东西都加转圈,而是一个诚实的指示:已本地保存、同步中、已同步、失败。隐藏这一点会产生最糟的结果,也就是某人以为自己的工作是安全的,而其实不是。
选择冲突规则
两台设备在离线时修改了同一条记录。两台都回来了。总得有东西来裁决。不存在放之四海皆准的答案;只存在对你的数据而言正确的答案。
| 策略 | 如何工作 | 适合 |
|---|---|---|
| 后写入者胜 | 时间戳最大的覆盖其余 | 设置、按设备的状态、低风险字段 |
| 字段级合并 | 每字段各有时间戳;合并不冲突的字段 | 人们编辑不同部分的记录 |
| 操作日志 | 存储意图,按序重放 | 计数器、列表,一切累加性的东西 |
| CRDT | 可确定性合并的数据结构 | 协作文本与共享文档 |
| 询问用户 | 把两个版本摆出来让他们选 | 仅限罕见且高价值的冲突 |
两条来自经验的警告。基于设备时钟的"后写入者胜"会悄悄丢数据,因为设备时钟是不准的;请用服务器分配的或逻辑时钟。还有,"询问用户"不是默认选项;它是给那些自动解决代价过高的情况留的逃生门。被问得够多之后,人们会不看内容就点过去。
按数据类型选择冲突规则,而不是按应用。用户的闪卡进度可以是累加的,能干净地合并。他们的显示名用"后写入者胜"就够了。对两者用同一条规则,必然对其中之一是错的。
同步本身
对大多数产品可行的形态:
- 每条记录带一个由服务器分配的版本号或更新时间戳。
- 客户端保存一个游标:它已经追上的位置。
- 同步时,客户端发送排队的写入和自己的游标。
- 服务器应用这些写入、解决冲突,并返回自该游标以来的所有变更以及一个新游标。
- 客户端原子地应用变更并推进游标。
第 5 步的原子性比看起来重要。如果你先推进游标、然后在应用时失败,你就悄悄跳过了一批变更,而且永远不会有东西告诉你。
刻意测试那些难看的情况
离线 bug 不会在正常测试中出现,因为正常测试有 wifi。把这些写进测试计划:
- 开一周飞行模式, 然后带着几百个排队操作重新连上。
- 在同步中途被杀死。 在一批数据在途时强制退出。不得丢失,也不得重复应用。
- 公共 wifi 的登录门户。 最糟的网络状态:设备报告有连接,而每个请求都返回登录页。请求必须干净地失败,不能破坏状态。
- 时钟偏差。 把设备时钟调偏一天,确认顺序仍然成立。
- 两台设备、同一账号、都离线, 编辑同样的记录。这里才是冲突规则证明自身价值的地方。
什么时候不要做
离线优先是实打实的工作量,也并非总是值得。以下情况可以跳过:应用本来没网就没意义,比如实时聊天或支付;数据本质上是共享且同时的,那时你要的多半是实时而不是离线;或者它只是一个使用频率很低的内部工具,成本超过收益。
当人们在连接不稳定的地方使用应用时就该做:通勤路上、公共交通、飞机上、乡村地区、信号差的楼里。对一个学习类应用来说,那是大部分的使用场景,所以我们把它当作起点假设,而不是一条功能需求。
最便宜的版本
如果完整的本地优先架构对现阶段来说太重,中间有一大片值得拿下的地带:
- 大力缓存读取, 让应用打开时看到的是内容而不是转圈。
- 持久地把写入排队, 即使还没有完整同步,也能让一个操作不因掉线而丢失。
- 现在就在客户端生成 ID, 因为今天它不花什么代价,而以后它会解锁一切。
这三件事用一小部分工作量带来大部分可感收益,而且如果你以后走得更远,没有一件是白做的。