三、事务与快照:数据库事务隔离级别与快照读

好,咱们直接进入正题。做Android本地存储的同学,肯定绕不开事务和并发。Room用了SQLite,DataStore基于文件,两者对待并发的姿势完全不一样。我个人习惯先理解清楚事务的隔离级别,再去看快照读,这样后面选型才不会踩坑。

3.1 事务隔离级别——你想想看,多线程同时读写会怎样?

理论上,数据库事务有四个隔离级别。我在项目中遇到过好几次用户反馈数据错乱,查到最后发现是隔离级别设得太低。说白了就是读到了不该读的东西。

标准的隔离级别有四种,从低到高分别是:

隔离级别 脏读 不可重复读 幻读
读未提交 可能 可能 可能
读已提交 避免 可能 可能
可重复读 避免 避免 可能
可串行化 避免 避免 避免

嗯,SQLite默认是「可串行化」——也就是说,它把整个隔离级别拉满了。我之前做的一个IM应用,明明只查一条消息,却发现另一个线程刚插入的数据莫名其妙消失了?后来才知道是并发写导致锁冲突。

关键点:Android上的SQLite默认以「串行化」方式运行。它通过文件锁(WAL模式下的读写锁)保证事务隔离。但代价是并发写性能下降。

3.2 快照读——Room怎么做到的?

快照读,其实是一种不用加锁就能读到一致性数据的方式。我一开始觉得这东西很高深,后来发现原理很简单:每个事务开始时,数据库给它拍了一张「数据快照」。这个快照记录了当时所有已提交事务的数据版本。后续读操作直接读快照,不管其他事务是否修改,都不影响它。

Room底层用的就是SQLite的WAL(Write-Ahead Logging)模式。在WAL模式下:

  • 读事务不会阻塞写事务
  • 写事务也不会阻塞读事务
  • 读事务看到的是开始时刻的「快照」

举个例子:假设表中有记录 A=1,两个线程并发执行。

时间点  | 线程1(写事务)          | 线程2(读事务)
--------|-------------------------|-----------------------
T1      | 开始事务,写A=2(未提交)| 开始事务,读A → 1
T2      |                         | 读A → 1(还是旧值)
T3      | 提交事务                |
T4      |                         | 读A → 1(因为快照在T1时已拍)
T5      |                         | 提交事务
T6      | 新读事务开始,读A → 2   |

看到没?线程2在整个读事务期间,始终看到A=1。这就是快照读的效果。我刚开始做项目时,常常困惑为什么更新完数据后查询还是旧值——其实就是因为读事务还没提交,快照没刷新。

小技巧:如果你需要立刻看到最新数据,记得把读操作放到写事务的提交之后,或者用@Transaction包裹连续读写操作。

Room里怎么开启WAL?很简单,在数据库构建器里设置:

Room.databaseBuilder(context, AppDatabase.class, "app.db")
    .enableWAL()  // 启用预写日志
    .build();

3.3 快照读的直接后果——你也遇到过的"读旧数据"问题

快照读虽然解决了脏读和不可重复读,但有一个经典缺陷:读到的是过去的数据。比如说,你在一个读事务里循环查询,发现同一行数据值始终不变——即使别的线程已经提交了修改。

我遇到过一个实际案例:用户在做余额查询时,看到的是几分钟前的余额。原因是查询页面打开后,Activity里启动了一个读事务(Room的自动查询),用户滑动列表时数据没更新。最后排查发现:读事务没有主动关闭,一直持有快照。

注意:Room的LiveData或Flow订阅查询,本质上是启动了一个长期读事务。如果你在subscribe期间有大量写操作,观察者看到的可能是「陈旧快照」直到下一次查询被触发。建议避免在频繁写的同时做长时间快照读。

3.4 DataStore是另外一套玩法

DataStore基于文件,不是传统关系型数据库。它使用协程和并发原语(Mutex)来实现一致性。它的操作是「写时复制」+「原子替换」:写入时先生成新文件,再原子替换旧文件。所以每个读操作要么看到完整旧文件,要么看到完整新文件——不存在中间状态。

但DataStore没有多版本快照的概念。读事务和写事务之间没有隔离级别设置。如果同时有多个写操作,会排队执行。嗯,说白了就是「串行化」但靠协程调度,不是靠数据库锁。

3.5 核心逻辑图:事务与快照的知识体系

下面这张图我把隔离级别、快照读原理、Room和DataStore的差异串起来了。

事务与快照核心逻辑 隔离级别 脏读 / 不可重复读 可串行化(SQLite) WAL模式 快照读 事务开始时拍下数据版本 MVCC(多版本并发控制) 读无锁,写不影响读 Room(SQLite + WAL) 读快照、写提交后刷新 支持LiveData/Flow长事务 DataStore(文件原子替换) 写时复制,无快照概念 协程串行写,读总能看到完整文件 对比结论 Room:强一致性+快照读,适合复杂读写场景 DataStore:弱隔离但简单,适合键值对存储

3.6 避坑指南——我之前踩过的坑

  • 千万不要在嵌套事务里做长查询:Room的@Transaction默认开启一个事务,里面如果再用LiveData订阅,会导致事务一直不结束。我之前就是这么干的,结果数据库文件被锁死,其他线程写不进去。
  • DataStore的读是同步还是异步?:DataStore的读是通过flow.first()获取,需要协程。我刚开始以为它跟SharedPreferences一样是同步的,直接在主线程调用,结果ANR了。
  • 快照读其实不是Bug,要理解它:如果你需要强实时性,用@Transaction包裹读写。如果你只是展示列表,快照读没问题。

3.7 选型建议

我个人习惯这样判断:如果数据需要复杂查询(Join、聚合)、需要事务保证、或者有多个线程并发读写,果断用Room+WAL。如果只是存用户偏好、标记等简单键值对,用DataStore更轻量。

一句话总结:Room的快照读取决于WAL的MVCC,DataStore依赖文件原子替换。选型前先想清楚你的业务需要什么级别的隔离。