数据模型设计:面向离线优先
做离线优先架构,第一个坑往往不是网络同步,而是数据模型本身。我记得几年前接手一个笔记应用,团队一上来就用传统 RESTful 范式设计模型——每条笔记对应用户、带时间戳、关系复杂。结果呢?离线写入时全是外键约束冲突,同步冲突率直接飙到 30% 以上。后来全部重构,才算是摸清了离线优先的数据模型长什么样。
说白了,你的数据模型必须生来能容忍“多个副本各自为政”。服务器不在场,客户端也能愉快地创建、修改、删除。这跟传统在线模型有本质区别。下面我把核心设计原则拆开讲。
核心原则:本地优先 + 幂等操作
我个人的习惯是先问一个问题:这条数据能否在没有服务器的情况下完全决定自己的样子?如果能,那就是离线友好的。
具体落地要满足三点:
- 主键不依赖服务器——用 UUID 或 Snowflake ID,别用自增 ID。你在飞机上新建一条笔记,不可能等服务器给你一个 ID 再写。
- 每个实体自带版本号——用单调递增的向量时钟或 LWW 寄存器,告诉别人“我这条数据是最新的”。
- 写操作是幂等的——同一个操作发两次,结果不该变。比如“设置标题为 X”是幂等的,“标题追加字符”就不是。
📌 一条经验法则:任何能在本地单独创建、不依赖其他实体存在的实体,才是离线优先的候选。别让 User 和 Note 强耦合,否则离线删用户时,笔记就得跟着一起炸。
实体设计示例:任务管理
拿最常见的「任务」实体来举例。我会这么定义:
// 离线优先的任务实体(Kotlin + Room)
@Entity(
tableName = "tasks",
indices = [Index(value = ["sync_status"], orders = [IndexOrder.ASC])]
)
data class TaskEntity(
@PrimaryKey
val id: String, // UUID,客户端生成
val title: String,
val completed: Boolean = false,
val version: Long = 0, // 单调递增版本号
val deleted: Boolean = false, // 软删除标记
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = createdAt,
val syncStatus: SyncStatus = SyncStatus.PENDING
)
enum class SyncStatus {
SYNCED, // 已同步到服务器
PENDING, // 待上传
CONFLICTING // 冲突未解决
}
注意到几个关键点:
- 主键
id是字符串型 UUID,绝不依赖自增。你在离线时创建的笔记,ID 就是确定的。 version字段是冲突检测的核心。每次修改都递增它,服务器在合并时只用比较版本号大小。deleted是软删除。我遇到过真的在本地物理删数据,结果同步时导致服务器数据丢失的情况——后来改成软删除 + 墓碑模式才稳住。syncStatus枚举,用于区分数据是已同步、待上传还是冲突。UI 可以根据它显示不同的状态指示器。
冲突解决策略对比
数据模型只解决了“能存”,接下来是“怎么合”。我整理过一张表,基本覆盖了主流场景:
| 策略 | 适用场景 | 缺点 |
|---|---|---|
| Last-Write-Wins (LWW) | 日志、笔记正文等简单字段 | 会丢数据,不适合协作编辑 |
| CRDT(无冲突复制数据类型) | 计数器、集合、文本(OT/CRDT) | 实现复杂,存储开销大 |
| 手动合并(用户选择) | 表单、审批流等业务敏感场景 | 用户体验差,需做 UI 提示 |
| 三路合并 | 结构化数据(JSON Patch 或类似) | 需要存储基线版本,够复杂 |
我个人最常用 LWW + 手动合并的组合。比如一个简单的笔记应用:标题、正文用 LWW,谁版本大谁说了算;但如果涉及“是否完成”这种状态,我会保留冲突标记,等用户上线后选择。
💡 小技巧:如果你用 Room 或 SQLite,可以在 SQL 里用 UPDATE ... WHERE version = ? 做乐观锁。更新时检查版本号是否匹配,不匹配说明冲突发生,扔到冲突队列里。
离线优先数据模型结构图
下面这张图展示了我认为比较完整的数据模型设计思路,从实体定义到冲突决策流:
避坑指南:我踩过的几个雷
我曾经在一个协同白板项目里,直接拿 SQLite 自增 ID 做主键。结果呢?两个离线客户端同时画了元素,上传后 ID 冲突,整个画布乱成一锅粥。从此以后,我所有新的离线项目都强制用 UUID。
- 别用服务器时间戳做版本号——因为离线时拿不到服务器时间。改用单调递增的本地计数器或 Lamport 时钟。
- 小心“幽灵关联”——比如任务关联了分类,分类离线删了,任务还活着。一同步,任务就变成孤儿。我的做法是:关联关系也用软删除,并且允许引用不存在的父对象(外键设为 nullable)。
- 不要试图在模型层解决所有冲突——有些业务逻辑就该交给 UI 层。比如用户修改了任务标题,另一个设备改了截止日期,这两个字段实际上没有冲突,LWW 用各自版本分别合并即可。
⚠️ 重要:永远不要在本地物理删除数据,除非你确认服务器端也删除了。 建议统一使用软删除 + 墓碑记录(tombstone),这样冲突检测时还能知道你删的是什么。
小结一下
面向离线优先的数据模型,核心就三个字:独立、幂等、可收敛。设计阶段多花半小时定义好实体、版本和冲突策略,后期能省掉大量同步 bug 的排查时间。我个人的习惯是先把表的 DDL 写出来,再写同步逻辑,顺序别搞反。