数据模型设计:面向离线优先

做离线优先架构,第一个坑往往不是网络同步,而是数据模型本身。我记得几年前接手一个笔记应用,团队一上来就用传统 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 = ? 做乐观锁。更新时检查版本号是否匹配,不匹配说明冲突发生,扔到冲突队列里。

离线优先数据模型结构图

下面这张图展示了我认为比较完整的数据模型设计思路,从实体定义到冲突决策流:

离线优先数据模型设计结构 实体设计层 UUID 主键 版本号 + 软删除 本地时间戳 操作约束层 幂等写操作 无外键强依赖 增量更新 冲突决策层 策略选择 版本对比 合并/标记 本地写入流 直接写入本地 DB 状态标记为 PENDING 同步上传流 网络恢复后批量上传 附带当前版本号 冲突解决流 版本比对 + 策略执行 标记 CONFLICTING 本地存储层(Room / SQLite) 表结构:id, data, version, sync_status, deleted, updated_at 💡 所有写入必须经过本地 DB,网络只是附加能力 ——这就是“离线优先”的数据模型原则

避坑指南:我踩过的几个雷

我曾经在一个协同白板项目里,直接拿 SQLite 自增 ID 做主键。结果呢?两个离线客户端同时画了元素,上传后 ID 冲突,整个画布乱成一锅粥。从此以后,我所有新的离线项目都强制用 UUID。

  • 别用服务器时间戳做版本号——因为离线时拿不到服务器时间。改用单调递增的本地计数器或 Lamport 时钟。
  • 小心“幽灵关联”——比如任务关联了分类,分类离线删了,任务还活着。一同步,任务就变成孤儿。我的做法是:关联关系也用软删除,并且允许引用不存在的父对象(外键设为 nullable)。
  • 不要试图在模型层解决所有冲突——有些业务逻辑就该交给 UI 层。比如用户修改了任务标题,另一个设备改了截止日期,这两个字段实际上没有冲突,LWW 用各自版本分别合并即可。

⚠️ 重要:永远不要在本地物理删除数据,除非你确认服务器端也删除了。 建议统一使用软删除 + 墓碑记录(tombstone),这样冲突检测时还能知道你删的是什么。

小结一下

面向离线优先的数据模型,核心就三个字:独立、幂等、可收敛。设计阶段多花半小时定义好实体、版本和冲突策略,后期能省掉大量同步 bug 的排查时间。我个人的习惯是先把表的 DDL 写出来,再写同步逻辑,顺序别搞反。