本地优先不是离线兜底:CRDT 改变了前端对「数据」的假设

大多数应用把数据库放在服务器,前端只是它的一个远程终端。
本地优先把这句话反过来:数据先属于这台设备,同步是后来的事。

一、两种模型,两种前端

服务器优先(现在 95% 的应用):

1
用户操作 → API 请求 → 服务器校验 → 写库 → 返回结果 → 前端更新 UI

每个操作都是网络往返。断网就没有应用,只有错误提示。乐观更新是个补丁——先改 UI,失败了再回滚,回滚时的状态冲突通常没人处理。

本地优先:

1
用户操作 → 写本地库(立即) → UI 更新 → 后台同步 → 冲突自动合并

写入是同步的、瞬时的、离线的。网络只影响「数据什么时候到别的设备」,不影响「能不能用」。

这个改动看起来只是把 DB 挪到浏览器,实际影响的是前端代码的组织方式:所有异步等待、加载状态、失败重试,从业务逻辑里消失了;取而代之的是一个同步 API + 一套同步引擎。

二、CRDT 是让「合并」不打架的办法

多人协作的核心问题:两台设备都改了同一条数据,谁赢?

悲观方案(锁 / 服务端仲裁):先请求修改权,拿到才改。需要网络,延迟高,离线不可用。

乐观方案(last-write-wins):用时间戳,晚的覆盖早的。简单,但会丢数据——两个人同时改不同字段,一个会被整条覆盖掉。

CRDT(无冲突复制数据类型):让数据结构本身保证「任意顺序合并结果一致」。不是解决冲突,是让冲突不存在。

几个常见类型的直觉:

类型 合并语义 适合
G-Counter 各副本只增,合并取各副本最大值之和 计数(浏览量、点赞)
PN-Counter 增/减分开计数 可减计数(库存、余额)
LWW-Register 带时间戳的单值 配置项、昵称
OR-Set 添加与删除分别打标记 标签、成员列表
RGA / YATA 按字符插入位置定序 富文本(Yjs 的底层)
Fugue / Movable List 更优的插入定序 列表、块编辑器

「合并结果一致」这个性质叫交换律 + 结合律 + 幂等:无论消息以什么顺序、重复几次到达,最终状态相同。这就是为什么同步可以「随便传」——不需要严格顺序,不需要恰好一次。

三、代价一:删除比添加难

OR-Set 的实现会让你第一次真正意识到分布式系统的复杂度:

删除一个元素不能直接删,因为另一台设备可能正在添加同名元素,直接删会让那个合法的添加被吞掉。所以删除记成一个「墓碑」(tombstone),标明「这个特定实例被删了」,合并时按实例判断。

后果是数据只增不减:编辑一篇文档一千次,历史操作记录都在。必须靠压缩(compaction / GC)定期清理,而压缩又需要知道「所有副本都见过这条记录了」——这本身需要一次同步确认。

CRDT 用空间换一致性。省下来的协调成本,变成了存储与压缩的复杂度。

四、代价二:不能有「唯一约束」

这是从 SQL 迁过来最不适应的地方。

CRDT 里没有「用户名唯一」这种全局约束——因为约束需要全局视图,而每个副本只知道自己。你能做的只有:

  • 本地检查(可能冲突)
  • 同步后再检查(事后冲突,需要 UI 提示用户改)
  • 或者把这个字段放回服务器仲裁

所以真实架构大多是混合的:协作型内容用 CRDT,账号/权限/计费这类强一致数据仍走服务端。别指望一套方案吃全部。

五、代价三:前端要重新学「状态」

服务器优先的应用里,前端状态是「服务器状态的缓存」:loading、error、data,三态齐全,用 TanStack Query 或 SWR 管得很好。

本地优先里,状态是唯一的真相,没有 loading(本地写入永远是即时的),只有「同步状态」这一层附加信息:已同步 / 待同步 / 有冲突。UI 上要展示的是同步状态,而不是加载状态。

这听起来是简化,但工程上是一个范式转换:你不再能靠「重新请求一次」来修复不一致。本地写坏了就是坏了,得靠操作历史回滚。

六、什么时候值得上

适合:

  • 编辑器、笔记、白板、看板这类高频连续编辑的场景(延迟敏感、离线刚需)
  • 多设备个人应用(手机记一半、电脑接着改)
  • 弱网/断网环境(现场作业、内网工具、飞机上)

不适合:

  • 强一致性要求(支付、库存扣减、票务)
  • 数据量巨大且只读为主(直接用缓存/CDN 更省)
  • 协作人数少且在线状态稳定(普通 REST + 乐观更新就够)

一个更朴素的判据:如果你的用户会在地铁上用它,值得;如果只在办公室用,别引入这套复杂度。

七、落地路径

  1. 先换存储层,不换同步层。 用 IndexedDB + 一个薄封装(Dexie、idb)把数据落本地,API 全部改成同步读写本地 + 后台推送。这一步就能拿到「离线可用」的收益,风险最低。
  2. 再引入 CRDT,只给需要协作的字段。 不要把整个 schema 都变成 CRDT 文档——那会让查询能力消失(CRDT 结构不适合做复杂查询,通常需要再派生出可查询的视图)。
  3. 同步引擎最后接。 自建 WebSocket 或用电动的同步服务都行,但一定要先想清楚鉴权与冲突可视化:用户看到两份内容打架时,界面得能解释发生了什么。

八、一句话

本地优先不是「离线也能用」这个功能,它是一种关于数据归属权的假设:数据先属于用户手里的设备,服务器是同步的中转。

这个假设一旦成立,前端就不再是服务器的一个视图,而是数据的主人之一。CRDT 是让这件事在数学上可行的工具——但它把复杂度从「网络请求」搬到了「合并与压缩」,并没有让它消失。