Room 把 SQLite 的表、查询和事务包装成了类型安全的 API,但类型安全并不等于并发安全。当网络同步、用户操作和后台任务同时修改本地数据时,重复记录、更新丢失、主从表不一致等问题仍然可能发生。本文从 Room 的线程模型讲起,结合账户扣减与远端同步场景,给出可验证的事务和并发治理方案。
Room 主要负责三件事:在编译期检查 SQL、把查询结果映射成 Kotlin 对象,以及提供数据库迁移和响应式查询能力。它能减少手写 SQLite 代码,却不会自动理解业务操作的原子性。
下面两次 DAO 调用在代码上挨得很近,但默认是两个独立事务:
val account = accountDao.getById(accountId)
accountDao.update(account.copy(balance = account.balance - amount))如果两个协程同时读取到相同余额,它们会基于同一个旧值计算并写回。最终数据库只保留其中一次扣减,这就是典型的“更新丢失”。`suspend` 只表示调用可以挂起,不会让“读取、计算、写入”自动成为不可分割的操作。
Room 会把查询和事务调度到自己的执行器。普通 `suspend` DAO 方法不会阻塞主线程,但多个调用依然可能交错执行。`Flow` 查询则会在关联表发生失效通知后重新查询,它观察的是提交后的数据库状态,而不是某个业务流程的中间步骤。
SQLite 同一时刻只有一个写事务可以真正推进,但这并不能消除数据竞争。竞争通常发生在事务之外的“先读后写”窗口:多个任务依次读到旧值,再排队写入各自计算出的结果。
因此并发治理的核心不是给所有代码加锁,而是回答三个问题:
对于计数、余额、库存等字段,优先让判断和更新在一条 SQL 中完成:
@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` 可能是账户不存在,也可能是余额不足,调用方应把它转换为明确的业务结果:
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 事务:
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`,并在数据库建立唯一约束:
@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` 并检查返回值。
远端同步把并发问题从同一进程扩大到了多个设备。最简单的“服务端数据覆盖本地数据”可能抹掉尚未上传的用户修改。可靠方案通常会给实体增加版本字段:
@Entity(tableName = "note")
data class NoteEntity(
@PrimaryKey val id: String,
val content: String,
val version: Long,
val syncState: SyncState,
val updatedAt: Long
)写入时把当前版本放进条件:
@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` 时说明版本已经变化,调用方必须重新读取并执行冲突策略。文本可以提示用户选择,收藏状态可以采用最后写入获胜,计数类数据则更适合提交增量。冲突规则属于业务设计,数据库只能帮助我们可靠地发现冲突。
一个事务中连续修改多张被观察的表时,Room 会在事务提交后统一发送失效通知。页面通常不会看到“主表已更新、明细表还没更新”的中间状态,这也是把相关写入纳入同一事务的重要收益。
不过,多个 Flow 分别在 ViewModel 中收集后再拼装,仍可能因调度时序产生短暂的不一致。优先让数据库通过关系查询或组合查询返回一个完整快照:
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`,应把它看作降低竞争和节省资源的优化手段。数据正确性仍应由事务、条件更新、唯一索引和服务端幂等共同保证。
数据库锁竞争、磁盘空间不足和进程终止都可能让操作失败。重试机制必须建立在幂等语义上,否则每次重试都可能重复扣减或重复插入。
推荐把同步任务拆成可恢复状态:
PENDING -> UPLOADING -> SYNCED
-> FAILED状态切换使用条件更新,例如只有 `PENDING` 才能变成 `UPLOADING`。任务启动时还要回收超时停留在 `UPLOADING` 的记录,避免进程终止后永远无法重试。服务端接口也应接收同一个 `requestId`,让端到端重试具备幂等性。
只验证一次成功流程不足以发现竞争问题。可以在 instrumented test 中使用真实 Room 数据库,同时启动多个协程争抢同一份数据:
@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` 并发提交、流水插入失败时余额回滚、旧版本更新被拒绝、进程恢复后同步任务再次执行。测试关注最终不变量,而不是依赖协程恰好以某种顺序运行。
遇到重复数据、状态回退或偶发不一致时,可以按以下顺序定位:
Room 的并发可靠性建立在明确的数据不变量上。单字段变更优先使用条件更新,跨表业务使用短事务,重复操作依靠唯一约束和稳定请求标识,多端修改通过版本号暴露冲突。`Flow`、协程和 `Mutex` 可以改善调用方式与执行效率,但事务和数据库约束才是数据正确性的底座。
当这些规则进入表结构、DAO 返回值和自动化测试后,并发问题就不再依赖“调用顺序应该没问题”的假设,而会变成能够发现、拒绝和恢复的确定行为。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。