“在座各位谁没被线上事务坑过?扣款成功但订单没生成、库存扣了却报库存不足、事务日志突然满掉直接拖垮业务——这些都是我工作里真实踩过的坑。”一谈到 MySQL 事务,很多刚入门的朋友第一反应是“不就是 BEGIN、COMMIT、ROLLBACK 嘛”,但真到线上环境,隔离级别选错了会出脏读,事务开太大了会把回滚段撑爆,Spring 注解一失效整个业务就静默错乱。这篇文章就是基于“mysql 事务”这个核心词,把我这些年积累的 MySQL 事务原理、Spring 事务注解的失效场景、分布式环境下订单与库存的一致性问题、以及事务日志满这类经典故障都串起来讲一遍,新手能从中理清事务的基础框架,有经验的开发也能在某些细节上找到共鸣。
适合谁来读?只要你写业务代码、管数据库、或者在排查线上数据对不上这种问题,这篇文章都值得花十分钟看完。内容不卖弄理论,也不会只丢给你一堆命令,而是尽量说清楚“为什么这么做”和“踩过的坑长什么样”。
1. MySQL 事务基础:从 ACID 说起
我最早接触 MySQL 事务,其实就是被一个转账场景逼着去学的。A 账户扣 1000 块、B 账户加 1000 块,这两步操作如果中间哪一步出了错,钱就凭空蒸发了。事务就是用来保证这种“要么都成功、要么都失败”的机制,这也是它核心的价值。MySQL 里的事务,本质上是一组 SQL 操作的集合,InnoDB 引擎通过日志、锁、MVCC 这些底层机制,把这一组操作打包成一个不可分割的执行单元。
1.1 ACID 四个特性到底在防什么
我们常说的 ACID,是原子性、一致性、隔离性、持久性的缩写。四个特性每个都对应着一类经典问题,理解它们能让你少走很多弯路。
原子性(Atomicity)解决的问题是“没做完怎么办”。一个事务包含多条 SQL,如果执行到第三条挂了,前面两条已经生效的必须要撤销回去,InnoDB 靠 undo log(回滚日志)来实现。每次修改数据之前,先把原始数据写到 undo log 里,一旦事务回滚,就用 undo log 里的旧值把当前值覆盖回来。
一致性(Consistency)是个容易被忽略但很关键的特性。它说的是事务执行前后,数据都必须符合业务定义的约束。比如转账场景里,A 和 B 的总余额不能变;订单场景里,下单后库存加锁定的数量要与订单商品数匹配。注意,数据库层面的主键、外键、约束能帮一部分忙,但真正的一致性逻辑更多要靠业务代码去保证,比如“扣库存前先检查库存是否充足”这种事,数据库管不了。
隔离性(Isolation)解决的是“并发事务互相干扰”的问题。两个事务同时操作同一行数据,如果不做隔离约束,可能出现一个事务读到了另一个事务未提交的数据,或者两个事务同时改了同一行导致结果错乱。InnoDB 通过锁和多版本并发控制(MVCC)来实现不同级别的隔离,这部分我会在第二章详细展开。
持久性(Durability)是最直观的保证:事务提交成功之后,数据就不能再丢了。InnoDB 依赖 redo log 来实现这个保证。很多人不理解为什么 InnoDB 要先把日志写进 redo log buffer 再刷到磁盘,而不是直接改数据文件。原因是磁盘顺序写 redo log 远比随机写数据文件快得多,这种方式叫 WAL(Write-Ahead Logging),先写日志、再写数据,崩溃恢复时靠 redo log 把数据重新补上。
1.2 InnoDB 的事务提交与回滚机制
InnoDB 在 MySQL 里是默认的存储引擎,也是唯一内置事务支持的引擎(MyISAM 老早就被淘汰了,因为它根本没有事务能力)。一个事务从开始到结束,要经历这些阶段。先执行 START TRANSACTION 或者 BEGIN 开启事务,此时事务进入活动状态。接着执行各种 DML 语句,比如 INSERT、UPDATE、DELETE,这些操作会直接修改缓冲池(Buffer Pool)中的数据页,同时生成 undo log 记录旧值。在事务还没有提交之前,这些修改对外部其他事务是不可见的——具体能不能看见取决于隔离级别。
事务提交时,InnoDB 会做两件关键的事:先把变更写入 redo log buffer,之后根据 innodb_flush_log_at_trx_commit 参数的配置来决定什么时候把 redo log 刷到磁盘,然后给数据页打上“已提交”的标记,并清理事务相关的 undo log(有些情况不会立即清理,比如有长事务还在读)。如果是回滚,InnoDB 会从 undo log 里找到修改前的值,逆序回放,把数据恢复到事务开始前的状态。
这里有一个特别容易踩的坑:事务里有一条 UPDATE 更新了 100 万行数据,中途执行到第 80 万行时发现有脏数据或者断言失败,需要回滚。此时回滚的时间可能非常久,因为 InnoDB 需要一条一条用 undo log 回放旧值,期间表上的锁还不会释放,其他查询全部阻塞。我见过一个生产事故,就是一条 UPDATE 误操作后执行回滚,结果整个库卡了将近二十分钟。所以大事务要拆批,一次不要处理太多行,这个教训很深刻。
1.3 事务的启动与结束方式
MySQL 里开启事务主要有三种方式:显式START TRANSACTION或BEGIN;执行 SET autocommit = 0 之后的所有 SQL 都处于一个事务中;或者在客户端工具里关闭自动提交后再执行语句。我强烈建议在业务代码中明确使用 START TRANSACTION 和 COMMIT/ROLLBACK,而不是依赖 SET autocommit=0,因为后者很容易出现“事务开着忘了提交”的低级错误,尤其连接归还到连接池时如果没清理,更会埋下大雷。
事务结束只有两种途径:提交(COMMIT)和回滚(ROLLBACK),另外还有一条隐式提交的野路子需要注意。在 MySQL 中,DDL 语句(CREATE TABLE、ALTER TABLE 等)会隐式提交当前事务,在事务执行到一半时你误跑了一条 DDL,前面的操作就全被固化下来了。有人写个统计脚本,先 UPDATE 数据,再顺手 ALTER TABLE 加个索引,结果 UPDATE 想回滚已经回滚不了了。这种问题在测试环境兑不出来,一上生产就出事,千万别让 DDL 混进业务事务里。
2. 事务隔离级别与锁:并发世界的游戏规则
隔离级别是 MySQL 事务面试中被问烂的问题,但很多开发者只知道要背一个“可重复读是 InnoDB 默认级别”,对于每个级别到底会出现什么问题,以及锁和 MVCC 怎么配合,脑子里其实是模糊的。这一章我结合具体业务例子来把隔离级别讲透,顺带把锁机制和 MVCC 的关系捋清。
2.1 四种隔离级别各解决什么问题
SQL 标准定义了四个隔离级别:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。隔离级别越低,并发性能越高,但出现的异常现象也越多。
读未提交意味着一个事务能读到另一个事务未提交的数据,这会产生脏读。比如事务 A 改了订单金额 1000 还没提交,事务 B 一查发现变成了 1000,拿去做了统计,结果 A 回滚了,B 的统计就是错的。这个级别在生产环境中几乎不用,除非你对一致性毫无要求,且对性能要求极变态(其实也没快多少,因为 InnoDB 在写的时候还是需要加锁)。
读已提交保证了只有提交后的数据才能被其他事务读到,解决了脏读问题。这个级别在 Oracle 和 PostgreSQL 中是默认级别,但 InnoDB 的默认级别却是可重复读。读已提交下仍然有一个问题叫不可重复读:同一个事务里执行两次相同的 SELECT,得到的结果可能不一样。比如你查一笔订单状态是“待支付”,然后另一个事务把这笔订单改成“已支付”并提交,你在这个事务里再查一次,状态就变了。对很多业务来说,这种变化是没法接受的,因为同一事务内要求看到的数据是一致的。
可重复读就是 InnoDB 的默认级别。它解决了不可重复读问题,核心原理是在事务第一次执行查询时生成一个一致性快照(Read View),事务内后续的所有普通查询都基于这个快照,因此看到的数据始终一致。可重复读虽然解决了不可重复读,但没有解决幻读问题——事务里连续两次执行同样的带范围条件的查询,第二次可能多出来一行之前没见过的数据。不过 InnoDB 通过间隙锁(Gap Lock)和临键锁(Next-Key Lock)在大部分场景下把这个坑也堵住了,尤其在高版本的 MySQL 8.0 中,配合默认的可重复读级别,幻读实际上很难出现。但如果你的查询条件走的是非唯一索引或者范围条件,间隙锁依然会生效,此时要注意死锁问题。
串行化是最严格的隔离级别,所有事务都强制串行执行,读写互相阻塞,基本可以理解为给表加了大锁。这个级别能解决一切并发问题,但并发性能几乎归零,生产环境很少使用。如果你的业务对数据一致性要求极高,量又不是特别大,可以短期考虑,但更好的方案应该是通过业务层面设计去规避。
不同隔离级别对应的异常问题可以看下面这张表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 典型场景 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用 |
| READ COMMITTED | 不会 | 可能 | 可能 | 报表统计可以凑合 |
| REPEATABLE READ | 不会 | 不会 | InnoDB 基本防住 | 订单、库存等核心交易 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 极端强一致场景 |
2.2 怎么查看和修改隔离级别
MySQL 8.0 中可以这样查看当前的隔离级别:
SELECT @@transaction_isolation; SELECT @@session.transaction_isolation; SELECT @@global.transaction_isolation;修改隔离级别只需要执行 SET 语句即可:
-- 设置全局(新连接才生效) SET GLOBAL transaction_isolation = 'READ-COMMITTED'; -- 设置当前会话 SET SESSION transaction_isolation = 'REPEATABLE-READ';需要注意的是事务隔离级别在事务执行中途不能修改,必须在事务开启之前设置才生效。很多人在排查问题时,直接执行SET SESSION transaction_isolation='READ-COMMITTED'想改变当前事务,结果发现根本不起作用,就是因为事务已经启动了。
配置隔离级别的另一个坑是和连接池有关。你的应用可能用的是 HikariCP 或者 Druid 连接池,即使你在数据库端把全局隔离级别改成 READ-COMMITTED,连接池在创建新连接时也可以自带初始化 SQL 把会话隔离级别改成它自己配置的值。如果你发现改了数据库全局设置,应用的行为却没变,请检查连接池配置中是否指定了connectionInitSql或类似参数。我在实际项目中遇到过这种“改了却没生效”的鬼事件,最后就是连接池在背后偷偷覆盖了会话设置。
2.3 锁与 MVCC 是怎么配合的
InnoDB 的锁分为共享锁(S Lock)和排他锁(X Lock)。共享锁之间可以兼容,多个事务同时持有共享锁没有问题;共享锁和排他锁互斥;排他锁和排他锁也互斥。普通的 SELECT 不加任何锁,走 MVCC 快照读;但如果 SELECT 后面带上 FOR UPDATE,则加的是排他锁,会阻塞其他事务的写操作。UPDATE、DELETE、INSERT 自动加排他锁。
MVCC(多版本并发控制)的核心是 undo log 版本链和 Read View。每一行数据除了业务字段之外,还有两个隐藏列:trx_id(最近修改这行的事务 ID)和 roll_pointer(指向 undo log 中旧版本数据的指针)。当一个事务做快照读时,它会基于当前数据库里活跃事务的列表生成一个 Read View,然后顺着版本链找到第一个“对当前事务可见”的版本。这就是为什么可重复读级别下,你开了事务之后第一次 SELECT 生成快照,后面所有 SELECT 都基于这个快照,其他事务的提交完全不影响你看到的版本。
锁和 MVCC 的关系很容易理解:MVCC 帮你解决了读不阻塞写、写不阻塞读的问题,而锁负责处理写与写之间的冲突。比如两个事务同时 UPDATE 同一行,那么后到的事务必须等前一个事务提交或者回滚释放锁,否则会一直等待,直到超过innodb_lock_wait_timeout(默认 50 秒)抛出行锁等待超时错误。
关于锁的粒度,InnoDB 支持行级锁,但行级锁本质上其实是索引记录锁。你需要特别注意:如果 UPDATE 语句的 WHERE 条件没有走索引,InnoDB 会锁住全表的记录,相当于把行锁升级成了表锁,性能直接崩掉。“UPDATE 语句为什么把整个表锁住”这类线上问题的排查,十有八九是索引缺失导致的。
3. Spring 事务实战:注解用法与失效避坑
Java 后端的环境里,Spring 事务基本是标配。但 @Transactional 这个注解绝对不是加上就万事大吉了,它在很多情况下会默默失效,而且失效时没有任何报错,业务数据该错还是错,排查起来很考验经验。这一章我重点列举几个最常见的失效场景,这是我在生产环境踩过、帮别人也排查过的真实问题。
3.1 @Transactional 的正确打开方式
先说基础。Spring 管理事务有两种方式:编程式事务(基于 TransactionTemplate)和声明式事务(基于 @Transactional 注解)。日常开发中 90% 的场景用注解就够了,写法很简单:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.getOrder()); stockMapper.deduct(dto.getProductId(), dto.getQuantity()); accountMapper.decrease(dto.getUserId(), dto.getAmount()); } }这个注解只对 public 方法生效,private 方法和 protected 方法都不行。这个限制说白了是因为 Spring 的声明式事务默认基于 Spring AOP,而 AOP 代理本质上是把外部调用拦截到代理对象上。private 方法没法被子类覆盖,所以无法被代理;内部调用(同一个类里 anotherMethod 调用被 @Transactional 标记的方法)也不会走代理,事务就直接被绕过了。如果你想在一个类内部调用带事务的方法,有几种解法:拆到另一个类里、往当前类注入自己(代理对象)、或者用 TransactionTemplate。
另外建议大家把事务注解加在 Service 层的接口实现类上,而不是加在 Controller 上。Controller 层只是做参数接收和响应返回,不该有事务边界。如果 Controller 里开启了事务,一个请求里做了大量只需要读的操作也放进事务,事务时间被拉长,连接占用时间也就变长了,高并发下连接池会被占满。
3.2 事务失效的五个经典场景
第一类是访问权限问题。@Transactional 加在 private 方法上不生效,这个前面说过。很多人明明知道,但随手还是把方法定义成 private 给忽略了。第二类是方法自调用问题。同类中一个普通方法调用了同类中的另一个事务方法,因为内部调用不走代理,事务不生效。我审查代码时见过最多的就是这种情况:一个类写了个 public create() 方法,它内部调用 this.createOrder(),createOrder 上面虽然加了 @Transactional,但根本没起作用。
第三类是 RuntimeException 回滚策略问题。默认情况下 Spring 只在遇到 RuntimeException 或 Error 时回滚事务,受检异常(Exception 的直接子类,如 IOException)不会触发回滚。这个设计是有深意的——受检异常可能表示业务上“可容忍”的情况,比如用户余额不足、库存不够,这些场景你可能希望事务继续提交或者由你自己决定回滚。如果你希望所有异常都回滚,可以在注解上标注rollbackFor = Exception.class。我强烈建议大部分业务方法直接写明@Transactional(rollbackFor = Exception.class),因为你很难预料底层代码会不会抛出一个受检异常,而默认不回滚策略很容易让数据处于半完成状态。
第四类是数据库引擎问题。如果表是 MyISAM 引擎,事务是无效的。现在 MySQL 8.0 默认就是 InnoDB,但有些同学会从老项目里迁移数据,建表语句里带着 ENGINE=MyISAM 没改,事务照样不生效。这个问题排查起来很迷惑,因为 SQL 执行不报错,数据也能写进去,就是事务回滚无效。写代码的兄弟一定要注意检查建表语句的引擎类型。
第五类是事务被 try-catch 吞掉。这是最常见也是最隐蔽的坑。代码里你在 service 方法内写了 try-catch,把异常吃了再打一行日志,Spring 感知不到异常发生,自然不会回滚。这个场景发生的频率高到什么程度?基本上我每次做团队代码评审都会看到几处。处理方案很简单:不要在 service 事务方法内部随意 catch 掉异常,如果有必须处理的辅助逻辑,把异常捕获后重新抛出一个 RuntimeException,或者在 catch 块里调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。我个人更推荐后者,因为前者有时候会把自定义错误的提示信息弄丢。
3.3 事务传播行为速查
Spring 事务传播行为是另一个高频考点。五个常用传播属性建议用一张表总结:
| 传播属性 | 含义 | 适用场景 |
|---|---|---|
| REQUIRED(默认) | 有事务就加入,没有就新建 | 大多数业务方法 |
| REQUIRES_NEW | 挂起当前事务,新建一个事务 | 日志记录、审计 |
| NESTED | 保存点嵌套事务 | 分段操作、部分失败可回退到保存点 |
| SUPPORTS | 有就加入,没有就算了 | 读操作可走可不走事务 |
| NOT_SUPPORTED | 挂起当前事务,不使用事务 | 大查询避免长事务 |
| MANDATORY | 必须存在事务,否则报错 | 断言某方法必须被事务内调用 |
| NEVER | 必须无事务,否则报错 | 只读操作、测试方法 |
REQUIRES_NEW 和 NESTED 的区别值得重点说。REQUIRES_NEW 是物理上完全独立的新事务,内层事务提交或回滚,不影响外层事务的状态。NESTED 则利用数据库的 savepoint(保存点)实现逻辑嵌套,内层事务回滚时只回滚到保存点,不终止外层事务,外层事务后续还可以继续执行和提交。如果内层事务把数据库资源(比如锁)耗尽,外层事务也会受影响,所以 REQUIRES_NEW 更彻底更隔离。业务上比如记录操作日志、发消息这种要“即使主流程失败也要记录”的场景,用 REQUIRES_NEW 最合适。
4. 分布式事务:订单与库存的经典难题
单机数据库事务再复杂,也还是在“一个库”的范围内玩。一旦系统拆成微服务,订单服务用的是订单库,库存服务用的是库存库,一个用户下单的操作要同时扣订单库的余额和库存库的库存,这时候单库事务就完全失效了。跨服务的分布式事务是每个做业务架构的人都得面对的硬骨头,也是最近面试特别爱追问的热点。这一章我把分布式事务的主流方案和适用场景都梳理一遍。
4.1 为什么订单和库存不能一个事务搞定
在微服务架构下,订单服务和库存服务通常是两个独立的服务,各自使用独立的数据库。如果让订单服务直接去操作库存数据库,那就要么把接口敞给订单服务、要么共享数据库——这两种做法都违背了微服务“数据独立”的设计原则。所谓分布式事务,本质上是要在多个独立的数据库连接、多个独立的事务边界之间,达成一个全局一致性的结果。
你可能会想:能不能用本地消息表?能,但这里最大的难点是网络是不可靠的。订单服务调用库存服务扣库存,如果网络超时,订单服务不知道库存到底扣没扣成功;库存服务扣成功了,但调用方收到超时异常,订单服务以为失败,回滚了订单,库存却已经扣了,这就是分布式事务里最典型的“悬挂事务”。再比如库存服务扣库存成功,但响应报文在网络上丢了,重试时又扣一次,导致超扣,这也是常见的“重复扣减”问题。
4.2 分布式事务的主流方案对比
我按实现难度和一致性强度把方案梳理了一下,大家可以根据自己项目的实际情况选型。
第一,两阶段提交(2PC)。这是最经典也最重的方案。它有一个全局协调者(Coordinator),先让所有参与者执行 prepare 阶段,都准备好了再通知所有参与者执行 commit。两阶段提交能保证强一致性,但问题在于协调者单点、prepare 后执行 commit 的过程中参与者宕机,协调者无法确认状态,只能长时间阻塞,整个链路卡死。性能也不行,所以生产环境直接用裸 2PC 的很少,更多是采用对 2PC 做了改良的方案。
第二,AT 模式(Seata AT)。这是阿里 Seata 框架主推的方案。AT 模式本质上是做了增强的 2PC,核心思路是在业务 SQL 执行前后自动生成 undo log(不是数据库里的 undo log,而是 Seata 自己在业务表旁边记录的前后镜像),如果事务需要回滚,就用 undo log 做补偿。对业务侵入小,写起来和本地事务差不多,但如果你对性能要求极高、或者表结构非常复杂,AT 模式的全局锁和前后镜像生成可能有一定开销。
第三,TCC(Try-Confirm-Cancel)。Try 阶段做资源预留,比如“冻结库存”,Confirm 阶段真正扣减,Cancel 阶段释放预留。TCC 对业务侵入比较大,每个参与事务的服务都要实现 try、confirm、cancel 三个接口,代码量翻倍,但性能比 2PC/AT 好,且不需要长事务。它适合那种“资源预留”语义很清晰的核心链路,比如下单扣库存、余额支付。
第四,事务消息(本地消息表 + 消息队列)。这个是我想重点讲的方案,因为它落地相对简单,也适合大部分业务场景。流程大概是这样:订单服务开启本地事务,在业务表里写入订单数据,同时往本地消息表写一条消息记录;本地事务提交后,定时任务或者消息发送组件把本地消息表里的消息投递到消息队列;库存服务消费消息,执行扣库存;如果库存服务执行成功,就消费成功并更新消息状态;如果失败,消息队列会重试,同时可以配合反向消息确认来兜底。
4.3 事务消息的落地经验
事务消息最怕的两件事:消息丢失和重复消费。消息丢失主要在本地事务提交后、投递到 MQ 前这个窗口期。你用 RocketMQ 的话可以直接用它的事务消息机制,事务消息发出去先处于半消息状态,等本地事务提交确认后才允许消费者消费。没有 RocketMQ 这类原生支持时,就只能用本地消息表 + 定时任务扫描发送,这种方式更通用但也更容易有延迟。落地时建议给消息表加状态字段和重试次数字段,重试达到上限后转人工处理。
重复消费这一问题几乎是必然发生的。比如库存服务扣库存成功了,但正准备给 MQ 回 ACK 时应用宕机了,MQ 会认为消费失败继续重投,库存服务又消费了一次,导致库存被扣两次。解决重复消费的唯一有效手段是让消费者具备幂等性。最简单也最可靠的方式是建一张消费记录表,以消息的唯一 ID 作为主键或者唯一索引,消费前先幂等插入,插入成功才执行后续业务逻辑。这个幂等表也可以叫去重表,配合状态字段防止并发重复。
我建议大多数中小团队优先考虑“本地事务 + 消息队列 + 幂等消费”的组合,它不复杂,能覆盖 90% 的异步一致性场景。只有在核心链路的实时性要求特别高、且团队有较强的中间件维护能力时,才去引入 Seata 或 TCC 这类重型方案。分布式事务没有银弹,很多号称“完美的全局事务方案”背后都是在一致性、可用性和性能之间做取舍。
5. 实战经验:事务日志满、大事务与排查技巧
这一章是纯实战向的干货。我把平时线上最容易遇到的三个事务相关问题集中讲,包括事务日志满、大事务危害、以及排查事务问题的一些技巧和工具。每一个都是踩过坑才总结出来的经验。
5.1 事务日志已满或过大的经典案例
“事务日志已满”这个错误很经典,错误文本一般是类似数据库的事务日志已满或者The transaction log for database 'xxx' is full。遇到这种情况,第一反应不是去清日志,而是要先搞清楚日志为什么满。
在 SQL Server 里事务日志满,通常是因为事务日志文件达到最大大小限制,或者所在的磁盘空间不足,而且数据库处于“简单恢复模式”但日志没有被自动截断。但如果你是在 MySQL 环境,也会遇到类似问题,不过通常表现不是报这个错,而是 InnoDB redo log 大小满了导致写不进去。MySQL 的 redo log 是循环写的,如果innodb_log_file_size配置得太小,而事务非常多或非常大,redo log 的写入速度赶不上刷盘速度,就会报错或者数据库性能急剧下降。我在一次压测中就遇到这个问题:持续跑高并发写流量,突然在错误日志里看到了“Log sequence number is in the future”之类的线索,一查发现 redo log 太小了。
日志满这一类问题,处理思路大概三步:第一步先判断是磁盘空间不够,还是日志文件大小达到上限,用df -h看磁盘、用SHOW ENGINE INNODB STATUS或SHOW VARIABLES LIKE 'innodb_log%'看 redo log 相关配置;第二步如果是磁盘不足,清理过期 binlog 或扩容;第三步把innodb_log_file_size调大(MySQL 8.0 里可以通过innodb_redo_log_capacity来配置 redo log 容量,默认 100MB,几百 G 的日志目录很常见)。调大 redo log 需要重启实例,所以建议一开始就留足余量。
5.2 大事务的隐患与避免方法
大事务是指运行时间长、涉及数据量大的事务,比如一个事务里 UPDATE 了几百万行、或者一个事务里循环调用远程接口导致事务一直不提交。大事务最直接的问题是锁占用时间长。事务里的写操作会一直持有锁,直到事务结束,期间其他事务的读写全部被堵住。还有一个隐藏问题:大事务会导致 undo log 膨胀。InnoDB 的 MVCC 依赖 undo log 保存历史版本,事务不提交,undo log 不能清理,久之会撑爆 undo tablespace,甚至影响数据库启动。
我曾经排查过一个案例:一个定时清理任务把清理 SQL 包在大事务里,一次事务删了 500 万条过期数据,结果数据库主库从库延迟从几秒飙到十几分钟,业务接口大批量超时。最终方案是把删除 SQL 改成按主键分批删除,每批 1000 条,每批单独提交一次,配合 sleep 一百毫秒再执行下一批。这样事务时间缩短到毫秒级,对线上基本无感知。
避免大事务的心得:第一,事务里不要做远程调用、不要做耗时的外部 I/O;第二,大批量操作一定要分批执行,分批次提交;第三,明确控制事务的时间窗口,比如对自动任务,加上执行时间的上限监控;第四,核心查询尽量走索引,避免全表扫描把锁范围扩大。
5.3 事务排查常用 SQL 与工具
当你怀疑线上有长事务或锁等待时,用下面几个 SQL 能很快定位问题。先看当前所有正在运行的事务:
SELECT * FROM information_schema.INNODB_TRX\G这条语句会给出事务 ID、事务状态、执行的具体 SQL 语句、锁等待时间等等。如果你想看哪些事务在等待锁、阻塞者是谁,直接看这张表就能定位到。
查看当前锁的情况用:
SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM performance_schema.data_locks;这两张表能告诉你锁的持有者和等待者,以及锁的类型和锁的对象。配合上面的事务表,可以快速判断死锁或者锁等待的根因。
对于死锁,MySQL 默认会在死锁发生时自动回滚代价较小的事务,并把死锁信息记录到错误日志中。你可以直接看错误日志,里面会有类似LATEST DETECTED DEADLOCK的段落。分析死锁时要关注的四个要素:事务 1 持有哪些锁、等待哪些锁;事务 2 持有哪些锁、等待哪些锁;以及它们的加锁顺序差异在哪里。
如果你们用的云数据库或者自建的 MySQL 版本比较新,还可以直接查询 performance_schema 库下的events_statements_current来获取当前正在执行的语句,再结合sys.innodb_lock_waits视图,一条 SQL 就能看到锁等待链条:
SELECT * FROM sys.innodb_lock_waits\G注意这些查询本身也会消耗一点数据库资源,但在问题排查时完全可以接受。平时建议在监控系统里对information_schema.INNODB_TRX做定期的数据采集,这样即使出问题也可以在事后回溯事务的起止时间,不用临时摸瞎。
另外推荐一个小技巧:在测试环境复现锁问题时,把事务的隔离级别统一成生产环境的配置,否则某些低级别下本来不阻塞的问题,生产却因为高隔离级别出现大量锁等待,这会让排查方向跑偏。
我在实际排查事务问题时,体会到的最深的一点是:绝大多数的线上问题都不是某一项配置不够好,而是多个因素叠加导致的。比如 redo log 配小了 + 大事务批量更新 + 连接池不够用,单看任何一个都不致命,但串起来就形成雪崩。处理时要有全局视角,先止血(回滚掉长事务、清理阻塞节点),再根治(调整 SQL、分批执行、调大 redo log 容量),最后做复盘沉淀到团队的开发规范里。
最后分享一个小技巧:如果你在排查 Spring 事务注解是否真的生效,可以临时打开 Spring 的 debug 日志,关注TransactionInterceptor的输出,它会显示每个方法的事务提交和回滚情况。如果发现方法根本没出现在日志里,恭喜你,那大概率就是自调用或者非 public 方法导致的事务失效,对着上面那一节逐个排查就行了。