本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
日志与恢复(redo / undo / binlog / 两阶段提交) · undo log 与回滚

为什么长事务会导致 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 记录。

当一个事务修改数据时,会发生以下过程:

  1. 对该行加排他锁。
  2. 将原始行数据拷贝到 undo log 中,作为旧版本。
  3. 修改行数据,并更新 DB_TRX_ID 为当前事务 ID。
  4. 将 DB_ROLL_PTR 指向刚刚创建的 undo log 记录。

这样,一行数据的多个版本通过 DB_ROLL_PTR 链接起来,形成一个版本链。

图示:版本链

text
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:

  1. 如果 DB_TRX_ID < min_trx_id,说明这个版本在当前事务启动前就已提交,可见。
  2. 如果 DB_TRX_ID >= max_trx_id,说明这个版本是在当前事务启动后才开启的事务创建的,不可见。
  3. 如果 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 状态
T1BEGIN; 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 持续增长,无法回收
TNCOMMIT;可以开始清理被事务 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 表来定位。

sql
1-- 查找运行超过 60 秒的事务
2SELECT
3 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 host
11FROM
12 information_schema.INNODB_TRX
13WHERE
14 trx_started < NOW() - INTERVAL 60 SECOND
15ORDER BY
16 trx_started ASC;

关键字段解读:

  • trx_started: 事务开始时间,这是定位长事务的核心依据。
  • trx_state: 事务状态,通常是 RUNNING。
  • trx_query: 事务中当前正在执行的 SQL。如果为 NULL,通常表示事务已开启,但当前处于空闲状态(例如,客户端执行了 BEGIN; 但迟迟未 COMMIT;)。这是最常见的“坏味道”。
  • trx_mysql_thread_id: MySQL 的连接线程 ID,可以通过 KILL 命令终止该事务。

如何处理和预防?

  1. 处理:

    • 分析:根据查出的 trx_query、user、host 等信息,判断事务来源和目的。
    • 沟通:联系相关业务的开发人员,确认是否可以终止。
    • 终止:确认无误后,执行 KILL <trx_mysql_thread_id>;。注意:KILL 一个执行了大量写操作的事务,会触发长时间的回滚,同样会消耗大量 I/O 和 CPU 资源。
  2. 预防:

    • 监控告警:配置监控系统(如 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 语句设置全局或会话级的超时时间,防止只读查询执行过久。

如何监控 Undo Log 状态?

  • SHOW ENGINE INNODB STATUS;: 在 TRANSACTIONS 部分,有一个 History list length 值。这个值代表了未被 purge 的 undo log 数量。如果该值持续增长,说明 purge 线程受阻,很可能存在长事务。
  • Performance Schema (MySQL 8.0+): 可以通过查询 performance_schema 中的相关表,监控 undo 相关的等待事件和资源消耗,进行更精细的分析。

常见追问与延伸题目

  1. 追问:如果我把隔离级别从 REPEATABLE READ 降为 READ COMMITTED,对这个问题有何影响?

    • 回答要点:会有显著的缓解作用。在 READ COMMITTED 隔离级别下,事务中的每一条 SELECT 语句都会创建自己的新 Read View。这意味着,即使一个事务持续很长时间,只要它在执行新的 SELECT,它的 Read View 就会“前进”,从而释放掉对更早版本 undo log 的依赖。这使得 purge 线程可以更及时地回收空间。相比之下,REPEATABLE READ 在整个事务期间都使用同一个初始 Read View,因此更容易“锚定”旧版本。
  2. 追问:KILL 一个长事务后,为什么数据库负载反而可能升高?这个过程发生了什么?

    • 回答要点:KILL 事务会触发回滚(Rollback)。数据库需要读取该事务产生的 undo log,并执行逆向操作来恢复数据。这个过程:
      • 是单线程的:回滚操作由单个线程完成,无法并行。
      • 消耗大量 I/O 和 CPU:需要读取 undo log 文件,并写入数据文件,会产生大量物理和逻辑读写。
      • 持有锁:在回滚过程中,可能需要获取行锁,这可能会阻塞其他正在等待这些行的事务。
      • 产生 redo log:回滚本身也是一种数据修改操作,其过程也需要被记录到 redo log 中,以保证回滚操作本身是持久的。因此,KILL 一个修改了大量数据的长事务,其回滚过程本身就是一个新的高负载操作。
  3. 追问:除了长事务,还有哪些情况可能导致 undo log 空间无法回收?

    • 回答要点:虽然长事务是最常见的原因,但还有其他可能性:
      • purge 线程处理能力不足:如果系统写入压力极大,产生了海量的 undo log,而 purge 线程的处理速度跟不上生成速度,也会导致 History list length 增长。这可以通过调整 innodb_purge_threads (默认为 4) 来尝试缓解。
      • 系统 Bug:在极少数情况下,可能存在 MySQL 自身的 Bug 导致 purge 过程出现问题。
      • 磁盘 I/O 瓶颈:如果磁盘 I/O 性能极差,purge 线程的读写操作都会变慢,也会导致清理延迟。