9、数据库事务:事务ACID特性、beginTransaction与setTransactionSuccessful、回滚机制rollback、事务嵌套与保存点、性能对比测试
事务这个东西,说白了就是「要么全做,要么全不做」。我刚开始写Android数据库时,总觉得事务是锦上添花的东西。直到有一次,用户批量导入通讯录时程序突然崩溃,结果数据库里一半联系人进去了,另一半丢了,还留下了一堆脏数据……嗯,从那以后,我再也不敢轻视事务了。
核心一句话:事务是保证数据一致性的最后一道防线。没有事务,你的数据库在异常面前就是纸糊的。
9.1 事务的ACID特性
ACID是事务的四个基本属性。你想想看,如果数据库连这四点都保证不了,谁敢把用户数据往里存?
- 原子性(Atomicity):事务里的操作,要么全部成功,要么全部失败。不存在「做了一半」的状态。
- 一致性(Consistency):事务执行前后,数据库的约束、规则都不能被破坏。比如转账后总金额不变。
- 隔离性(Isolation):并发执行的事务之间互不干扰。每个事务都感觉自己是独占数据库的。
- 持久性(Durability):事务一旦提交,结果就是永久的。哪怕系统断电,数据也不会丢。
我个人习惯把ACID记成「原一隔持」——虽然不太好听,但确实管用。
避坑指南:我曾经在项目中遇到过一个问题:批量插入1000条数据,每插入一条就调用一次db.insert(),结果中间某条数据格式不对抛了异常,前面插入的500条已经写进去了。这就是典型的「没有事务」的后果。后来我改成事务包裹,异常时统一回滚,问题解决。
9.2 beginTransaction 与 setTransactionSuccessful
在Android的SQLite中,事务的使用非常直接。核心就三个方法:beginTransaction()、setTransactionSuccessful()、endTransaction()。
我见过不少新手写事务时,只调了beginTransaction和endTransaction,却忘了调setTransactionSuccessful。结果呢?所有操作都被回滚了,数据一条都没写进去。为什么会这样?因为endTransaction()在没有调用setTransactionSuccessful的情况下,默认执行回滚。
// 标准的事务写法
db.beginTransaction();
try {
// 执行多条SQL操作
db.execSQL("INSERT INTO users(name, age) VALUES(?, ?)", new Object[]{"张三", 25});
db.execSQL("UPDATE accounts SET balance = balance - 100 WHERE user_id = ?", new Object[]{1});
// 标记事务成功
db.setTransactionSuccessful();
} catch (Exception e) {
// 异常时自动回滚,不需要手动调用rollback
Log.e("DB", "事务执行失败", e);
} finally {
// 结束事务(提交或回滚)
db.endTransaction();
}
注意:setTransactionSuccessful()必须在endTransaction()之前调用,且只能调用一次。如果你在事务中调用了两次,第二次会抛出异常。
9.3 回滚机制 rollback
回滚,就是把数据库恢复到事务开始之前的状态。在Android SQLite中,你其实很少需要手动调用rollback()方法。因为endTransaction()在没有标记成功时,会自动执行回滚。
但有一种情况需要手动回滚:当你需要在事务中间判断某个条件不满足,提前终止整个事务时。
db.beginTransaction();
try {
// 第一步:插入订单
long orderId = db.insert("orders", null, orderValues);
if (orderId == -1) {
// 插入失败,手动回滚
db.endTransaction(); // 这里会触发回滚
return false;
}
// 第二步:扣减库存
int rows = db.update("products", values, "id = ?", new String[]{productId});
if (rows == 0) {
// 库存扣减失败,回滚
db.endTransaction();
return false;
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
个人经验:我曾经在项目中遇到一个诡异的bug——事务回滚后,自增主键的计数器并没有回滚。也就是说,你回滚了插入操作,但下一次插入时主键会跳过被回滚的那个值。这不是bug,是SQLite的设计如此。如果你对主键连续性有要求,需要自己处理。
9.4 事务嵌套与保存点
事务嵌套,说白了就是事务里面再开事务。Android SQLite支持事务嵌套,但它的实现方式有点特殊——嵌套的事务实际上共享同一个数据库连接。
当你调用beginTransaction()时,SQLite会维护一个事务计数器。只有最外层的事务提交或回滚时,才会真正影响数据库。内层事务的setTransactionSuccessful()只是标记一个「保存点」(Savepoint)。
// 外层事务
db.beginTransaction();
try {
// 做一些操作
db.execSQL("INSERT INTO log VALUES('开始批量操作')");
// 内层事务
db.beginTransaction();
try {
db.execSQL("INSERT INTO users(name) VALUES('内层插入')");
db.setTransactionSuccessful(); // 这只是标记保存点
} finally {
db.endTransaction(); // 内层结束,不影响外层
}
// 如果这里发生异常,内层的插入也会被回滚
db.setTransactionSuccessful();
} finally {
db.endTransaction(); // 真正提交或回滚
}
你想想看,这种设计有什么好处?最大的好处是:你可以在一个大的事务中,把某些操作「打包」成子事务。即使子事务成功了,只要外层事务回滚,子事务的数据也会被撤销。
保存点的本质:保存点就是事务中的「书签」。你可以回滚到任意一个保存点,而不影响其他部分。这在复杂的数据迁移、批量处理场景中非常有用。
9.5 性能对比测试
事务对性能的影响有多大?我直接说结论:差距是数量级的。
为什么?因为每次insert()、update()操作,如果不放在事务里,SQLite都会自动开启一个隐式事务,操作完立即提交。这意味着每次操作都要经历「写日志 → 刷磁盘 → 更新索引」这一整套流程。而事务把这些操作攒在一起,最后一次性提交,磁盘I/O次数大大减少。
我做过一个简单的测试:插入1000条记录。
| 测试场景 | 耗时(毫秒) | 说明 |
|---|---|---|
| 逐条插入(无事务) | 约 8500 ms | 每次插入都触发磁盘写入 |
| 单条事务包裹 | 约 8200 ms | 和逐条插入差不多,因为每条还是独立事务 |
| 批量事务(1000条一次提交) | 约 120 ms | 快了70倍! |
| 批量事务 + 预编译 | 约 45 ms | 事务+预编译,性能最佳 |
性能优化建议:
- 批量操作(插入、更新、删除)一定要用事务包裹
- 事务不要太大,建议每500-1000条提交一次,避免长时间锁表
- 配合
SQLiteStatement预编译,性能还能再翻倍
// 性能最优的批量插入写法
SQLiteStatement stmt = db.compileStatement("INSERT INTO users(name, age) VALUES(?, ?)");
db.beginTransaction();
try {
for (User user : userList) {
stmt.bindString(1, user.getName());
stmt.bindLong(2, user.getAge());
stmt.executeInsert();
stmt.clearBindings();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
stmt.close();
}
注意:事务虽然快,但也不是越大越好。一个事务包含几万条操作时,内存占用会飙升,而且一旦失败,回滚的成本也很高。我建议根据数据量灵活控制,比如每1000条提交一次。
好了,关于事务的核心内容就这些。ACID特性是理论基础,beginTransaction和setTransactionSuccessful是实战工具,回滚机制是安全保障,事务嵌套和保存点是进阶技巧,性能对比测试则告诉你——事务不是可有可无的,它是高性能数据库操作的基石。
公众号:蓝海资料掘金营,微信deep3321