真正动手写这篇 MySQL 事务的文章,是因为最近在排查一个线上问题时发现,不少同事对事务的理解停留在"要么成功要么失败"这个层面上,稍微往深处问两句就答不上来了。但实际在用的时候,隔离级别、锁、回滚日志、事务失效、传播行为,每一个都能单独挖出一堆坑。这篇文章我不想写成官方文档的翻译稿,而是把我自己从基础到实战过程中理清楚的东西、踩过的坑,以及最后沉淀出来的排查思路都梳理出来。适合正在用 MySQL 做业务开发、需要处理数据一致性问题的人,也适合准备面试想系统过一遍事务知识点的同学。
1. 为什么说事务是 MySQL 最容易被用错的底层能力
1.1 从一次扣库存事故说起
先讲一个我上个月遇到的真实例子。
我们的一个下单接口,核心逻辑是扣减库存、创建订单。一开始的代码是这样的:
java复制public void createOrder(Long productId, Integer count) {
Product product = productMapper.selectById(productId);
if (product.getStock() < count) {
throw new RuntimeException("库存不足");
}
Product update = new Product();
update.setId(productId);
update.setStock(product.getStock() - count);
productMapper.updateById(update);
orderMapper.insert(buildOrder(productId, count));
}
表面看起来没什么问题,先查库存,再扣库存,再生成订单。直到有一次压测,并发跑到 50 的时候,订单生成数量超过了实际库存,出现了严重的超卖。
问题出在哪儿?两个请求同时查到了库存是 10,都判断库存充足,然后都执行了减 1,最后库存变成了 9,可是生成了两笔订单。这就是典型的并发写冲突,没有事务隔离,也没有锁保护。
后来有人把方法上加了个 @Transactional,以为就行了。其实也不行,因为 select 出来的是快照数据,update 的时候如果用了乐观锁之类的机制还好,单纯靠事务注解,两个并发事务还是可能基于同一个旧值去做更新。这是很多初学者最容易犯的错:以为事务能解决所有一致性问题,实际上事务只是给你提供了一个"可回滚、可隔离"的框架,如何避免并发冲突还要靠锁、幂等、版本号等手段。
1.2 事务的本质和大多数人的理解偏差
面试的时候我问过很多人,"什么是事务?"答案统一是"一组操作要么全部成功,要么全部失败"。这个说法没错,但是太粗了。
ACID 这四个维度才是事务的完整画像:
- 原子性(Atomicity):一组操作作为一个整体,要么全做,要么全不做。MySQL 里面靠 undo log 实现。
- 一致性(Consistency):事务执行前后,数据完整性约束不能被破坏。这个其实最终靠应用层和数据库约束一起保证。
- 隔离性(Isolation):并发事务之间互相不干扰的程度,由隔离级别和锁机制控制。
- 持久性(Durability):事务提交后,数据不会丢失,InnoDB 里靠 redo log 保证。
很多人以为事务就是 BEGIN 和 COMMIT 之间的代码,其实这只是事务的最小单元。真正决定一个事务靠不靠谱的,是它能不能在各种异常场景下依然保持 ACID。比如一个事务执行到一半,MySQL 进程突然崩溃了,重启之后未提交的事务能不能自动回滚?已提交的数据能不能不丢?这时候 redo log 和 undo log 就派上用场了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL 事务的骨与肉:redo log、undo log 和 ACID 的落地方式
2.1 redo log:持久性的守护者
InnoDB 的默认策略是"写日志先行",也就是 WAL(Write-Ahead Logging)。简单来说,事务提交时,并不会立即把数据页刷到磁盘,而是先把这次修改的 redo log 写入日志缓冲区,然后按策略刷到磁盘上的 redo log 文件。这样做的好处很明显:磁盘随机写变成顺序写,性能提升一个数量级。
那万一数据页还没刷盘,数据库就崩了呢?重启时 InnoDB 会扫描 redo log,把已经提交但没来得及刷盘的数据重新写回数据页,这样就保证了持久性。
redo log 的刷盘策略由 innodb_flush_log_at_trx_commit 控制:
| 参数值 | 行为 | 安全性 | 性能 |
|---|---|---|---|
| 0 | 每秒刷一次,事务提交时不主动刷 | 数据可能丢 1 秒 | 最高 |
| 1 | 每次提交都刷盘 | 最安全 | 最低 |
| 2 | 每次提交写入操作系统缓存,每秒刷盘 | 操作系统不崩就不丢 | 折中 |
生产环境我一般建议设为 1,尤其涉及订单、支付这种核心链路,宁可慢一点也不能丢数据。
2.2 undo log:原子性和 MVCC 的地基
undo log 记录的其实是"反向操作"。一个 INSERT 对应一个 DELETE 的 undo 记录,一个 UPDATE 对应一条记录修改前的旧值。事务回滚的时候,InnoDB 就把 undo log 里的反向操作重新执行一遍,数据就恢复到事务开始前的状态了。
但 undo log 的作用不只回滚,它还是 MVCC(多版本并发控制)的核心。MySQL 的"可重复读"隔离级别,就是靠 undo log 里的历史版本链,让一个事务在多次查询时读到同一份快照。也就是说,一个事务里,别的并发事务提交了新数据,这个事务依然看到的是自己事务开始时的旧版本。
你想想,如果没有 undo log,要实现"可重复读",是不是只能通过把数据锁死?那并发性能会惨不忍睹。所以在 InnoDB 里,读操作大多走 MVCC,不阻塞;写操作之间走行锁,控制冲突。
2.3 事务语法演示与 InnoDB 的事务边界
直接上 MySQL 客户端操作看一个例子:
sql复制-- 开始事务(也可以写成 START TRANSACTION)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 如果两条都成功,提交
COMMIT;
-- 如果某一条失败,回滚
ROLLBACK;
这里有一个细节:BEGIN 其实不会立即开启事务,MySQL 是执行到事务里的第一条语句时才真正分配事务 ID。START TRANSACTION 和 BEGIN 在效果上基本相同,但 START TRANSACTION 后面可以跟修饰符,比如 START TRANSACTION READ ONLY 标识只读事务,在一些场景下能减少开销。
还有一点,默认情况下 MySQL 是自动提交的,每条 INSERT、UPDATE、DELETE 语句都是一个独立事务。如果想关闭自动提交,可以:
sql复制SET autocommit = 0;
但要提醒一句:关掉自动提交后,如果事务忘了提交,连接长时间挂起,就会有一堆看不见的锁卡住别人,这是生产环境最常见的"晕死锁"源头之一。
3. 隔离级别与并发异常:从脏读到幻读的完整推演
3.1 四种隔离级别到底隔离了什么
SQL 标准定义了四种隔离级别,隔离强度从低到高:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 READ UNCOMMITTED | 可能 | 可能 | 可能 |
| 读已提交 READ COMMITTED | 不可能 | 可能 | 可能 |
| 可重复读 REPEATABLE READ | 不可能 | 不可能 | 可能(InnoDB 下实际避免) |
| 串行化 SERIALIZABLE | 不可能 | 不可能 | 不可能 |
- 脏读:事务 A 读到了事务 B 未提交的数据。如果 B 回滚,那 A 读到的就是无效数据。
- 不可重复读:事务 A 内两次读取同一行数据,结果不一样。因为两次读取之间,事务 B 提交了更新,把数据改了。
- 幻读:事务 A 内两次执行同一个范围查询,第二次多出了一些行。因为事务 B 插入了一行符合条件的新数据。
MySQL InnoDB 的默认隔离级别是可重复读(REPEATABLE READ),而且在这个级别下,InnoDB 通过 MVCC 和间隙锁(Gap Lock)把幻读也一并解决了,这也是很多 DBA 说"其实 MySQL 的默认隔离级别安全性比标准定义的更高"的原因。
3.2 如何用命令查看和修改隔离级别
sql复制-- 查看当前会话隔离级别
SELECT @@transaction_isolation;
-- 查看全局隔离级别
SELECT @@global.transaction_isolation;
-- 会话级别设置
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局设置
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
生产环境要改默认隔离级别的话,更建议直接在 MySQL 配置文件 my.cnf 中写入:
ini复制[mysqld]
transaction-isolation = READ-COMMITTED
为什么有些公司会把隔离级别改成读已提交?因为可重复读级别下,如果使用间隙锁,锁的范围会扩大,在高并发场景下更容易出现死锁。读已提交级别的锁范围更小,适合 CPU 密集型高并发系统。但代价是,同一个事务里,你会看到别的事务已经提交的新数据,业务上可能需要对这种变化做适配。
3.3 脏读、不可重复读、幻读的实际演示
我在本地库表演示一下,大家就可以很清楚看到差异。
先准备一张表:
sql复制CREATE TABLE `tx_test` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(20) DEFAULT NULL,
`amount` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO tx_test (id, name, amount) VALUES (1, 'Alice', 100);
脏读演示(需要把你的隔离级别设为读未提交)
事务 A:
sql复制BEGIN;
UPDATE tx_test SET amount = 200 WHERE id = 1;
-- 此时不提交
事务 B(另一个会话):
sql复制SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT amount FROM tx_test WHERE id = 1;
-- 读到了 200,这就是脏读
如果 A 回滚了,B 读到的 200 就是废数据。生产环境没人会用读未提交,但这个例子能帮你理解隔离级别的意义。
不可重复读演示(读已提交级别下)
事务 A:
sql复制SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT amount FROM tx_test WHERE id = 1;
-- 读到 100
事务 B:
sql复制BEGIN;
UPDATE tx_test SET amount = 150 WHERE id = 1;
COMMIT;
事务 A 再执行一次:
sql复制SELECT amount FROM tx_test WHERE id = 1;
-- 读到 150
同一事务内两次读,结果从 100 变成 150,这就是不可重复读。可重复读级别下 InnoDB 会通过一致性视图让第二次读仍然返回 100。
幻读演示(可重复读级别下,通过一定的骚操作才能看到)
经典场景是事务 A 先查 SELECT * FROM tx_test WHERE amount > 50,事务 B 插入一条 amount=60 的记录并提交,然后事务 A 再执行同样的查询,可重复读级别下因为 MVCC 快照读,看不到新插入的行。但如果是当前读,比如 SELECT * FROM tx_test WHERE amount > 50 FOR UPDATE,则会依赖锁,在标准 SQL 下可能读到新行。InnoDB 通过间隙锁把范围锁住,让 B 无法插入,从而避免了幻读。
注意,这里有一个很容易被误解的点:一致性快照读 vs 当前读。上面的示例中普通 SELECT 是快照读,不加锁;FOR UPDATE、UPDATE、DELETE 是当前读,必须锁住最新版本的数据。
4. Spring 事务注解的实际用法与失效场景排查全记录
4.1 哪些情况下 @Transactional 是真的不生效
Spring 事务,核心是 @Transactional 注解和事务管理器。实际开发中,注解失效的情况特别多,我把自己排查过的场景全部列一下。
场景一:方法被同类内部调用
java复制@Service
public class OrderService {
@Transactional
public void createOrder() {
// 事务逻辑
updateStock();
}
private void updateStock() {
// 这部分看似在事务里,其实不是
}
}
当一个事务方法调用同类中的另一个方法时,其实是 this 直接调用,绕过了 Spring 的代理对象。@Transactional 是基于 AOP 代理实现的,绕过代理就是绕过事务增强。
解决办法有几种:
- 把内部方法拆到另一个
Service,通过注入的 Bean 调用; - 在类中注入自己的代理对象
@Autowired private OrderService self,然后通过self调用; - 用
TransactionTemplate手动控制事务边界。
场景二:方法不是 public
java复制@Transactional
private void createOrder() {
// 不会生效
}
Spring 的 @Transactional 默认只对 public 方法生效,因为 Spring AOP 在生成代理时,默认会拦截 public 方法。非 public 方法要么改成 public,要么用 AspectJ 织入,但 AspectJ 配置成本高,一般不会这么干。
场景三:异常被 catch 掉
java复制@Transactional
public void createOrder() {
try {
updateStock();
} catch (Exception e) {
log.error("扣库存失败", e);
// 吞掉异常,事务是不会回滚的
}
}
这个坑太经典了。事务的触发条件是方法往外抛异常,如果你把异常吞了,Spring 根本感知不到,事务就正常提交了。正确做法是捕获后抛出 RuntimeException,或者在 catch 里手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
场景四:异常类型不是 Runtime 异常
@Transactional 默认只对 RuntimeException 和 Error 回滚,受检异常(比如 IOException、SQLException)默认不回滚。如果你希望受检异常也回滚,需要在注解上显式声明:
java复制@Transactional(rollbackFor = Exception.class)
场景五:数据库引擎不支持事务
如果表是 MyISAM 引擎,那你用了 @Transactional 也没意义,因为 MyISAM 压根不支持事务。InnoDB 才是事务引擎。做任何事务设计的第一步,先确认表结构。
4.2 排查一条事务不生效问题的完整链路
我把自己处理过的一个实际问题的排查链路写出来,大家可以照着这个思路走。
第一步,检查代码层面。确认 @Transactional 是不是加在 public 方法上,确认调用方是不是通过代理对象调用。我那次的问题出在内部自调用,这是个隐形杀手,代码一眼看过去完全没问题。
第二步,检查 Spring 配置。确认是否开启了事务管理,通常在配置类上有 @EnableTransactionManagement。Spring Boot 默认自动配置,一般不会漏,但如果是老项目的 XML 配置方式,就要看 <tx:annotation-driven/> 有没有加。
第三步,确认数据源是否配置了事务管理器。如果是多数据源场景,DataSourceTransactionManager 必须绑定到正确的数据源上,否则事务管的是另一个库,业务库的操作自然不受控制。
第四步,看日志。把 Spring 事务日志级别调成 DEBUG:
yaml复制logging:
level:
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
然后运行用例,看日志里有没有 Creating new transaction、Initiating transaction commit、Rolling back transaction 这些关键行。如果连 Creating new transaction 都没有,说明代理就没生效;如果有 Commit 没有 Rollback,说明回滚逻辑有问题。
第五步,检查异常是否被吞。在业务代码里搜 catch 块,看看是不是有异常在该抛的时候没抛出去。
5. 事务传播行为:七种行为在真实业务里的调用关系
5.1 七种传播行为的语义对比
@Transactional 里还有一个重要的配置是 propagation,事务传播行为。它解决的是"一个事务方法调用另一个事务方法时,事务边界怎么划分"的问题。
Spring 定义了七种传播级别:
| 传播行为 | 说明 | 典型使用场景 |
|---|---|---|
| REQUIRED | 有事务就加入,没有就新建。默认值,覆盖 90% 场景 | 普通业务方法 |
| REQUIRES_NEW | 无论有没有事务,都新建一个独立事务。外层事务挂起,内层独立提交 | 日志记录、消息推送,不希望受外层回滚影响 |
| NESTED | 有事务就新建一个嵌套事务(Savepoint),没事务就新建一个事务 | 批量操作中部分失败不影响整体 |
| SUPPORTS | 有事务就加入,没有就以非事务方式执行 | 查询方法 |
| NOT_SUPPORTED | 当前方法以非事务方式执行,有外层事务先挂起 | 有锁的查询不想长时间占事务 |
| MANDATORY | 必须有事务,否则报错 | 内部控制方法 |
| NEVER | 必须没有事务,否则报错 | 测试方法或特殊场景 |
默认的 REQUIRED 最符合直觉:多个服务方法调用时,全部在同一个事务里,要么一起成功,要么一起失败。
5.2 真实业务中的传播行为组合
举一个典型的电商下单场景。下单主流程需要在一个事务里完成:扣减库存、创建订单、删除购物车记录;但是下单之后有一波数据埋点日志,这个日志要写库,必须独立于主事务。如果日志写在同一个事务里,主事务回滚的时候日志也会回滚,那问题排查就麻烦了。
这时候日志方法应该用 REQUIRES_NEW:
java复制@Service
public class OrderService {
@Autowired
private LogService logService;
@Transactional(rollbackFor = Exception.class)
public void createOrder() {
// 扣库存
// 创建订单
// 删除购物车
logService.saveLog("user-1", "order-1001", "下单成功");
}
}
@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String userId, String orderId, String content) {
// 写日志,即使外层事务回滚,日志也已经入库
}
}
需要注意的是,REQUIRES_NEW 会挂起外层事务,而外层事务持有的数据库连接并不会释放,所以这里其实多占了一个连接。如果并发量很大,像这种内层独立事务调用要控制频率,否则连接池很容易被打满。
NESTED 和 REQUIRES_NEW 的差别非常大。NESTED 不是真正独立的事务,它是在外层事务里创建一个保存点(Savepoint),内层执行失败回滚时,只回滚到保存点,外层还可以继续提交。而 REQUIRES_NEW 是彻底的新事务,外层回滚不影响它已经提交的数据。我建议:如果你只需要"这一小段失败了,不影响主流程,但整体还能继续",优先用 NESTED,它能避免不必要的连接占用。
5.3 自调用场景下传播行为为什么失效
前面说过同类内部调用会让 @Transactional 失效。对于传播行为也是一样的道理,因为传播行为本质也是靠代理实现的。
比如:
java复制@Transactional
public void methodA() {
methodB();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB() {
// 真实场景里,这里的新事务永远开不出来
}
因为 methodA 和 methodB 在同一个类里,methodA 里的 this.methodB() 是直接调用,没走代理。所以 methodB 上的 REQUIRES_NEW 不生效,它只是 methodA 同一个事务里的普通方法。
要解决,同样是把 methodB 抽到另一个 Service 里,或者像前面说的注入自身代理。
6. 分布式事务的选择:从 XA 到 Seata 再到本地消息表
6.1 为什么单体事务解决不了微服务问题
微服务架构下,一个业务流程要跨多个服务、多个数据库。比如订单服务调用库存服务,如果库存服务的扣减成功了,订单服务却创建失败,数据就不一致了。单体数据库的事务只能管自己的库,跨库、跨服务的原子操作它管不了。
这就是分布式事务要解决的问题。它要保证的是"多个独立的数据库事务,最终在全局视角上体现为原子性"。
6.2 常见的分布式事务方案对比
两阶段提交(2PC)与 XA 协议
XA 是数据库层面的分布式事务协议,它有两个阶段:准备阶段和提交阶段。准备阶段,事务协调者询问所有参与者能否提交,参与者都返回 OK;提交阶段,协调者通知所有参与者提交。任何一方在准备阶段失败,全局回滚。
XA 的好处是强一致性,坏处也很明显:准备阶段锁资源时间太长,性能差;协调者是单点,如果协调者挂了,参与者会一直卡住。所以 XA 一般只适合对一致性要求极高、并发量不大的内部系统。
TCC 模式(Try-Confirm-Cancel)
TCC 是把一个业务分成两个阶段:Try 阶段做资源预留,Confirm 阶段做真正提交,Cancel 阶段做回滚补偿。它不依赖数据库锁,而是靠业务代码实现。
典型例子:扣减账户余额,Try 阶段先冻结 100 元,Confirm 阶段把冻结转为扣除,Cancel 阶段把冻结释放。TCC 的难点在于每个业务操作都要设计好这三个阶段,代码量和工作量大,但性能和可用性比 XA 好。
Seata AT 模式
Seata 的 AT 模式算是对 2PC 的一种工程化改良。业务代码只需要在方法上加 @GlobalTransactional,Seata 会自动生成 undo log,记录修改前后的镜像。事务提交时先做分支事务提交,最后由全局事务协调者判断是否需要回滚。需要回滚时,根据 undo log 自动补偿。
AT 模式对业务侵入小,但是对数据库的读写性能有影响,因为每次操作都要额外记录前后镜像。如果是读写比例高的系统,要注意压测。
本地消息表 + 消息队列
这是最常见的最终一致性方案,也是最容易落地的一种。核心思想是:把一个跨服务的操作,拆成"本地事务 + 消息发送 + 消费重试"。
以订单和库存为例:
- 订单服务在本地事务里创建订单,同时向一张本地消息表写入一条"扣减库存"的消息。
- 本地事务提交后,通过一个后台任务定时扫描消息表,把未发送的消息投递到消息队列。
- 库存服务消费消息,执行扣减,然后通过 ACK 机制或事务消息机制确认处理结果。
- 如果消费失败,消息队列会重试,超过重试次数进入死信队列,人工介入。
这套方案没法做到强一致,但最终能达到数据一致。它的优点是不需要引入独立的事务协调组件,业务代码里只要保证"业务操作和消息入表在同一个事务"。
6.3 订单与库存分布式事务:实际落地方案参考
我之前参与的一个订单系统,最终选型是 Seata AT 模式 + 本地消息表兜底。主要原因是业务需要快速上线,TCC 的开发工作量太大,而 XA 的锁性能撑不住大促流量。
具体做法是:
- 核心链路(创建订单 + 扣库存)用 Seata 的
@GlobalTransactional保证强一致; - 同时给关键操作做幂等,防止重复扣减;
- 非核心链路(比如发短信通知、增加积分)走消息队列最终一致。
最终效果是,平时运行稳定,大促时 Seata 的 undo log 写入量上升明显,性能有一定损失,但整体还能接受。如果让我现在重新选,对于高并发大促场景,我会更倾向于把"创建订单"和"扣减库存"设计成两个独立的事务,用"本地消息 + 重试"来保证最终一致,这样数据库和事务协调器的压力都会小很多。这取决于业务的取舍:强一致优先,还是可用性和性能优先。
7. 我踩过的坑和一套通用的排查思路
7.1 坑一:REQUIRES_NEW 引发的连接池耗尽
某次线上告警,连接池全部被占满,应用无响应。查了一圈,最后发现是一个"数据同步"的定时任务里,在循环中对几千条数据逐条调用了带 REQUIRES_NEW 的方法。
每调用一次,外层事务挂起,内层事务新建,意味着要额外租用一个数据库连接。外层事务本身占用了一个连接,内层每执行一次也要占用一个连接,循环几千次,连接根本没机会及时释放。后续请求全部阻塞在连接池等待上。
后面把方案改成了 NESTED 或彻底去掉内层事务,性能立刻恢复正常。
这个教训是:REQUIRES_NEW 虽然好用,但要警惕它带来的多连接占用。任何事务配置都要考虑连接池的资源上限。
7.2 坑二:长事务导致主从延迟和锁表
有一次我写了一个事务,里面有外部 HTTP 调用,结果外部接口超时 30 秒,整个事务挂了 30 秒。这个事务里更新的那行记录被锁了 30 秒,其他所有需要改这行数据的请求全部堆积,主库的并发瞬间被拖垮,主从延迟也飙到了十几秒。
这是一个特别典型的反面教材:不要在事务里做远程调用、不要做耗时操作。事务里尽量只做数据库操作,包括 RPC、HTTP 请求、消息发送这类操作都应该在事务提交之后再做。你可以把事务提交后要做的事情,通过 Spring 的 TransactionSynchronizationManager.registerSynchronization() 注册成一个回调,在事务提交后触发。
代码大概长这样:
java复制@Transactional
public void createOrder() {
// 扣库存
// 创建订单
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 发 MQ 消息、调用远程接口
}
});
}
7.3 坑三:可重复读下的当前读,间隙锁把范围扩大
默认隔离级别可重复读下,如果执行 UPDATE 或 SELECT ... FOR UPDATE,InnoDB 不仅会锁住命中的行,还会在索引区间插入间隙锁,防止其他事务向这个区间插入数据。
如果 where 条件用了非索引列,或者没有走索引,那么 InnoDB 可能会退化成锁全表。这在分页查询、批量更新场景中特别容易踩。排查死锁日志、锁等待时,尽量先确认 SQL 是否走了正确的索引。
通用排查 SQL 和命令如下:
sql复制-- 查看当前所有事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITs;
-- 查看 InnoDB 引擎状态(能看到最近的死锁日志)
SHOW ENGINE INNODB STATUS;
遇到事务卡死、锁等待超时的时候,第一件事就是用 INNODB_TRX 找到那个长时间未提交的 trx_id,然后结合 PROCESSLIST 找到对应的连接,确认事务是在等锁还是长时间停留。如果是长时间停留,多半是代码里漏了提交或者事务里混入了外部调用。
7.4 一套完整的事务问题排查步骤
我把这些年排障的方法沉淀为下面这几个步骤:
- 拿到告警或业务异常,先看异常栈是锁等待超时还是死锁,还是连接池耗尽。
- 如果是锁等待超时,立刻执行
SELECT * FROM information_schema.INNODB_TRX,找到占锁事务。 - 用
SHOW ENGINE INNODB STATUS看死锁信息,里面会明确给出两条 SQL 和具体的锁模式,定位到代码后再优化。 - 如果是连接池耗尽,先查有没有事务长时间未提交(查
INNODB_TRX),再查有没有REQUIRES_NEW滥用。 - 如果是主从延迟,检查事务执行时间,确认有没有外部调用、批量大事务。
- 最后回归到代码,检查事务边界、传播行为、异常处理、索引是否合理。
我个人建议,每个团队都应该整理一份《事务使用规范》,把下面几条红线写进去:
- 禁止在事务里做 RPC、HTTP、消息发送等耗时操作;
- 事务方法必须 public,必须通过代理调用;
- 捕获异常后必须判断是否需要回滚,需要就重新抛出;
- 除非有明确理由,否则使用默认的 REQUIRED 传播级别;
- 每个事务处理的数据量不要太大,批量任务要分批提交;
- 生产环境修改隔离级别前,必须评估锁范围变化对并发的影响。
做了这么多年开发,我对事务最大的感受是:事务本身并不难理解,难的是在真实环境里,你永远不知道它会被什么奇怪的方式破坏。只有把底层机制弄清楚,把易错点全部总结成规范,踩坑的概率才会明显降下来。
如果你看完这篇文章,能把 redo log 和 undo log 各自负责什么、四种隔离级别有什么区别、Spring 事务为什么会失效、分布式事务又有哪些选择这几个问题捋清楚,那这篇长文的功夫就没白费。后续在实际项目里如果再遇到事务相关的疑难杂症,欢迎回来按我上面这套思路再走一遍。
