本地优先不是离线兜底:CRDT 改变了前端对「数据」的假设
本地优先不是离线兜底: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 + 乐观更新就够)
一个更朴素的判据:如果你的用户会在地铁上用它,值得;如果只在办公室用,别引入这套复杂度。
七、落地路径
- 先换存储层,不换同步层。 用 IndexedDB + 一个薄封装(Dexie、idb)把数据落本地,API 全部改成同步读写本地 + 后台推送。这一步就能拿到「离线可用」的收益,风险最低。
- 再引入 CRDT,只给需要协作的字段。 不要把整个 schema 都变成 CRDT 文档——那会让查询能力消失(CRDT 结构不适合做复杂查询,通常需要再派生出可查询的视图)。
- 同步引擎最后接。 自建 WebSocket 或用电动的同步服务都行,但一定要先想清楚鉴权与冲突可视化:用户看到两份内容打架时,界面得能解释发生了什么。
八、一句话
本地优先不是「离线也能用」这个功能,它是一种关于数据归属权的假设:数据先属于用户手里的设备,服务器是同步的中转。
这个假设一旦成立,前端就不再是服务器的一个视图,而是数据的主人之一。CRDT 是让这件事在数学上可行的工具——但它把复杂度从「网络请求」搬到了「合并与压缩」,并没有让它消失。
- Yjs:yjs.dev
- Automerge:automerge.org
- 本地优先软件社区:localfirstweb.dev
评论