Room 离线优先同步:本地事务、Outbox 与旧响应覆盖新编辑的竞态


用户离线把标题改成 A,应用显示“已保存”;网络恢复后开始上传 A,这时用户又改成 B。上传 A 的响应稍后返回,如果同步代码直接把服务端返回行写回数据库,刚输入的 B 就可能消失。这个问题与 Worker 是否重试无关,核心是数据版本和确认条件没有建模。

把“已保存”分成两个承诺

离线优先通常让界面以本地数据源为读取入口,再由同步过程协调网络数据;冲突需要明确解决策略。Android 的离线优先架构说明介绍了这一结构。

对产品而言,“本机已保存”和“服务器已确认”是不同状态。前者可以在 Room 事务提交后成立,后者必须等远端确认。用一个布尔值 synced 勉强能表示简单场景,但很难同时表达本地待上传、服务端冲突、认证阻塞和删除待确认。

本文采用一个有意收窄的设计:笔记编辑发送绝对状态快照,不发送“标题末尾增加一个字”这种增量命令;同一笔记按顺序同步;一个工作进程负责发送。多设备竞争通过服务器版本检查处理,而不试图只靠本机锁解决。

本地事务连接业务表与待发送表

业务表至少保存 id、title、localRevision、serverVersion、dirty、deleted。Outbox 保存 operationId、noteId、localRevision、titleSnapshot、deleted、baseServerVersion。操作 ID 在生成记录时固定,不能每次重试重新生成。

如果先更新业务表,再单独插入 Outbox,进程可能在两步之间死亡:界面保留了修改,后台却永远不知道需要上传。反过来先写 Outbox 也可能出现发送了一个本地没有展示的状态。两步应在同一个本地数据库事务中提交。

下面是 Kotlin、Room 2.6 及以上同类 API 的 DAO 核心示意,假设已配置 room-runtime、协程支持和 KSP;实体声明中的字段与上文一致,完整数据库注册由项目提供。示例未在 Android 工程编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Dao
abstract class NotesDao {
@Query("SELECT * FROM notes WHERE id = :id")
abstract suspend fun find(id: String): NoteEntity?

@Update
abstract suspend fun update(note: NoteEntity)

@Insert
abstract suspend fun insertOperation(op: OutboxEntity)

@Transaction
open suspend fun edit(id: String, title: String, operationId: String) {
val old = requireNotNull(find(id))
val next = old.localRevision + 1
update(old.copy(title = title, localRevision = next, dirty = true))
insertOperation(OutboxEntity(
operationId = operationId,
noteId = id,
localRevision = next,
titleSnapshot = title,
deleted = false,
baseServerVersion = old.serverVersion
))
}

@Query("""
UPDATE notes SET dirty = 0, serverVersion = :serverVersion
WHERE id = :id AND localRevision = :sentRevision
""")
abstract suspend fun markCleanIfCurrent(
id: String, sentRevision: Long, serverVersion: Long
): Int

@Query("""
UPDATE outbox SET baseServerVersion = :serverVersion
WHERE noteId = :id AND localRevision > :sentRevision
""")
abstract suspend fun rebaseSuccessors(
id: String, sentRevision: Long, serverVersion: Long
)

@Query("DELETE FROM outbox WHERE operationId = :operationId")
abstract suspend fun deleteOperation(operationId: String)

@Transaction
open suspend fun acknowledge(op: OutboxEntity, serverVersion: Long) {
markCleanIfCurrent(op.noteId, op.localRevision, serverVersion)
rebaseSuccessors(op.noteId, op.localRevision, serverVersion)
deleteOperation(op.operationId)
}
}

Room 事务方法的异常和取消会影响事务完成,相关挂起事务也可通过 withTransaction 表达。Room 事务 API给出了边界。网络调用应在事务外执行,避免把数据库写锁保持到网络超时。

这里 markCleanIfCurrent 返回 0 不是失败:它可能表示用户已产生更新的本地版本,旧响应只能确认旧操作,不能清掉新操作的 dirty。示例也没有把响应的旧 title 重新覆盖到业务表。

为什么确认之后还要调整后继操作

假设本地第 7、8 次编辑都是在远端版本 12 上创建的。第 7 次上传成功后,远端变成 13。如果第 8 次仍携带 baseVersion=12,服务器会把同一设备顺序编辑误判为冲突。

因此需要在确认第 7 次时,把尚未发送的后继快照调整到新基线。上面的 rebaseSuccessors 只在“单笔记串行、后继尚未发出”的前提下成立。若第 8 次已经并发发送,就不能再改它的请求语义;这也是为什么发送器必须按笔记串行化。

多进程或多个 Worker 可能同时运行时,还需要数据库租约或原子 claim 机制,记录执行者和到期时间,避免两个发送器都认为自己拥有队首。仅用进程内 Mutex 无法协调进程崩溃后的恢复。

本地版本不能解决跨设备冲突

localRevision 只反映一个本地副本的修改顺序。另一台手机也可能有 revision=8,它们不具有全局可比较性。服务器应检查 baseServerVersion:版本相符才提交,并在同一原子操作里产生新 serverVersion;版本不符返回当前远端快照或冲突标识。

笔记正文可以让用户选择保留本地、保留远端或进入合并界面;像开关配置这样的简单数据也可以采用明确的最后写入策略,但应由服务器序列或可靠协议决定,不能默认手机墙上时钟总是准确。

删除还需要 tombstone。若本地删除行后直接移除所有记录,远端旧快照重新拉取时可能把它“复活”。删除操作应保留到服务器确认,并结合拉取游标与删除保留周期设计清理规则。

原子性只有本地范围,恢复必须允许重复

远端提交成功、本地 acknowledge 尚未执行时进程死亡,是无法用 Room 本地事务覆盖的窗口。恢复后同一 operationId 会再发一次,服务端必须返回相同操作的结果,而不是再次执行。服务端还要拒绝相同 ID 对应不同负载,避免应用错误被“去重成功”掩盖。

本地表可以用主键和唯一索引落实操作 ID、笔记与本地版本等约束,而不是先查再插。Room 实体与索引说明提供了声明方式。是否允许一个版本对应多个操作,需和编辑事务的契约保持一致。

合并待办可以减少流量,但要保留语义

如果用户在一秒内把标题从 A 改到 B 再改到 C,而三条操作都尚未发送,绝对状态快照通常只需上传 C。合并可以减少请求,但不能随意套在所有操作上:余额加十、追加一条审计事件和发送一封邮件,合并后可能改变业务效果。

对本文的笔记快照,合并应发生在数据库事务中,只选择明确尚未领取发送的后继记录。已经进入网络调用的操作不能被原地修改,否则同一个 operationId 可能对应两份不同内容,破坏服务端去重契约。发送器应读取不可变的请求快照,并把“待发送”和“已领取”区分清楚。

同步拉取也要遵守本地脏状态边界。远端返回同一笔记的新版本时,若本地没有待提交编辑,可以直接更新;若存在本地修改,应进入合并或冲突流程,而不是用一条无条件 upsert 盖掉本地行。把拉取游标前移与应用这一批变更放在同一个本地事务中,可以避免“游标已经前移但数据没写完”造成永久遗漏。

还要为账号切换划分数据作用域。仅用 noteId 作为主键,若两个账号的 ID 空间可能重叠,就存在读错或确认错数据的风险。数据库键、Outbox 查询、服务器幂等作用域与租约都应包含一致的账号或租户边界;注销不能只删除屏幕状态,却让旧账号 Worker 继续发送新会话里的数据。

有效的验证需要卡住时间窗口

测试时用可暂停的假服务端:收到版本 7 的请求后先不返回,执行版本 8 的本地编辑,再放行版本 7 响应。断言标题仍是版本 8,dirty 仍为 true,版本 7 Outbox 被移除,版本 8 的基线调整正确。

再分别在本地编辑事务中、远端提交后、本地确认前模拟异常或进程终止。恢复后检查是否存在“有修改却无 Outbox”、重复业务对象、已删除笔记复活。对跨设备同时修改,确认出现可解释的冲突状态,而不是静默覆盖。

最后验证数据库迁移:旧用户已有数据时新增版本列和 Outbox,默认值必须有明确含义。还应测试磁盘写入失败,确认界面不会在事务回滚后仍提示本机已保存;成功提示必须来自提交结果。本文是同步协议的设计与核心片段,没有执行数据库迁移或多设备实验;上线前需要对真实后端实现这些确定性的时序测试。


 上一篇
Compose 性能分析:重组次数、稳定性与掉帧之间到底有什么关系 Compose 性能分析:重组次数、稳定性与掉帧之间到底有什么关系
重组次数多不必然意味着卡顿,稳定性标记也不能代替测量。本文以可筛选笔记列表为例,分析状态读取位置、列表身份和强跳过的行为,用确定性工作负载建立从系统 Trace 到代码修改的性能证据链。
2026-10-18
下一篇 
HttpClient 长连接不是永久连接:DNS、连接池与安全重试的工程边界 HttpClient 长连接不是永久连接:DNS、连接池与安全重试的工程边界
复用 HttpClient 可以减少连接开销,却不能自动解决 DNS 切换和业务重复提交。本文从连接池的生命周期出发,设计超时、并发和重试预算,解释安全方法与幂等的区别,并讨论响应流和 Cookie 隔离问题。
2026-10-18
  目录