一个事务没提交,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\G 的 TRANSACTIONS 段暴露了根因:
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 历史链表。链表越长,意味着被保留的旧版本越多。在高并发写入的系统里,这条链可能每分钟增长几万条。

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 回收。

这里还有一个容易被忽略的细节: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 被阻塞
理解了这个层级,再看为什么长事务会导致物理膨胀的三个原因:
-
UNDO segment 不会释放:每个活跃事务至少持有一个 undo segment。长事务不结束,它的 segment 不能回收。这个 segment 的 1024 个 page 都处于"正在使用"状态,truncation 线程无法收缩
-
新 segment 持续分配:后续事务的 UNDO 日志需要新的 segment,不断从表空间中分配新的 page。旧 segment 不释放 + 新 segment 不断分配 = 单向增长
-
自动 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 → 表空间单向增长

路径:一套完整的排查工具箱
以下命令可以直接在生产库执行(只读,不锁)。
第一步:定位长事务
最快找到"元凶"的 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 万条)

第二步:确认 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\G 的 TRANSACTIONS 段,关注三个指标:
| 指标 | 正常值 | 危险值 | 含义 |
|---|---|---|---|
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,做成时间序列趋势图,比看告警阈值更直观——你能看到是突然暴涨还是缓慢爬坡。

重构:止血 + 方案 + 配置
立即止血: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 需要值班人员立刻处理。

标记:系统化防御长事务
代码库扫描
# 搜索事务与外部调用共存——这是最常见的长事务根因
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 在什么场景下会引发意想不到的问题。