一个事务没提交,UNDO 表空间撑爆 28G——长事务的隐形代价

上篇讲了 MySQL 同一个事务查了四次数据反复横跳的问题——根因是快照读和当前读的 ReadView 边界不同。这篇我们来看另一个长事务的经典后果:UNDO 表空间撑爆。

以下分析基于 MySQL 8.0.32

现象:28GB UNDO 表空间是怎么发现的

凌晨 3 点,告警群炸了:"MySQL 磁盘使用率 94%"。

登录上去一看,两个 UNDO 表空间撑到了 28GB——正常是 500MB。56 倍的涨幅。按这个增速,不到 40 分钟磁盘写满,数据库将自动进入只读模式。

一个事务跑了 2 小时没提交。

不是它改了多少行——才改了 156 行——是它阻止了 InnoDB 的 purge 线程清理 UNDO 日志。这 2 小时里系统产生的 289 万条 UNDO 记录,全部因为这一个事务被冻结在磁盘上。

场景:一个事务两小时没提交,UNDO 表空间从 500MB 涨到 28GB,磁盘告警 94% 路径:磁盘告警 → UNDO 膨胀定位 → InnoDB UNDO 全景(段/页/purge 线程/truncation) → 排查命令 → 止血方案 → 根源预防

收到磁盘告警

凌晨 3:02,Prometheus 告警:/data/mysql 使用率 94%。

第一反应是 binlog 撑爆了。但 df -h 确认是数据目录,进 MySQL 看一眼:

SELECT file_name, tablespace_name, ROUND(total_extents * 16 / 1024, 1) AS size_mb
  FROM information_schema.innodb_tablespaces
  WHERE tablespace_name LIKE 'undo%';
+-----------------------------+-----------------+---------+
| file_name                   | tablespace_name | size_mb |
+-----------------------------+-----------------+---------+
| ./undo_001                  | innodb_undo_001 | 17248.0 |
| ./undo_002                  | innodb_undo_002 | 11264.0 |
+-----------------------------+-----------------+---------+

28GB。正常情况两个 UNDO 表空间加起来不超过 1GB。

很多人会在这时候直接重启 MySQL。千万别——重启不会清理 UNDO,要等事务回滚完才能释放。

SHOW ENGINE INNODB STATUS 长事务 + 高 HLL

SHOW ENGINE INNODB STATUS\GTRANSACTIONS 段暴露了根因:

History list length 2894563
---
TRANSACTION 2678801, ACTIVE 7821 sec
undo log entries 156
MySQL thread id 452

History list length(HLL)289 万——这是 purge 线程还没回收的 UNDO 日志页数。正常水平:几百到几千。289 万说明清理被彻底阻塞了。

TRANSACTION 2678801 活跃了 7821 秒(2 小时 10 分钟),只改了 156 行。为什么改这么少却阻塞了 289 万条 UNDO 清理?

答案就在 InnoDB 的 UNDO 机制里。理解这个机制,以后遇到类似问题就不需要拍脑袋了。

解析:InnoDB UNDO 全景——为什么一个事务能锁住 289 万条 UNDO

UNDO 日志链:每行记录都有一条"来路"

InnoDB 的每行记录在更新时,旧版本会被写入 UNDO 日志,并通过 DB_ROLL_PTR(回滚指针)链接到聚簇索引记录上。这个指针是物理存在于行记录中的 7 字节字段——你可以理解为每行数据里藏了一个链表节点。

[当前版本: name='Alice V3'] ← DB_ROLL_PTR ──── [UNDO v2: name='Alice V2']
    trx_id=100                                    trx_id=90
                                                          │ DB_ROLL_PTR
                                                          ▼
                                                  [UNDO v1: name='Alice Original']
                                                      trx_id=70

这里有个关键点很多人不知道:INSERT 的 UNDO 和 UPDATE 的 UNDO 行为不同。INSERT 的 UNDO 在事务提交后可以立即清除(因为没有其他事务需要看到"插入前的版本"),而 UPDATE 的 UNDO 必须等所有可能的读取者都看不见它才能清除。这也是为什么批量 UPDATE 比批量 INSERT 更容易撑爆 UNDO 表空间。

这形成了一个 UNDO 历史链表。链表越长,意味着被保留的旧版本越多。在高并发写入的系统里,这条链可能每分钟增长几万条。

UNDO 日志链与 ReadView 阻塞 Purge

Purge 线程的多线程架构

很多人以为 purge 只是一个后台线程默默干活。实际上 MySQL 8.0 的 purge 系统比这复杂得多:

  • Purge Coordinator(协调线程):负责调度,决定何时唤醒 worker
  • Purge Worker(工作线程,最多 4 个,由 innodb_purge_threads 控制):实际执行 UNDO 日志的清理
  • Page Cleaner:负责将清理后的脏页刷盘

三者的协作关系:

事务提交 → UNDO 标记为可清理
         → Purge Coordinator 定期检查(innodb_purge_batch_size 控制每次处理量)
         → 分配任务给 Purge Worker
         → Worker 遍历 UNDO 历史链表,删除旧版本
         → Page Cleaner 将变更刷入磁盘

关键限制:Purge Coordinator 每次最多处理 innodb_purge_batch_size 条 UNDO 日志(默认 300)。如果系统每秒产生 1000 条 UNDO,而 purge 每秒只能处理 300 条,HLL 就会持续增长。但通常这不是问题——长事务才是。

Purge 线程清理 UNDO 的判断条件

UNDO 日志的 trx_id < 系统中最老活跃事务的 trx_id(low_limit_id)

ReadView 是每个事务启动时建立的"快照视图",记录当前所有活跃事务 ID。low_limit_id 是 ReadView 中最大的事务 ID——这意味着在这个事务启动时,所有 trx_id < low_limit_id 的事务要么已提交要么已回滚。

Purge 能清理的极限 = min(全部活跃事务的 trx_id) - 1。

当一个长事务持续不提交:

事务 A (trx_id=2678801): 活跃 2 小时,不提交 ↓
事务 B (trx_id=2678802): 已提交,产生 UNDO
事务 C (trx_id=2678803): 已提交,产生 UNDO
           ...
事务 X (trx_id=2678900): 已提交,产生 UNDO
                         ↓
Purge 只能清理: trx_id < 2678801
                         ↓
事务 B~X 的 UNDO 全部无法清理

289 万条 UNDO 就是这么堆积的。不是 A 改了 289 万行——是 A 不结束,堵住了 200 个后续事务的 UNDO 回收。

ReadView 低水位线阻止 Purge 前进

这里还有一个容易被忽略的细节:RC(READ COMMITTED)和 RR(REPEATABLE READ)的 read view 寿命不同。在 RC 隔离级别下,每条语句执行完就释放 read view;在 RR 下,read view 一直保持到事务结束。这也是为什么 RR 下长事务的 UNDO 膨胀风险远高于 RC——read view 活多久,purge 就被挡多久。

UNDO 表空间为什么会物理膨胀——从段到页的解剖

UNDO 日志在内存中维护链表,但物理存储对应的是 UNDO 表空间里的真实页面。理解这个映射关系很重要:

UNDO 的物理组织(从大到小):

Rollback Segment(回滚段,默认 128 个)
  └── Undo Segment(undo segment,每个段 1024 个 undo page,每个事务至少占 1 个 segment)
        └── Undo Page(16KB,实际存储 UNDO 记录的物理页,一个页可存约 500 条简单 UNDO)
              └── Undo Record(每条 UPDATE/INSERT/DELETE 产生 1 条)

28GB 的组成拆解: - 每个 undo page = 16 KB - 28 GB = 1,835,008 个 undo page - 平均每个事务分配 2-3 个 segment(32-48 个 page) - 2 小时内约 50,000-60,000 个事务产生的 UNDO 被阻塞

理解了这个层级,再看为什么长事务会导致物理膨胀的三个原因:

  1. UNDO segment 不会释放:每个活跃事务至少持有一个 undo segment。长事务不结束,它的 segment 不能回收。这个 segment 的 1024 个 page 都处于"正在使用"状态,truncation 线程无法收缩

  2. 新 segment 持续分配:后续事务的 UNDO 日志需要新的 segment,不断从表空间中分配新的 page。旧 segment 不释放 + 新 segment 不断分配 = 单向增长

  3. 自动 truncation 有条件:MySQL 8.0 的 innodb_undo_log_truncate=ON 默认启用。但 truncation 触发的前提是——undo segment 中没有任何活跃事务。只要长事务存在,至少有一个 undo segment 被它持有活跃状态 → truncation 静默跳过

这就是 28GB 无法自动收缩的根因:truncation 线程想回收,长事务不让。

正常状态:
  Undo Segment [事务A(已提交)] → 可回收
  Undo Segment [事务B(已提交)] → 可回收
  → truncation 触发 → 收缩 UNDO 表空间

长事务状态:
  Undo Segment [事务A(活跃 2h)] → 被锁定
  Undo Segment [事务B(已提交)] → 不可回收(purge 被阻塞)
  Undo Segment [事务C(已提交)] → 不可回收
  → truncation 触发 → 检查发现有活跃 segment → 跳过
  → 继续分配新 segment → 表空间单向增长

UNDO 表空间增长时间线

路径:一套完整的排查工具箱

以下命令可以直接在生产库执行(只读,不锁)。

第一步:定位长事务

最快找到"元凶"的 SQL:

SELECT trx_id, trx_state,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_age_seconds,
       trx_mysql_thread_id,
       trx_query,
       trx_rows_locked, trx_rows_modified
  FROM information_schema.innodb_trx
  WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 300
  ORDER BY trx_started;

重点关注两列: - trx_age_seconds——活跃时长,> 300 秒就值得怀疑 - trx_mysql_thread_id——KILL 时要用到的线程 ID - trx_rows_modified——改了多少行,不是判断长事务的唯一标准(上面那个事务只改了 156 行就阻塞了 289 万条)

定位长事务 SQL

第二步:确认 UNDO 表空间是否在膨胀

SELECT file_name, tablespace_name,
       ROUND(total_extents * 16 / 1024, 1) AS size_mb
  FROM information_schema.innodb_tablespaces
  WHERE tablespace_name LIKE 'undo%';

反复执行几次看 size_mb 是否在增长。如果在增长,说明 HLL 还在上升,purge 跟不上。

第三步:看 HLL 和 purge 状态

SHOW ENGINE INNODB STATUS\GTRANSACTIONS 段,关注三个指标:

指标 正常值 危险值 含义
History list length < 10,000 > 1,000,000 purge 待清理页数
Purge done for trx's n:o < X X 接近 Trx id counter 差距 > 500 purge 落后了多少事务
ACTIVE ... sec < 60 > 600 最老事务活跃秒数

一个实用的技巧:不用盯着 SHOW ENGINE INNODB STATUS 看,直接从 performance_schema 查 HLL:

SELECT variable_value > 1000000 AS hll_alert
  FROM performance_schema.global_status
  WHERE variable_name = 'Innodb_history_list_length';

第四步:InnoDB 监控面板

SELECT name, subsystem, COUNT, STATUS
  FROM information_schema.innodb_metrics
  WHERE subsystem = 'transaction'
    AND name IN ('trx_rseg_history_len', 'trx_undo_slots_used');
  • trx_rseg_history_len:UNDO 历史链表长度(等价于 HLL)
  • trx_undo_slots_used:已分配的 undo slot 数,反映当前活跃事务 + 未清理事务的总量

这两个指标放进 Prometheus/Grafana,做成时间序列趋势图,比看告警阈值更直观——你能看到是突然暴涨还是缓慢爬坡。

InnoDB UNDO 监控指标

重构:止血 + 方案 + 配置

立即止血:KILL 长事务

-- thread_id 来自第一步的 trx_mysql_thread_id
KILL 452;

KILL 后事务进入回滚阶段。回滚时间取决于 trx_rows_modified 和 UNDO 日志量。156 行的回滚可能在几秒内完成,但如果长事务改了 100 万行,回滚可能需要数分钟甚至数小时。

KILL 的三种形式

命令 效果 适用场景
KILL QUERY thread_id 只终止当前查询,事务保持 查询跑太久但事务短
KILL thread_id 终止连接 + 回滚事务 长事务,需要强制结束
KILL CONNECTION thread_id 同 KILL 同上

长事务场景用 KILL {thread_id} 就够了,不需要加 CONNECTION

KILL 之后,UNDO 表空间不会立刻缩小。MySQL 8.0 的 undo truncation 触发时机:事务回滚完成后,purge 追上进度,undo segment 变为 empty 状态,truncation 线程在下一个检查点触发收缩。这个过程通常需要 10-30 分钟。

-- 验证长事务是否已清理
SELECT COUNT(*) FROM information_schema.innodb_trx
  WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 300;

MySQL 5.7 vs 8.0:UNDO 管理的核心差异

很多人说"我用 5.7 没遇到过这个问题"——不是你的系统没有长事务,是 5.7 的 UNDO 默认在系统表空间里,撑爆了 ibdata1 而不是独立的 UNDO 表空间,你根本不知道哪部分是 UNDO 导致的。

维度 MySQL 5.7 MySQL 8.0
UNDO 存储位置 默认在 ibdata1(系统表空间) 独立 undo_001/undo_002 表空间
是否可自动收缩 需手动配置独立 UNDO 表空间 + 开启 truncation 默认开启 innodb_undo_log_truncate=ON
innodb_undo_log_truncate 默认值 OFF ON
UNDO 表空间数量 最多 95 个 最多 127 个
truncation 触发条件 事务结束后、undo segment 不活跃、无 purge 延迟 同上
影响面 UNDO 膨胀影响 ibdata1,连带影响其他系统表空间功能 UNDO 膨胀只影响独立表空间,可单独清理

5.7 迁移到 8.0 后的关键变化:UNDO 从系统表空间独立后,膨胀变得可见了——28GB 的 UNDO 表空间一眼就能看到,而不是藏在 ibdata1 里慢慢涨。这让排查变得容易,但也更容易触发告警。

根本方案一:应用层的事务边界控制

长事务的根因永远是应用代码问题。数据库层能做的只是止血,真正的预防在代码里。

❌ 反模式(事务内有外部调用):
   @Transactional
   public void processOrder(Long orderId) {
       orderService.update(orderId);        // 10ms
       paymentService.callExternalApi();    // HTTP 调用 -> 可能阻塞 30 秒
       logService.save(orderId);            // 5ms
   }

✅ 正确模式(事务只包数据库操作):
   paymentService.callExternalApi();        // 先调外部
   @Transactional(timeout = 30)
   public void processOrder(Long orderId) {
       orderService.update(orderId);        // 10ms
       logService.save(orderId);            // 5ms
   }

事务的黄金法则——"三不":

禁止 原因
❌ 事务内做 RPC/HTTP 调用 网络延迟、对方超时 → 事务挂起
❌ 事务内执行 Thread.sleep() 事务不释放连接,不释放锁,不释放 UNDO
❌ 事务内等待用户输入/消息队列 等待时长不可控

事务超时是保底方案@Transactional(timeout = 30) 确保事务最久不超过 30 秒。但超时不是常规手段——超时的事务一样会产生回滚和 UNDO,只是不让它无限挂下去。

根本方案二:UNDO 表空间配置

MySQL 8.0 虽默认开启自动 truncation,但配置可以再加固:

[mysqld]
innodb_undo_log_truncate = ON
innodb_max_undo_log_size = 2G
innodb_purge_rseg_truncate_frequency = 128
innodb_purge_threads = 4
innodb_purge_batch_size = 300

几个参数的作用:

参数 作用 建议值
innodb_max_undo_log_size 单个 UNDO 表空间大小上限 2G-4G(不要设太大,否则单个表空间太大收缩耗时)
innodb_purge_threads purge 工作线程数 4(8 核以上服务器可设为 8)
innodb_purge_batch_size 每次 purge 处理的最大 UNDO 数 300(默认,高并发写入可调大到 500)
innodb_purge_rseg_truncate_frequency purge 检查 truncation 的频率系数 128(默认,值越小检查越频繁)

MySQL 5.7 注意:innodb_undo_log_truncate 默认 OFF,必须手动开启。而且 5.7 的 UNDO 默认在系统表空间(ibdata1)中,需要先创建独立 UNDO 表空间:

-- MySQL 5.7:创建独立 UNDO 表空间
CREATE UNDO TABLESPACE undo_003 ADD DATAFILE 'undo_003.ibu';

根本方案三:自动化告警

-- P3 告警:存在超过 5 分钟的事务
SELECT COUNT(*) AS long_trx_count
  FROM information_schema.innodb_trx
  WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 300;
-- P2 告警:HLL 超过 100 万(UNDO 可能已开始膨胀)
SELECT variable_value > 1000000 AS hll_alert
  FROM performance_schema.global_status
  WHERE variable_name = 'Innodb_history_list_length';
-- P1 告警:UNDO 表空间超过 10GB(紧急)
SELECT SUM(total_extents * 16 / 1024) > 10240 AS undo_over_10g
  FROM information_schema.innodb_tablespaces
  WHERE tablespace_name LIKE 'undo%';

P3 可以每天发一次报告,P2 触发即时告警,P1 需要值班人员立刻处理。

KILL 长事务前后 UNDO 对比

标记:系统化防御长事务

代码库扫描

# 搜索事务与外部调用共存——这是最常见的长事务根因
grep -rn '@Transactional' --include='*.java' . \
  | grep -E '(http|rest|rpc|sleep|wait|lock)'

数据库层监控

-- 创建定时事件,记录长事务到告警表
CREATE EVENT monitor_long_transactions
ON SCHEDULE EVERY 5 MINUTE
DO
  INSERT INTO dba_alert.log (alert_time, alert_type, detail)
  SELECT NOW(), 'LONG_TRX',
         CONCAT('trx_id=', trx_id, ', age=', 
                TIMESTAMPDIFF(SECOND, trx_started, NOW()), 's, thread=',
                trx_mysql_thread_id)
    FROM information_schema.innodb_trx
    WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 300;

长事务防御清单

层级 措施 优先级
应用层 代码 Review 拦截"事务内 RPC" P0
应用层 @Transactional(timeout=N) 保底 P0
数据库层 innodb_undo_log_truncate=ON 确认 P1
数据库层 UNDO 表空间 + HLL 监控告警 P1
数据库层 长事务 >5 分钟自动告警 P1
运维层 innodb_max_undo_log_size 配置 P2
运维层 5.7 迁移到独立 UNDO 表空间 P2

UNDO 表空间不会无缘无故膨胀。每一 GB 增长的背后,都有一个事务在拽着 purge 线程不让走。

"UNDO 膨胀的原因不是写太多——是太久不提交。"

排查路标:磁盘告警 → 查 innodb_trx 找 HLL + 最长活跃事务 → 确认事务内容 → KILL 止血 → 检查应用代码中的事务内 RPC → 加固 UNDO truncation 配置 → 加长事务监控告警。

这次最坑的地方在哪?那个事务只改了 156 行。新人一看"才改了不到 200 行,不可能导致 28GB",就放过它了。但 UNDO 膨胀不看它改了多少——看它活了多少。一个不变的事务,比一个写得多的事务更危险,因为它挡住的不是自己的 UNDO,是所有人的 UNDO。

下篇我们聊事务隔离级别选错导致的故障——READ COMMITTED 和 REPEATABLE READ 在什么场景下会引发意想不到的问题。