为什么长事务会导致 undo log 暴涨?如何排查?
Why do long transactions cause the undo log to grow uncontrollably? How to troubleshoot it?
考察要点
这道题面试官想看你是否深入理解 InnoDB 的 MVCC(多版本并发控制)机制。核心在于,你是否明白 undo log 的双重作用:不仅仅是用于事务回滚,更关键的是为其他事务提供一致性读(Consistent Read)的旧版本数据。因此,长事务的存在会阻止后台 purge 线程回收不再需要的 undo log,导致其持续增长。
核心概念
要彻底理解这个问题,需要掌握以下几个环环相扣的概念:
1. Undo Log 的双重角色
- 事务回滚(Rollback):当事务执行失败或被用户
ROLLBACK时,InnoDB会利用 undo log 中记录的“前镜像”(before-image)数据,将已修改的行恢复到事务开始前的状态。 - 多版本并发控制(
MVCC):在REPEATABLE READ和READ COMMITTED隔离级别下,当一个事务需要读取某行数据,而该行数据正在被另一个未提交的事务所修改时,InnoDB不会去加锁等待,而是通过 undo log 读取该行数据的一个历史版本,从而实现非阻塞的读操作。
2. MVCC 与版本链
InnoDB 为每行数据添加了两个隐藏列:
DB_TRX_ID(6 bytes): 记录最后一次修改该行的事务 ID。DB_ROLL_PTR(7 bytes): 一个回滚指针,指向该行上一个版本的 undo log 记录。
当一个事务修改数据时,会发生以下过程:
- 对该行加排他锁。
- 将原始行数据拷贝到 undo log 中,作为旧版本。
- 修改行数据,并更新
DB_TRX_ID为当前事务 ID。 - 将
DB_ROLL_PTR指向刚刚创建的 undo log 记录。
这样,一行数据的多个版本通过 DB_ROLL_PTR 链接起来,形成一个版本链。
图示:版本链
1 +-----------------+ (DB_ROLL_PTR) +-----------------+ (DB_ROLL_PTR) +-----------------+2 | Row (ver 3) | --------------------> | Undo Log (ver 2) | --------------------> | Undo Log (ver 1) |3 | DB_TRX_ID=105 | | DB_TRX_ID=102 | | DB_TRX_ID=99 |4 +-----------------+ +-----------------+ +-----------------+5 (Current Data) (Old Version) (Older Version)
3. Read View(一致性视图)
当一个事务首次执行 SELECT 语句时(在 REPEATABLE READ 级别下),InnoDB 会创建一个名为 Read View 的快照。Read View 包含了在创建它时,系统中所有活跃(未提交)的事务 ID 列表。其核心组件是:
m_ids: 创建 Read View 时,活跃的事务 ID 列表。min_trx_id:m_ids中的最小值。max_trx_id: 创建 Read View 时,系统下一个将要分配的事务 ID。
可见性判断规则:当事务访问某行数据时,会比较该行版本的 DB_TRX_ID 和当前事务的 Read View:
- 如果
DB_TRX_ID<min_trx_id,说明这个版本在当前事务启动前就已提交,可见。 - 如果
DB_TRX_ID>=max_trx_id,说明这个版本是在当前事务启动后才开启的事务创建的,不可见。 - 如果
min_trx_id<=DB_TRX_ID<max_trx_id,则检查DB_TRX_ID是否在m_ids列表中:- 如果在,说明创建该版本的事务在当前事务启动时仍活跃,不可见。
- 如果不在,说明创建该版本的事务在当前事务启动前已提交,可见。
如果某个版本对当前事务不可见,它就会沿着 DB_ROLL_PTR 指向的版本链去寻找上一个版本,重复此过程,直到找到一个可见的版本为止。
4. Purge 线程与长事务的冲突
- Purge 线程:是 MySQL 的一个后台线程,专门负责清理不再被任何事务所需要的 undo log 记录,以回收磁盘空间。
- 清理的条件:一个 undo log 版本可以被清理,当且仅当系统中不存在任何 Read View 需要访问它。换言之,修改该版本的事务(
DB_TRX_ID)必须小于现存最老的 Read View 的min_trx_id。
核心矛盾:
一个长事务(比如一个开启后长时间未提交的事务)会持有一个非常旧的 Read View。这意味着它的 min_trx_id 会非常小。即使后续有成千上万的新事务启动、修改数据并提交,它们产生的 undo log 版本链也不能被 purge 线程回收,因为这个长事务的旧 Read View 理论上仍有可能需要访问这些旧版本。
示例:
| 时间 | 事务 A (ID=100, 长事务) | 事务 B (ID=101) | 事务 C (ID=102) | Purge 线程 | Undo Log 状态 |
|---|---|---|---|---|---|
| T1 | BEGIN; SELECT ...; (创建 Read View,min_trx_id=100) | 空 | |||
| T2 | (空闲) | BEGIN; UPDATE t SET c=2 WHERE id=1; COMMIT; | 生成 id=1 的旧版本 undo log | ||
| T3 | (空闲) | BEGIN; UPDATE t SET c=3 WHERE id=1; COMMIT; | 生成 id=1 的更新版本 undo log | ||
| T4 | (空闲) | 尝试清理 | 无法清理,因为事务 A 的 Read View 仍可能需要看到 T2 之前的数据 | ||
| ... | (持续空闲) | (大量新事务) | (大量新事务) | 持续被阻塞 | undo log 持续增长,无法回收 |
| TN | COMMIT; | 可以开始清理被事务 A 阻塞的所有 undo log |
最终,只要长事务不结束,它所持有的 Read View 就像一个"锚",阻止了 purge 线程的清理工作,导致 undo log 文件(或表空间)持续膨胀。
常见低分回答
-
回答 1:只提回滚
“长事务执行了大量的 DML 操作,所以产生了大量的 undo log 用于回滚。” 点评:这个回答只说对了一半。它解释了长事务自身产生 undo log 的情况,但完全忽略了长事务作为“观察者”时,如何阻止其他事务产生的 undo log 被清理,这才是导致 undo log 暴涨的主要原因。一个
SELECT类型的长事务自身不产生 undo log,但同样会引发此问题。 -
回答 2:概念混淆
“长事务会锁住很多行,导致 undo log 无法清理。” 点评:将“锁”和“
MVCC版本”混为一谈。锁是用于并发控制的,防止写-写冲突;而 undo log 版本是用于实现一致性读的。长事务不一定持有锁(如一个纯SELECT事务),但它一定持有 Read View,是 Read View 阻止了清理,而非锁。 -
回答 3:不知如何排查
“undo log 涨了,可能是数据库有长事务,需要 DBA 去看看。” 点评:过于笼统,没有体现出工程师解决问题的能力。面试官期待你能给出具体的 SQL 查询和排查步骤,展现你的动手能力和对
information_schema的熟悉程度。
工程实践与优化
如何排查长事务?
主要通过查询 information_schema.INNODB_TRX 表来定位。
1-- 查找运行超过 60 秒的事务2SELECT3 trx_id,4 trx_state,5 trx_started,6 TIMEDIFF(NOW(), trx_started) AS duration,7 trx_mysql_thread_id,8 trx_query,9 user,10 host11FROM12 information_schema.INNODB_TRX13WHERE14 trx_started < NOW() - INTERVAL 60 SECOND15ORDER BY16 trx_started ASC;
关键字段解读:
trx_started: 事务开始时间,这是定位长事务的核心依据。trx_state: 事务状态,通常是RUNNING。trx_query: 事务中当前正在执行的 SQL。如果为NULL,通常表示事务已开启,但当前处于空闲状态(例如,客户端执行了BEGIN;但迟迟未COMMIT;)。这是最常见的“坏味道”。trx_mysql_thread_id: MySQL 的连接线程 ID,可以通过KILL命令终止该事务。
如何处理和预防?
-
处理:
- 分析:根据查出的
trx_query、user、host等信息,判断事务来源和目的。 - 沟通:联系相关业务的开发人员,确认是否可以终止。
- 终止:确认无误后,执行
KILL <trx_mysql_thread_id>;。注意:KILL一个执行了大量写操作的事务,会触发长时间的回滚,同样会消耗大量 I/O 和 CPU 资源。
- 分析:根据查出的
-
预防:
- 监控告警:配置监控系统(如 Prometheus + Grafana),定期检查
information_schema.INNODB_TRX,对超过阈值(如 60 秒)的事务进行告警。 - 应用侧优化:
- 避免交互式事务:确保应用代码中,事务块(
BEGIN/COMMIT)内不包含等待用户输入、RPC 远程调用等耗时操作。 - 缩短事务周期:遵循“小而快”的原则,让事务尽快提交或回滚。
- 大批量操作分批处理:对于大批量的
UPDATE或DELETE,应拆分成多个小事务分批执行,避免单个事务过大过长。
- 避免交互式事务:确保应用代码中,事务块(
- 数据库参数配置:
innodb_undo_log_truncate(MySQL 5.7+): 开启此参数(默认 ON),当 undo 表空间超过innodb_max_undo_log_size时,MySQL 会尝试在线收缩 undo 表空间。但这只是事后补救,不能解决长事务本身的问题。max_execution_time(MySQL 8.0+): 可以为SELECT语句设置全局或会话级的超时时间,防止只读查询执行过久。
- 监控告警:配置监控系统(如 Prometheus + Grafana),定期检查
如何监控 Undo Log 状态?
SHOW ENGINE INNODB STATUS;: 在TRANSACTIONS部分,有一个History list length值。这个值代表了未被purge的 undo log 数量。如果该值持续增长,说明purge线程受阻,很可能存在长事务。- Performance Schema (MySQL 8.0+):
可以通过查询
performance_schema中的相关表,监控 undo 相关的等待事件和资源消耗,进行更精细的分析。
常见追问与延伸题目
-
追问:如果我把隔离级别从
REPEATABLE READ降为READ COMMITTED,对这个问题有何影响?- 回答要点:会有显著的缓解作用。在
READ COMMITTED隔离级别下,事务中的每一条SELECT语句都会创建自己的新 Read View。这意味着,即使一个事务持续很长时间,只要它在执行新的SELECT,它的 Read View 就会“前进”,从而释放掉对更早版本 undo log 的依赖。这使得purge线程可以更及时地回收空间。相比之下,REPEATABLE READ在整个事务期间都使用同一个初始 Read View,因此更容易“锚定”旧版本。
- 回答要点:会有显著的缓解作用。在
-
追问:
KILL一个长事务后,为什么数据库负载反而可能升高?这个过程发生了什么?- 回答要点:
KILL事务会触发回滚(Rollback)。数据库需要读取该事务产生的 undo log,并执行逆向操作来恢复数据。这个过程:- 是单线程的:回滚操作由单个线程完成,无法并行。
- 消耗大量 I/O 和 CPU:需要读取 undo log 文件,并写入数据文件,会产生大量物理和逻辑读写。
- 持有锁:在回滚过程中,可能需要获取行锁,这可能会阻塞其他正在等待这些行的事务。
- 产生 redo log:回滚本身也是一种数据修改操作,其过程也需要被记录到 redo log 中,以保证回滚操作本身是持久的。因此,
KILL一个修改了大量数据的长事务,其回滚过程本身就是一个新的高负载操作。
- 回答要点:
-
追问:除了长事务,还有哪些情况可能导致 undo log 空间无法回收?
- 回答要点:虽然长事务是最常见的原因,但还有其他可能性:
purge线程处理能力不足:如果系统写入压力极大,产生了海量的 undo log,而purge线程的处理速度跟不上生成速度,也会导致History list length增长。这可以通过调整innodb_purge_threads(默认为 4) 来尝试缓解。- 系统 Bug:在极少数情况下,可能存在 MySQL 自身的 Bug 导致
purge过程出现问题。 - 磁盘 I/O 瓶颈:如果磁盘 I/O 性能极差,
purge线程的读写操作都会变慢,也会导致清理延迟。
- 回答要点:虽然长事务是最常见的原因,但还有其他可能性: