4、Room 的 ACID 保证:SQLite 如何撑起数据一致性

做 Android 本地存储,最怕什么?

我个人的答案是:数据写到一半,App 闪退了。或者两个线程同时在写同一个表,数据乱成一锅粥。Room 之所以敢叫板「官方持久化库」,底气就在于它底层依托的 SQLite 提供了完整的 ACID 保证。说白了,你只管用 DAO 操作,脏活累活 SQLite 全干了。今天我们就掰开揉碎,看看 Room 到底怎么利用 SQLite 实现原子性、一致性、隔离性和持久性。

💡 一句话记住 ACID: 原子性(要么全成要么全不成)、一致性(数据永远合法)、隔离性(并发互不干扰)、持久性(写成功就不会丢)。
SQLite 的 ACID 实现机制 SQLite 事务引擎 原子性 ROLLBACK 回滚 一致性 约束 & 触发器 隔离性 WAL + 锁机制 持久性 WAL checkpoint 日志(rollback journal) 原子提交协议 NOT NULL / UNIQUE / FOREIGN KEY 自动回滚不一致状态 读写锁 + MVCC 多版本并发控制 WAL 文件 + fsync 保证物理写入 Room 的封装:@Transaction 注解 自动生成 BEGIN / COMMIT / ROLLBACK 代码 配合 SQLite 原生的 ACID 机制,开发者无需手写事务

原子性:要么全做,要么全不做

原子性说白了就是:一组操作要么全部成功,要么看起来像没发生过。SQLite 靠的是 rollback journal(回滚日志)或 WAL 模式下的原子提交协议。

当你执行 BEGIN TRANSACTION,SQLite 会把原始数据页复制到日志文件。如果中途 App 崩溃了,下次打开数据库时,SQLite 检测到日志文件存在且没有 COMMIT,就会自动回滚。Room 的 @Transaction 注解本质上就是在帮你生成这些代码。

🚀 个人经验: 我遇到过这种情况——在一个批量插入的操作里忘记加 @Transaction,结果插入到一半遇到唯一约束冲突,部分数据写进去了部分没写进去,导致 UI 展示异常。从那以后我养成了习惯:只要是多个写操作,哪怕只有两行代码,也加上 @Transaction。

看看 Room 帮我们生成的 SQLite 代码大概长什么样:

// Room 自动生成的代码(简化版)
_sqlite.beginTransaction();
try {
    // 你的 DAO 方法内的所有操作
    _sqlite.execSQL("INSERT INTO users ...");
    _sqlite.execSQL("UPDATE profile ...");
    _sqlite.setTransactionSuccessful();
} finally {
    _sqlite.endTransaction(); // 没有 setSuccessful 就会自动 ROLLBACK
}

一致性:数据永远合法

一致性的含义是:事务开始前和结束后,数据库的约束条件都成立。SQLite 通过以下几种方式保证:

  • 声明式约束:NOT NULL、UNIQUE、PRIMARY KEY、FOREIGN KEY。举个例子,如果你定义了外键,插入一条指向不存在父记录的子记录,SQLite 直接拒绝。
  • 触发器:可以在 INSERT/UPDATE/DELETE 前后自动执行校验逻辑。
  • 原子性自动回滚:当事务内的操作违反约束时,SQLite 会回滚整个事务,保证不会留下「半截子」不一致的状态。

Room 里你在 Entity 上标注 @NonNull 或 @ColumnInfo(index = true),最后都会映射到 SQLite 的约束上。我个人的习惯是:能交给数据库校验的,绝不写在应用层。因为应用层可能漏掉,但数据库的一致性保证是铁打的。

⚠️ 注意: 外键约束在 SQLite 中默认是关闭的!Room 帮你自动开启了 PRAGMA foreign_keys = ON,但你如果自己写 raw SQL 或迁移脚本,别忘了手动设置。

隔离性:并发不打架

隔离性解决的是「多个事务同时读写同一行数据」的问题。SQLite 的隔离级别是 Serializable(可串行化),这是最严格的级别。但在 WAL 模式下,它又允许读写并发。

底层原理:

  1. 读锁(SHARED):多个读事务可以同时持有,互不阻塞。
  2. 写锁(RESERVED / EXCLUSIVE):写事务需要独占锁。在回滚日志模式下,读写完全互斥;但在 WAL 模式下,写操作写入 WAL 文件,读操作从原数据库文件读取,所以读不阻塞写,写也不阻塞读。
  3. MVCC(多版本并发控制):WAL 模式下每个读事务看到的是开始时刻的快照,写事务的修改不会影响正在进行的读。
💡 关键点: Room 默认开启 WAL 模式(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING),所以你的 App 可以一边读一边写,性能大幅提升。但要注意,如果多个线程同时写同一个表,还是会有锁竞争,建议用 @Transaction 让它们排队。

我曾经踩过一个坑:在后台线程批量插入数据时,UI 线程查询列表,结果偶尔出现「数据瞬间消失又出现」的错觉。后来发现是读事务在 WAL 模式下看到的是旧版本,等 WAL checkpoint 之后新版本才可见。解决办法是用 Flow 监听数据库变化,或者调高 wal_autocheckpoint 间隔。

持久性:写成功就不丢

持久性要求:一旦事务提交成功,数据必须被永久保存,即使系统崩溃也不会丢失。SQLite 通过以下机制保证:

  • WAL(Write-Ahead Logging):修改先写入 WAL 文件,待安全后再合并回主数据库。这是默认推荐模式。
  • fsync:每次 COMMIT 后,SQLite 会调用操作系统层面的 fsync() 将数据从页面缓存强制刷到磁盘。这是保证持久性的关键。
  • fullfsync / PRAGMA synchronous:你可以配置 synchronous = FULL(最安全)或 synchronous = NORMAL(略快但极端情况下可能丢失部分数据)。Room 的默认设置是 FULL。
PRAGMA 选项 写性能 持久性保证
synchronous = OFF 极快 💀 可能丢数据(不推荐)
synchronous = NORMAL 较快 绝大多数情况可靠
synchronous = FULL(默认) 较慢 ✅ 最安全
🔧 调优建议: 我个人的项目经验是,大部分 App 使用默认的 FULL 都没问题。只有极高频写入的场景(比如日志记录)才考虑降到 NORMAL,但一定要评估可接受的数据丢失风险。

Room 如何串联这四者?

Room 没有自己实现 ACID,它只是个「翻译官」和「管家」——

  • 自动生成 beginTransaction() / endTransaction() 代码(原子性)
  • 自动开启外键约束、把注解映射成表约束(一致性)
  • 默认启用 WAL 模式,并提供 @Transaction 控制隔离边界(隔离性)
  • 使用 SQLite 原生的 checkpoint 机制和 fsync(持久性)

你唯一要做的,就是正确使用这些注解和配置。嗯,其实说到底,ACID 是 SQLite 的功劳,但 Room 替我们省掉了大量手写事务的样板代码。下次如果面试官问「Room 怎么保证 ACID」,你就可以底气十足地告诉他:Room 自己不干活,它只是 SQLite 的优秀代理人。