人人都会AI编程

15.5 事务编程最佳实践:粒度控制、异常回滚、避免死锁

更新时间:2026-07-11

前面的章节已经把事务的基本操作、隔离级别和长事务的危害讲清楚了。这一节重点回归到日常开发中真正容易出问题的三个环节:事务应该开多大?出错了怎么兜底?怎么不让事务互相卡死? 这些没有绝对的公式,但有一些经过大量生产验证的经验,可以直接用起来。

15.5.1 事务粒度控制:把大事化小

一个最常见的错误是把“一个业务请求”等同于“一个事务”,让事务包裹了太多非数据库操作。比如:

// 错误示例:事务包含了远程调用和文件处理
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
// 调用第三方支付接口(可能耗时几百毫秒)
// 上传文件到 OSS
INSERT INTO order_log ... ;
COMMIT;

这样做有三个直接问题:

  1. 事务持有锁的时间被无限拉长。在可重复读隔离级别下,事务期间修改过的行会一直持有行锁,直到事务结束才释放。这段时间内,其他要操作同一行的请求全部排队等待。
  2. 连接被长期占用。数据库连接是稀缺资源,一个事务占着一个连接空转几百毫秒,高并发下连接池很快耗尽。
  3. 业务操作失败导致事务被迫回滚,大量工作白做。如果支付接口超时或抛出异常,整个事务回滚,库存更新和日志插入全部作废。

最佳实践:

  • 事务只包裹数据库写操作,不放外部调用。先完成远程调用、业务校验等非数据库步骤,确认没问题后再开启事务,执行数据库修改并立即提交。
  • 一次事务只做一件事。比如库存扣减是一个事务,订单创建是另一个事务。如果业务上必须保证两者的原子性,可以通过最终一致性方案(如事务消息)而不是一个大事务硬绑在一起。
  • 拆分成小事务,必要时引入补偿逻辑。比如扣库存成功、创建订单失败时,在应用层发起补库存操作,或者依赖定时任务兜底。

正确的结构应该是:

// 先做外部调用和校验
boolean paySuccess = paymentService.call();
if (!paySuccess) throw new BusinessException("支付失败");
// 开启短事务,只做数据库操作
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
INSERT INTO order_log ... ;
COMMIT;

对于批量处理,比如一次要更新 10 万行数据,也建议分批提交,每几千行一个事务,避免单事务过大导致同步复制延迟或回滚风暴。

15.5.2 异常回滚:确保事务一定被关闭

事务开出去是借,必须还。要么提交,要么回滚。如果应用的异常处理逻辑有漏洞,可能导致事务既没提交也没回滚,连接被挂起,直到超时断开,这期间的锁也不释放,会引发连锁反应。

几种典型的风险场景和处理方式:

  • finally 块中或 try-with-resources 保证回滚

Java 代码中,用 try-catch-finally 或者框架的事务管理器(如 Spring 的 @Transactional)来确保异常时回滚。但要注意,只捕获 Exception 可能漏掉 Error 或某些自定义异常,建议在 finally 中检查事务状态并回滚。

  // 伪代码
  begin();
  try {
      // 业务逻辑
      commit();
  } catch (Exception e) {
      rollback();
      throw e;
  }
  
  • 超时回滚

如果事务因为网络抖动或死锁等待被卡住,应用层应该设置超时,避免无限挂起。可以通过连接参数 connectionTimeout 和事务超时(如 Spring 的 timeout 属性)来控制。

  • 幂等性与重试

事务回滚后,有些操作可以被安全地重新执行多次,有些不行。对于数据库写操作,尽量设计为幂等的,比如用唯一键防重插入,这样即使重试也不会出错。对于转账等非幂等操作,需要借助分布式事务表或第三方系统保证不重复执行。

  • 注意 checked 异常和 Spring 默认回滚策略

Spring 默认只对 RuntimeExceptionError 回滚,对于 checked 异常不回滚。如果你的业务方法抛出了 IOException 等 checked 异常,需要显式配置 @Transactional(rollbackFor = Exception.class),否则事务会正常提交,造成数据不一致。

15.5.3 避免死锁:让事务有秩序地干活

InnoDB 的死锁检测算法会自动选择一个代价较小的事务回滚,但一旦发生死锁,业务逻辑就会收到一个异常,需要处理。与其被动承受,不如从设计上减少死锁的发生几率。

死锁的本质是:两个或多个事务以不同的顺序请求对方已经持有的锁资源。所以避免死锁最核心的原则就是:让加锁顺序一致

具体方法如下:

  1. 固定增删改的执行顺序

例如,有两个业务 A 和 B 都要操作用户表和订单表,A 先更新用户再更新订单,B 如果有类似的逻辑也要先更新用户再更新订单,统一规则。
在涉及多表关联时,设计明确的表操作顺序规范,比如“所有涉及账户和流水的操作,一律先处理流水表再处理账户表”。

  1. 使用索引避免间隙锁范围扩大

许多死锁是间隙锁引起的。当 WHERE 条件中的列没有索引时,InnoDB 可能会对扫描到的所有行加锁,甚至加上间隙锁,导致锁定范围远比你预期的大,增加与其他事务冲突的概率。
只要给高频更新条件的列加上合适的索引,缩小锁范围,死锁概率就会大幅降低。

  1. 将大的写事务拆小,降低冲突面

事务越小,持有锁的时间越短,和其他事务重叠的概率越低。也正因为此,前面说的粒度控制和避免死锁是相通的。

  1. 选择合适的隔离级别,减少加锁

可重复读(RR)下的间隙锁是避免幻读的功臣,但也是很多死锁的源头。如果你的业务可以接受偶尔的幻读(例如某些报表统计场景),可以评估将事务级别降为读已提交(RC),加上 binlog_format=ROW,既能保证复制安全,又能大幅减少间隙锁,降低死锁概率。
但必须清楚,RC 下不可重复读和幻读可能发生,需要业务逻辑做兼容。

  1. 使用乐观锁避免行争用

对于热点行更新,比如秒杀库存扣减,行锁和死锁是并发量提升的巨大瓶颈。可以改用乐观锁:在库存表上加一个版本号字段,更新时检查版本号是否被其他事务篡改,如果篡改则重试。这种方式将“先锁后改”变成“先改后判冲突”,减少了锁等待。

   UPDATE stock SET num = num - 1, version = version + 1
   WHERE sku_id = ? AND version = ? AND num > 0;
   -- 影响行数为0表示冲突,需要重试
   
  1. 监控与排查死锁

当死锁真的发生时,MySQL 会记录到错误日志中(SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 节),里面详细列出了两个事务分别持有哪些锁、等待哪些锁,以及当时的 SQL 语句。通过分析这些信息,可以定位到具体的冲突点,优化索引或调整逻辑。

15.5.4 补充的几点实战素养

  • 及时提交或回滚,不要让事务在代码里“空转”。开启事务后尽快执行数据库操作并结束,避免在事务中间做用户交互或复杂计算。
  • 设置适当的锁等待超时innodb_lock_wait_timeout,默认 50 秒),防止一个事务因为长时间等待锁而把连接拖死。对于高并发系统,建议调低到 5~10 秒,快速失败然后由应用层重试。
  • 注意自动提交陷阱。默认情况下 MySQL 是自动提交的,每条语句都是一个单独事务。如果你要手动管理事务,务必先用 BEGINSTART TRANSACTION 关闭自动提交,执行完后手动 COMMIT/ROLLBACK。
  • 避免在使用连接池时手动开启事务后忘记提交,否则连接被还回池时事务可能还处于活跃状态,下次被取用时会出现“事务未关闭”的意外情况。Spring 等框架会自动处理,自己手动管理时要格外小心。

这些实践并不是什么高深的理论,都是日常搬砖中反复踩出来的坑。把它们内化成编码习惯,你的系统在高并发和异常场景下会稳健得多。