Android 从零到一 Room 并发写入与事务一致性:从数据竞争到可靠落地
Room 把 SQLite 的表、查询和事务包装成了类型安全的 API,但类型安全并不等于并发安全。当网络同步、用户操作和后台任务同时修改本地数据时,重复记录、更新丢失、主从表不一致等问题仍然可能发生。本文从 Room 的线程模型讲起,结合账户扣减与远端同步场景,给出可验证的事务和并发治理方案。
Room 解决了什么,没有解决什么
Room 主要负责三件事:在编译期检查 SQL、把查询结果映射成 Kotlin 对象,以及提供数据库迁移和响应式查询能力。它能减少手写 SQLite 代码,却不会自动理解业务操作的原子性。
下面两次 DAO 调用在代码上挨得很近,但默认是两个独立事务:
kotlin
val account = accountDao.getById(accountId)
accountDao.update(account.copy(balance = account.balance - amount))
如果两个协程同时读取到相同余额,它们会基于同一个旧值计算并写回。最终数据库只保留其中一次扣减,这就是典型的"更新丢失"。suspend 只表示调用可以挂起,不会让"读取、计算、写入"自动成为不可分割的操作。
先理解 Room 的执行模型
Room 会把查询和事务调度到自己的执行器。普通 suspend DAO 方法不会阻塞主线程,但多个调用依然可能交错执行。Flow 查询则会在关联表发生失效通知后重新查询,它观察的是提交后的数据库状态,而不是某个业务流程的中间步骤。
SQLite 同一时刻只有一个写事务可以真正推进,但这并不能消除数据竞争。竞争通常发生在事务之外的"先读后写"窗口:多个任务依次读到旧值,再排队写入各自计算出的结果。
因此并发治理的核心不是给所有代码加锁,而是回答三个问题:
- 哪些数据库操作必须一起成功或一起失败?
- 哪些判断必须和写入处于同一个事务快照?
- 多个来源写入同一实体时,冲突应该覆盖、拒绝还是合并?
用原子 SQL 代替先读后写
对于计数、余额、库存等字段,优先让判断和更新在一条 SQL 中完成:
kotlin
@Dao
interface AccountDao {
@Query(
"""
UPDATE account
SET balance = balance - :amount,
updated_at = :updatedAt
WHERE id = :accountId
AND balance >= :amount
"""
)
suspend fun deduct(
accountId: Long,
amount: Long,
updatedAt: Long
): Int
}
返回值是受影响的行数。返回 1 表示扣减成功,返回 0 可能是账户不存在,也可能是余额不足,调用方应把它转换为明确的业务结果:
kotlin
sealed interface DeductResult {
data object Success : DeductResult
data object InsufficientBalance : DeductResult
}
suspend fun deduct(accountId: Long, amount: Long): DeductResult {
require(amount > 0)
val changed = accountDao.deduct(accountId, amount, clock.millis())
return if (changed == 1) {
DeductResult.Success
} else {
DeductResult.InsufficientBalance
}
}
这种写法缩短了竞争窗口,也避免把数据库中的旧状态搬到内存后再做判断。
跨表操作必须建立事务边界
真实业务往往不只更新一张表。扣减余额后还需要写入流水,如果任意一步失败,两张表必须同时回滚。
Room 推荐使用 RoomDatabase.withTransaction 在仓库层组织跨 DAO 事务:
kotlin
class AccountRepository(
private val database: AppDatabase,
private val accountDao: AccountDao,
private val ledgerDao: LedgerDao,
private val clock: Clock
) {
suspend fun deductWithLedger(
accountId: Long,
requestId: String,
amount: Long
): DeductResult = database.withTransaction {
val changed = accountDao.deduct(accountId, amount, clock.millis())
if (changed == 0) {
return@withTransaction DeductResult.InsufficientBalance
}
ledgerDao.insert(
LedgerEntity(
requestId = requestId,
accountId = accountId,
amount = -amount,
createdAt = clock.millis()
)
)
DeductResult.Success
}
}
事务边界应放在掌握完整业务语义的层,而不是强行塞进某个单表 DAO。这样既能覆盖多个 DAO,又不会让数据库接口依赖上层领域对象。
事务内部不要执行网络请求、文件读写或长时间计算。SQLite 写事务持有越久,其他写任务等待越久,超时和卡顿风险也越高。常见做法是先完成本地事务,再由独立同步流程处理远端提交。
用唯一约束实现幂等
移动端请求很容易被重复执行:用户连续点击、WorkManager 重试、进程重启后恢复任务,都可能再次提交同一个业务动作。仅在内存中设置 isLoading 无法覆盖这些情况。
为业务请求分配稳定的 requestId,并在数据库建立唯一约束:
kotlin
@Entity(
tableName = "ledger",
indices = [Index(value = ["request_id"], unique = true)]
)
data class LedgerEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "request_id") val requestId: String,
@ColumnInfo(name = "account_id") val accountId: Long,
val amount: Long,
@ColumnInfo(name = "created_at") val createdAt: Long
)
唯一约束是数据库层的最终防线。即使两个任务同时检查"记录不存在",也只有一个插入可以成功。业务层可以捕获 SQLiteConstraintException,查询已有流水并返回已完成结果。
不要把 OnConflictStrategy.REPLACE 当成通用去重方案。SQLite 的 REPLACE 本质上可能删除旧行再插入新行,会改变自增主键,并可能影响外键关系。对于业务幂等,ABORT 配合唯一键通常更清晰;确实需要忽略重复数据时,可以使用 IGNORE 并检查返回值。
多端同步需要明确冲突规则
远端同步把并发问题从同一进程扩大到了多个设备。最简单的"服务端数据覆盖本地数据"可能抹掉尚未上传的用户修改。可靠方案通常会给实体增加版本字段:
kotlin
@Entity(tableName = "note")
data class NoteEntity(
@PrimaryKey val id: String,
val content: String,
val version: Long,
val syncState: SyncState,
val updatedAt: Long
)
写入时把当前版本放进条件:
kotlin
@Query(
"""
UPDATE note
SET content = :content,
version = version + 1,
syncState = :pending,
updatedAt = :updatedAt
WHERE id = :id AND version = :expectedVersion
"""
)
suspend fun updateIfVersionMatches(
id: String,
content: String,
expectedVersion: Long,
pending: SyncState,
updatedAt: Long
): Int
受影响行数为 0 时说明版本已经变化,调用方必须重新读取并执行冲突策略。文本可以提示用户选择,收藏状态可以采用最后写入获胜,计数类数据则更适合提交增量。冲突规则属于业务设计,数据库只能帮助我们可靠地发现冲突。
Flow 观察与事务提交的配合
一个事务中连续修改多张被观察的表时,Room 会在事务提交后统一发送失效通知。页面通常不会看到"主表已更新、明细表还没更新"的中间状态,这也是把相关写入纳入同一事务的重要收益。
不过,多个 Flow 分别在 ViewModel 中收集后再拼装,仍可能因调度时序产生短暂的不一致。优先让数据库通过关系查询或组合查询返回一个完整快照:
kotlin
data class AccountOverview(
@Embedded val account: AccountEntity,
@Relation(
parentColumn = "id",
entityColumn = "account_id"
)
val ledgers: List<LedgerEntity>
)
@Transaction
@Query("SELECT * FROM account WHERE id = :accountId")
fun observeOverview(accountId: Long): Flow<AccountOverview?>
这里的 @Transaction 保证 Room 在一次一致的读取事务中完成主表和关联表查询。它解决的是多次读取之间的一致性,与写事务关注的问题不同。
协程 Mutex 什么时候有用
Mutex 适合保护进程内、同一实例共享的复杂内存状态,或者减少同类任务重复进入数据库。但它不能替代数据库约束:
- 多进程应用中的另一个进程不会共享同一把锁。
- 应用重启后锁状态消失,恢复任务仍可能重复执行。
- 服务端和其他设备完全不受本地锁约束。
如果使用 Mutex,应把它看作降低竞争和节省资源的优化手段。数据正确性仍应由事务、条件更新、唯一索引和服务端幂等共同保证。
失败重试与事务的关系
数据库锁竞争、磁盘空间不足和进程终止都可能让操作失败。重试机制必须建立在幂等语义上,否则每次重试都可能重复扣减或重复插入。
推荐把同步任务拆成可恢复状态:
text
PENDING -> UPLOADING -> SYNCED
-> FAILED
状态切换使用条件更新,例如只有 PENDING 才能变成 UPLOADING。任务启动时还要回收超时停留在 UPLOADING 的记录,避免进程终止后永远无法重试。服务端接口也应接收同一个 requestId,让端到端重试具备幂等性。
如何测试并发正确性
只验证一次成功流程不足以发现竞争问题。可以在 instrumented test 中使用真实 Room 数据库,同时启动多个协程争抢同一份数据:
kotlin
@Test
fun concurrentDeductNeverOverdraws() = runTest {
seedAccount(balance = 100)
val results = (1..20).map {
async(Dispatchers.IO) {
repository.deductWithLedger(
accountId = 1,
requestId = "request-$it",
amount = 10
)
}
}.awaitAll()
assertEquals(10, results.count { it is DeductResult.Success })
assertEquals(0, accountDao.getById(1).balance)
assertEquals(10, ledgerDao.count())
}
还应覆盖这些场景:相同 requestId 并发提交、流水插入失败时余额回滚、旧版本更新被拒绝、进程恢复后同步任务再次执行。测试关注最终不变量,而不是依赖协程恰好以某种顺序运行。
排查线上异常的检查清单
遇到重复数据、状态回退或偶发不一致时,可以按以下顺序定位:
- 查找"查询后再更新"的代码,确认判断与写入是否原子。
- 检查跨表写入是否位于同一个
withTransaction中。 - 检查业务唯一键是否真的建立了数据库唯一索引。
- 确认冲突策略,尤其留意
REPLACE带来的删除再插入语义。 - 检查网络调用或耗时计算是否占用了数据库事务。
- 对照 WorkManager 和接口重试日志,确认请求标识是否稳定。
- 为关键更新记录受影响行数、版本号和请求标识,避免只记录异常堆栈。
总结
Room 的并发可靠性建立在明确的数据不变量上。单字段变更优先使用条件更新,跨表业务使用短事务,重复操作依靠唯一约束和稳定请求标识,多端修改通过版本号暴露冲突。Flow、协程和 Mutex 可以改善调用方式与执行效率,但事务和数据库约束才是数据正确性的底座。
当这些规则进入表结构、DAO 返回值和自动化测试后,并发问题就不再依赖"调用顺序应该没问题"的假设,而会变成能够发现、拒绝和恢复的确定行为。