Room 并发写入与事务一致性:从数据竞争到可靠落地

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 返回值和自动化测试后,并发问题就不再依赖"调用顺序应该没问题"的假设,而会变成能够发现、拒绝和恢复的确定行为。

相关推荐
天才熊猫君1 小时前
自动给所有 catch 块补上错误上报:从原理到落地
前端·javascript
朱涛的自习室1 小时前
Munk AI 桌面端「预告」
android·前端·人工智能
程序员包打听1 小时前
从 npx 到 moonx,moonbit 的野心与展望
前端·后端
laboratory agent开发1 小时前
工具调用失败后怎么办?重试分层与降级回路的三种路线
服务器·前端·网络
IMPYLH2 小时前
HTML 的 <dialog> 元素
前端·html
AI编程实验室2 小时前
用 npm + Three.js 做一颗西瓜:把夏天的清凉感放进浏览器
前端·后端·ai编程
渣波2 小时前
基于 Milvus 构建小说知识库 RAG,实现图书智能问答(天龙八部实战)
前端·后端
JavaGuide2 小时前
我最推荐的 4 个 AI 编程 Skills:grill-me、research、diagnosing-bugs、code-review
前端·后端·ai编程
Moment2 小时前
2026 了,前端转 AI 全栈我是这么学的 😍😍😍
前端·后端·面试