17、事务处理:@Transaction注解、事务特性、批量操作、回滚机制
说实话,事务这个东西,刚接触Room的时候我总觉得它离我很远。不就是增删改查嘛,一条SQL搞定的事,搞什么事务?直到有一次,我在一个批量导入联系人功能里,写到一半程序崩溃了——结果数据库里一半数据进去了,另一半没进去。用户一打开通讯录,看到一堆残缺的记录,直接给了个差评。
嗯,从那以后,我再也不敢小看事务了。
17.1 什么是事务?为什么需要它?
事务,说白了就是一组数据库操作,要么全部成功,要么全部失败。没有中间状态。
你想想看,转账这个场景:A扣100块,B加100块。如果扣完A的钱之后服务器宕机了,B没加上——这钱去哪了?事务就是用来解决这种问题的。
在Room里,事务有四个核心特性,简称ACID:
| 特性 | 含义 | 我的理解 |
|---|---|---|
| 原子性(Atomicity) | 操作要么全做,要么全不做 | 就像点外卖,要么全部送到,要么退款 |
| 一致性(Consistency) | 数据前后状态必须合法 | 转账前后总金额不变 |
| 隔离性(Isolation) | 并发操作互不干扰 | 两个人同时转账,不会乱套 |
| 持久性(Durability) | 提交后数据永久保存 | 就算手机重启,钱也不会丢 |
我个人习惯把事务想象成一个「保护罩」——你把一组操作放进去,Room帮你保证要么全部生效,要么全部回滚。
17.2 @Transaction注解:最简单的开启方式
在Room里,开启事务最简单的方式就是加一个@Transaction注解。我刚开始用的时候觉得这也太简单了吧?但实际用下来,真香。
@Dao
interface UserDao {
@Transaction
fun transferMoney(fromUserId: Long, toUserId: Long, amount: Long) {
// 扣钱
deductBalance(fromUserId, amount)
// 加钱
addBalance(toUserId, amount)
}
@Query("UPDATE user SET balance = balance - :amount WHERE id = :userId")
fun deductBalance(userId: Long, amount: Long)
@Query("UPDATE user SET balance = balance + :amount WHERE id = :userId")
fun addBalance(userId: Long, amount: Long)
}
注意,@Transaction修饰的方法里,可以调用多个DAO方法。Room会自动把它们包在一个数据库事务里。如果中间任何一步失败了,前面的操作也会回滚。
@Transaction 只能用在 DAO 方法上,不能用在 Repository 层。我曾经犯过这个错——在 Repository 里加 @Transaction,结果 Room 根本不认,数据该乱还是乱。
17.3 批量操作:性能提升的关键
批量操作是我在项目中用得最多的场景。比如用户导入通讯录,一次可能几百上千条记录。如果一条一条插入,那速度慢得让人抓狂。
为什么会慢?因为每条插入都是一次独立的数据库事务,每次都要写磁盘、刷新索引。批量操作的核心思路就是:把所有插入放在一个事务里,只提交一次。
@Dao
interface ContactDao {
@Transaction
fun insertContacts(contacts: List<Contact>) {
// 先删除旧数据
deleteAllContacts()
// 再批量插入
for (contact in contacts) {
insertContact(contact)
}
}
@Insert
fun insertContact(contact: Contact)
@Query("DELETE FROM contact")
fun deleteAllContacts()
}
这里有个小技巧:如果你用@Insert注解一个接收List的方法,Room会自动帮你做批量插入优化。
@Insert
fun insertAll(contacts: List<Contact>)
我个人建议,如果数据量超过100条,一定要用批量操作。我在一个项目里测试过:插入1000条数据,逐条插入用了8秒,批量插入只用了0.3秒。差距就是这么夸张。
17.4 回滚机制:出错了怎么办?
事务最强大的地方在于回滚。一旦某个操作失败,之前的所有修改都会被撤销,数据库回到事务开始前的状态。
Room的回滚是自动的——只要方法抛出异常,事务就会回滚。但这里有个坑:你必须确保异常没有被吞掉。
@Transaction
fun updateUser(user: User) {
try {
updateName(user.id, user.name)
updateAge(user.id, user.age) // 这里可能抛异常
} catch (e: Exception) {
// 吞掉异常!事务不会回滚!
Log.e("TAG", "出错了", e)
}
}
@Transaction
fun updateUser(user: User) {
updateName(user.id, user.name)
updateAge(user.id, user.age) // 让异常抛出去
// Room 会自动回滚
}
我曾经在一个支付模块里犯过这个错——为了不让用户看到错误提示,我把异常全catch了。结果数据写到一半,余额扣了但订单没生成。排查了半天才发现是事务根本没回滚。
17.5 事务的隔离级别
Room默认使用SQLite的隔离级别,也就是SERIALIZABLE。说白了就是:同一时间只有一个事务能写数据,其他事务得排队。
这听起来很慢,但实际项目中大部分场景都够用。除非你的App有极高的并发写入需求——不过说实话,移动端很少遇到这种情况。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
Room默认就是最严格的SERIALIZABLE,所以不用担心并发问题。但代价就是写入性能会受一点影响——不过对于移动端来说,这点影响完全可以忽略。
17.6 事务与协程:异步操作的正确姿势
现在Kotlin项目基本都用协程了。Room也支持在挂起函数里使用事务。
@Dao
interface OrderDao {
@Transaction
suspend fun createOrder(order: Order, items: List<OrderItem>) {
insertOrder(order)
for (item in items) {
insertOrderItem(item)
}
}
@Insert
suspend fun insertOrder(order: Order)
@Insert
suspend fun insertOrderItem(item: OrderItem)
}
注意,挂起函数里的事务和普通函数一样,异常会自动触发回滚。但有个细节:如果你在事务里调用了其他挂起函数,要确保它们不会切换线程。否则事务可能会被意外提交。
我曾经在事务里调了一个网络请求的挂起函数,结果网络请求切到了IO线程,事务被提前提交了。后面的数据库操作变成了新事务,前面的操作回滚不了了。所以记住:事务里不要做网络请求、文件读写这些可能切线程的操作。
17.7 知识体系总览
下面这张图是我自己总结的事务处理知识体系,你可以对照着复习:
17.8 实战建议总结
说了这么多,最后给你几个我自己的实战建议:
- 能用@Transaction就别手动管理事务——Room帮你处理好了开始、提交、回滚,省心省力
- 批量操作一定要加事务——100条以上数据,性能差距是数量级的
- 事务里别做耗时操作——网络请求、文件读写、复杂计算,统统不要放进去
- 异常一定要抛出去——catch了异常却不处理,事务不会回滚,数据就乱了
- 测试事务回滚——我习惯写一个单元测试,故意让中间步骤抛异常,验证数据是否真的回滚了
事务处理这个东西,平时可能感觉不到它的存在。但一旦出了问题,排查起来特别痛苦。所以从一开始就养成好习惯,比事后补坑要划算得多。
一句话总结:事务就是数据库操作的「后悔药」——只要没提交,随时可以反悔。用好它,你的数据安全就有保障。
公众号:蓝海资料掘金营,微信deep3321