以下是英文博客文章《Why Local-First Apps Haven't Become Popular?》的中文翻译,保留了原文结构与技术细节,并适配为中文技术博客风格:
为什么"本地优先"(Local-First)应用尚未流行起来?
离线优先(Offline-first)应用听起来像是未来的方向:秒开、默认保护隐私,再也不用在信号不稳时盯着旋转的加载图标。
但在实践中,真正做好离线支持的应用少之又少。大多数应用只是把用户的操作暂存在本地,等网络恢复后再推送出去(剧透:这其实行不通)。最终,用户看到的往往是一条令人不安的提示:"更改可能未保存"。
原因很简单:同步很难。
当你构建一个本地优先的应用时,你实际上是在构建一个分布式系统 。多个设备可以各自独立地修改数据(有时处于离线状态),之后必须在不丢失任何数据的前提下,收敛到完全一致的状态。
要解决这个问题,主要有两大挑战:
- 事件顺序不可靠
- 数据冲突
下面我们逐一分析。
1. 事件顺序不可靠
在分布式环境中,多个设备会在不同时间产生事件。如果你只是按收到的顺序应用这些变更,最终结果就会不一致。
举个例子:
- 设备 A 设置
x = 3
- 设备 B 设置
x = 5
这两个操作都是在离线状态下完成的。
当设备重新联网同步时,x
的最终值将取决于哪个更新先被应用------这显然是个问题。
传统后端数据库通过强一致性来解决这个问题,但这需要全局协调,对于本地优先系统来说太慢、也太脆弱。
我们真正想要的是最终一致性(eventual consistency):每个设备可以独立应用变更,但一旦所有变更都已知,所有设备最终会收敛到相同的状态。
难点在于:如何在没有中心化时钟(网络可能中断)的情况下,确定事件的正确顺序?
解法:混合逻辑时钟(Hybrid Logical Clocks, HLC)
令人惊讶的是,这个看似棘手的问题其实有一个简洁的解决方案:混合逻辑时钟(HLC)。
HLC 生成的时间戳具备两个关键特性:
- 可比较性:可以按字典序排序
- 因果一致性:能准确反映事件发生的先后关系
HLC 结合了两类信息:
- 物理时间(来自设备本地时钟)
- 逻辑时间(一个计数器,用于处理时钟不同步或事件过于密集的情况)
简单来说,HLC 让设备在没有完美同步时钟的情况下,也能就"谁先发生"达成一致。
举个例子
假设有两台设备 A 和 B:
- 设备 A 在
10:00:00.100
记录一个事件
→ 其 HLC 为(10:00:00.100, 0)
(时间 + 计数器) - A 将此 HLC 随消息发送给 B
- 设备 B 的本地时钟显示
10:00:00.095
(略慢) - 当 B 收到消息时,它会将自身的 HLC 提升到至少不小于 A 的时间戳
- 于是 B 的 HLC 变为
(10:00:00.100, 1)
------ 计数器加 1,表示该事件发生在 A 之后
结果:
- A 的事件:
(10:00:00.100, 0)
- B 的事件:
(10:00:00.100, 1)
即使 B 的物理时钟更慢,我们也能在所有设备上一致地排序事件。
注意 :HLC 的同步依赖于设备间通信。如果设备长期离线,它们各自独立生成 HLC,不会互相影响。但一旦联网,HLC 机制能确保后续事件的因果顺序正确。因此,系统可以承受数周离线而不丢失数据------因为所有变更都被本地持久化,且时间戳设计保证了未来同步时的正确合并。
2. 数据冲突
即使有了可靠的事件排序,冲突依然会发生------当两个设备在离线状态下修改了同一份数据。
例如:
- 初始余额 =
100
- 设备 A :
+20
→ 余额 =120
- 设备 B :
-20
→ 余额 =80
同步时,到底该保留哪个值?
如果简单地先后应用两个更新,后一个会覆盖前一个------导致用户数据丢失。
大多数系统要求开发者手动编写冲突解决逻辑,但这既容易出错,又难以维护。
解法:CRDT(无冲突复制数据类型)
正确的做法是使用 CRDT(Conflict-Free Replicated Data Types)。
CRDT 能保证两个关键性质:
- 可交换性(Commutativity):应用顺序不影响结果
- 幂等性(Idempotence):重复应用同一变更无副作用
这意味着你可以以任意顺序、甚至多次应用消息,所有设备最终仍会收敛到相同状态。
最简单的 CRDT 策略之一是 "最后写入胜出"(Last-Write-Wins, LWW):
- 每次更新都附带一个时间戳(物理或逻辑)
- 当多个设备修改同一字段时,时间戳最新的更新胜出
例如:
- 设备 A :余额 =
120
,时间戳10:00:00
- 设备 B :余额 =
80
,时间戳10:00:02
同步后,系统保留 80
,因为它写入得更晚。
但请注意 :LWW 对于"余额"这类需要累积语义的场景并不理想。理想方案应使用 PN-Counter (正负计数器)等更高级的 CRDT,分别记录增减操作,从而在合并时得到
100 + 20 - 20 = 100
的正确结果。LWW 仅适用于"覆盖型"字段(如用户昵称、设置项等)。
为什么 SQLite 是理想选择?
构建本地优先应用时,你需要一个可靠、轻量、无处不在的本地数据库。SQLite 正是不二之选:久经考验、资源占用小、全平台支持。
正因如此,我们把本地优先框架实现为一个 SQLite 扩展。
我们的简化方案如下:
- 每次变更都作为一条消息存入
messages
表,包含:timestamp
(来自 HLC)dataset
(表名)row
(编码后的主键)column
value
应用一条消息的逻辑非常简单:
- 查询当前值
- 如果传入的时间戳更新 → 覆盖
- 如果更旧 → 忽略
这样,无论同步顺序如何,所有设备最终都会收敛到一致状态。
为什么这很重要?
这种架构让同步变得简单而可靠:
- ✅ 可靠:即使离线数周,也不会丢失数据
- ✅ 确定性:最终状态始终一致
- ✅ 轻量:仅需一个小型 SQLite 扩展,无重型依赖
- ✅ 跨平台:支持 iOS、Android、macOS、Windows、Linux 和 WASM
给开发者的建议
- 别再用"请求队列"假装支持离线了
- 拥抱 最终一致性
- 使用经过验证的分布式系统技术:HLC 和 CRDT
- 保持轻量,避免不必要的依赖
结果?你将得到一个秒开、离线可用、默认保护隐私的应用------而无需传统客户端-服务器同步的复杂性。
如果你需要一个生产级、跨平台、真正离线优先的 SQLite 同步引擎,欢迎试用我们开源的 SQLite-Sync 扩展。
读者反馈节选(原文附录)
"HLC 的例子似乎需要设备间通信来同步时钟或计数器。如果设备长期离线,B 的 HLC 无法被 A '纠正',这是否与'支持数周离线'矛盾?"
答:不矛盾。HLC 在离线期间各自独立增长;联网后通过消息交换自动对齐时间戳。所有本地变更始终被保存,同步时按 HLC 排序合并,因此不会丢数据。
"如果某设备时钟错误(比如快了一年),会不会导致所有设备被'带偏'?"
答:HLC 会取 max(本地时间, 收到的时间戳),所以确实可能被"拉高"。但后续事件会依赖计数器区分顺序。更健壮的实现可加入设备 ID 作为 tie-breaker(决胜字段)。
"LWW 处理余额变更太弱了,应该用 PN-Counter。"
答:完全正确!LWW 仅适用于非累积型数据。对于计数、集合、文本等,应选用对应 CRDT 类型。我们正在探索如何在 SQLite 中高效存储多种 CRDT。
原文作者:Marco Bambini
中文翻译仅供参考,技术细节请以原文为准