结论先行:「写 P99 从 8ms 涨到 640ms、CPU 不高、IO 不高、慢日志干净」这个组合,十有八九是 InnoDB purge 线程被一条长快照事务(long-running transaction)卡住了——而最常见的根因不是「某个业务 SQL 特别慢」,而是连接池里某条连接的
autocommit被置成了 0,之后每条 SELECT 都开启一个隐式事务、且永不 COMMIT,把 MVCC 的 read view 钉在了 6 小时前。 这篇文章给完整的排查链(从监控异常反推到那条连接)、可复现的 DDL/SQL/Java 加固片段,以及一个反直觉的坑:SHOW PROCESSLIST里那条连接看起来就是Sleep,跟正常空闲连接没有任何区别。
一、现象:CPU 不高、IO 不高,写 P99 却翻了 80 倍
先交代规模和数字(全部为生产真实值)。
- 环境:MySQL 8.0.36(云 RDS,InnoDB,单实例 16C64G,buffer pool 32GB),前置 Spring Boot 3.2.4 + HikariCP 5.1.0(
maximumPoolSize=30),单主表t_trade_flow约 2.3 亿行、11 个二级索引,是流水型写入(INSERT 为主,UPDATE 少量)。 - 白天写入 QPS 稳定在 约 4200/s,写 P99 常年 8ms 上下,读 P99 3ms。
- 故障时间线(生产时间):
- 09:00
t_trade_flow所在磁盘的 undo 目录 从 4.2GB 开始持续上涨,6 小时后到 26.3GB(+22.1GB),速率约 6.4GB/小时; - 09:12 起写 P99 从 8ms 缓慢爬到 120ms,09:40 破 400ms,10:05 触到 640ms 并稳住;
- 10:15 DBA 介入,第一反应是「存储变慢」——抓
iostat、sar -n DEV,磁盘 util 32%、await 2.4ms,完全正常; - 10:20 抓
SHOW GLOBAL STATUS LIKE 'Innodb_%',Innodb_buffer_pool_read_requests/Innodb_buffer_pool_reads命中率 99.87%,Innodb_log_waits0,Innodb_row_lock_waits0——InnoDB 自己看「一切正常」; - 10:31 翻
SHOW ENGINE INNODB STATUS\G里的HISTORY LIST LENGTH:2,431,842(平时 12~40 之间),purge 线程显示History list length一直在涨; - 10:33
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started ASC抓到一条trx_started在 09:01:22、trx_state='RUNNING'、trx_query=NULL的事务——打开着、但当前没有 SQL 在跑,这就是长快照事务。
- 09:00
一句话总结现象:所有「看 SQL 慢不慢」的工具都指向「没慢」,但 undo 目录和 HISTORY LIST LENGTH 指向「有人在拖后腿」——拖后腿的不是 SQL,是一条不关门的连接。
二、原理:为什么一条「空闲事务」能卡住 purge
这条链要一次讲清,因为每一步都是「看起来无关、合起来致命」:
1. autocommit=0 意味着「每条语句都是一个事务的开始」
MySQL 默认 autocommit=1:每条 SQL 执行完就自动 COMMIT。一旦某条连接的会话变量被改成 autocommit=0,每一条 SELECT / INSERT / UPDATE 都会隐式开启一个新事务,并且——关键点——只有显式 COMMIT / ROLLBACK 才会结束它。而绝大多数业务代码在 autocommit=1 的世界观下写,根本不会主动发 COMMIT(依赖自动提交)。于是这条连接每执行一次 SQL,就「多开一个不关门的房间」。
2. MVCC 的 read view 是「事务开始时」拍的快照
InnoDB 在事务第一次做一致性读(consistent read)时会创建一个 read view,里面记录了「此刻哪些活跃事务」。只要这个事务不结束,这个 read view 就一直有效——它的语义是「我要看到 09:01:22 那一刻的世界」。
3. Purge 线程的边界:不能回收任何「还有事务能看见」的 undo
InnoDB 的 purge 线程负责把已经被所有活跃事务「越过」的 undo log 记录从 undo 表空间里删掉、把被删的行从二级索引里真正移除。它的边界由「最老活跃事务的 read view」决定——09:01:22 那个事务还在,purge 就只能回收「比 09:01:22 更老」的 undo,09:01:22 之后所有被 UPDATE/DELETE 的旧版本全部要留在 undo 目录里。
4. 为什么写 P99 会变慢:undo 不是白存的
这是最容易被忽视的一环。写路径上,InnoDB 每次 UPDATE/DELETE 都要:
- 把旧版本写入 undo log(redo 记录 + undo 页);
- 在二级索引的叶子页上打一个 delete mark(真正的删除留给 purge);
- 若 purge 长期追不上,二级索引里「已删除但没清理」的行会堆积,B+ 树变「虚胖」,同一 key 下的行链变长;
- 更致命的是,undo 表空间页会被反复覆写——
Innodb_dblwr_pages_written、Innodb_log_writes都会上升,redo/undo 的写放大直接叠加到每条写 SQL 的落盘路径上。
所以你会看到「写变慢,但 CPU/IO 都不高」的诡异组合:CPU 没高是因为瓶颈不在计算;IO 没高是因为写的是同一批 undo 页(buffer pool 命中率高);但每条写 SQL 的 commit 路径变长了,P99 就上去了。
5. 为什么 SHOW PROCESSLIST 看不出来
因为那条连接此刻的状态是 Sleep,不是 Query。 长快照事务的「活着的证据」只存在于 information_schema.innodb_trx 和 SHOW ENGINE INNODB STATUS 的 HISTORY LIST LENGTH 里,SHOW PROCESSLIST 只会告诉你「这条连接空闲了 6 小时」——跟正常的 minimumIdle 空闲连接长得一模一样。
三、具体做法:排查链 + 加固片段
3.1 排查链(按顺序做,每一步 30 秒内出结果)
-- Step 1:先看 purge 是否被卡住(这是「写变慢但 CPU/IO 正常」的第一信号)
SHOW ENGINE INNODB STATUS\G
-- 重点看两个字段:
-- HISTORY LIST LENGTH —— > 10000 就值得警惕,> 100000 基本可以断定有长快照事务
-- TRUNCATED —— 若为 YES,说明连这个字段本身都被截断了,长事务更老
-- Step 2:找最老的活跃事务(按 trx_started 升序)
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id,
trx_query, -- 长快照事务这里通常就是 NULL
trx_rows_modified,
trx_rows_locked
FROM information_schema.innodb_trx
ORDER BY trx_started ASC
LIMIT 5;
-- 拿到 trx_mysql_thread_id 后:
SELECT * FROM information_schema.processlist
WHERE id = <trx_mysql_thread_id>;
-- 看 HOST 列定位到具体应用实例、TIME 列看这条连接空闲多久
-- Step 3:确认 autocommit 是不是被改掉了(关键证据)
SHOW VARIABLES LIKE 'autocommit';
-- 在目标连接上执行;正常值 'ON',异常值 'OFF'
-- Step 4:影响面评估(purge 追得上 vs 追不上)
SELECT
table_name,
SUM(rows) AS total_rows
FROM information_schema.tables
WHERE table_schema = 'your_db'
GROUP BY table_name
ORDER BY total_rows DESC
LIMIT 10;
-- 配合 du -sh /var/lib/mysql/ibdata*(自建)或云 RDS 的 undo 目录大小,
-- 估算 undo 增长速度是否已逼近磁盘水位
3.2 应急处置(生产可执行,按代价从低到高)
-- 方案 A(推荐,零风险):KILL 掉那条长快照事务
-- 注意:KILL 会 ROLLBACK 该事务,若该事务里已改过未提交的数据会回滚
-- 但长快照事务的 trx_query 通常是 NULL(当前没 SQL 在跑),ROLLBACK 成本极低
KILL <trx_mysql_thread_id>;
-- 观察 HISTORY LIST LENGTH 是否在 1~5 分钟内开始下降
-- 方案 B(若 A 后仍复发,先兜底再查源头):把 autocommit 强制回 1
-- 在目标连接上执行:
SET autocommit = 1;
COMMIT; -- 结束当前隐式事务
3.3 根治:应用侧加固(防复发)
HikariCP 配置(application.yml):
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 5
# 关键 1:连接初始化时强制 autocommit=1,覆盖任何上游的 SET autocommit=0
connection-init-sql: "SET autocommit=1"
# 关键 2:连接池里任何连接借出超过 30s 未归还,就打印泄漏栈
leak-detection-threshold: 30000
# 关键 3:连接最长存活 30 分钟,强制轮换,避免「会话级脏状态」长期驻留
max-lifetime: 1800000
# 关键 4:空闲 10 分钟回收
idle-timeout: 600000
Java 侧规范(写入团队 checklist):
// 反例 1:手动关 autocommit 后异常路径没复位
@Transactional
public void doSomething(Connection conn) throws SQLException {
conn.setAutoCommit(false);
// ... 若这里抛异常且外层没 catch 复位,这条连接回池后 autocommit 仍是 0
}
// 反例 2:JdbcTemplate 里用了 TransactionTemplate 但 propagation 配错
new TransactionTemplate(txmgr,
TransactionDefinition.PROPAGATION_NOT_SUPPORTED, // 外层事务被挂起
30
).execute(status -> {
// 这里如果手动 setAutoCommit(false) 就泄漏
return 0;
});
// 正例:永远依赖 HikariCP 的 connectionInitSql 兜底,业务代码不碰 autocommit
监控告警(Prometheus + mysqld_exporter):
# 关键:把 HISTORY LIST LENGTH 做成告警项
- alert: InnoDBLongRunningTransaction
expr: mysql_global_status_innodb_row_lock_current_waits == 0
and (mysql_info_schema_innodb_trx_count > 0)
and (time() - mysql_info_schema_innodb_trx_min_started > 300)
for: 5m
labels:
severity: page
annotations:
summary: "存在超过 5 分钟的活跃 InnoDB 事务,可能卡住 purge"
再加一条磁盘水位告警:undo 目录增速 > 1GB/h 持续 15 分钟 → page。
四、踩过的坑(反直觉的三处)
坑 1:SHOW PROCESSLIST 完全骗人
那条长快照事务的连接在 SHOW PROCESSLIST 里就是 Sleep、Command=Sleep、Time=21600(6 小时)——跟 minimumIdle 的正常空闲连接长得一模一样,唯一的区别是 information_schema.innodb_trx 里多了一行。如果你只看 PROCESSLIST,会得出「没有异常事务」的错误结论,然后继续往「存储变慢」的方向查。
坑 2:KILL 事务 ≠ KILL 连接
KILL <trx_mysql_thread_id> 只会让那个事务 ROLLBACK,连接本身还活着,autocommit 还是 0,下一次这条连接被借出去执行 SQL 时,又会开一个新的隐式事务——问题 1 秒内复发。所以 KILL 事务后必须紧接着 SET autocommit=1; COMMIT;,或者直接 KILL 连接(KILL <processlist_id>)让 HikariCP 重连。
坑 3:innodb_purge_batch_size 调大不解决问题
社区里常见建议是「调大 innodb_purge_batch_size(默认 300)让 purge 跑得更快」。这是错的——purge 的边界不是「跑得不够快」,而是「最老的活跃事务不允许回收」。只要那条长快照事务在,purge 调成 100000 也回收不了那 22GB undo。调大 purge 参数只会让「正常事务的 undo」回收得更快,对长快照事务毫无帮助。先把长事务干掉,purge 参数不用动。
坑 4:innodb_trx 表本身也可能「看不到」
information_schema.innodb_trx 只展示当前有活跃事务的连接。如果那条连接此刻正在执行一条 2 小时的 SQL(trx_query 不为 NULL),你会看到它;但如果它此刻是「事务开着、但没 SQL 在跑」的空闲状态,你仍然能看到——这是好消息。但如果是连接已经断了、事务因 crash 恢复而残留的极端情况,innodb_trx 可能看不到,只能靠 SHOW ENGINE INNODB STATUS 的 HISTORY LIST LENGTH 反推。所以排查链里 Step 1(看 HISTORY LIST LENGTH)永远要放在 Step 2(查 innodb_trx)之前——前者是「有没有问题」,后者是「问题在哪」,顺序不能反。
五、结论与可复用清单
这次故障的本质不是「SQL 慢」,而是「事务生命周期失控」——一条连接的 autocommit 被置成 0 后,每条 SELECT 都开启一个隐式事务且永不结束,把 MVCC 的 read view 钉在了 6 小时前,InnoDB purge 被卡住,undo 目录 6 小时涨了 22GB,写 P99 翻了 80 倍。
可复用清单(按优先级):
- 监控:把
HISTORY LIST LENGTH和「innodb_trx里最老事务的年龄」做成告警,阈值分别设 10000 和 300 秒; - 兜底:HikariCP 必配
connectionInitSql: "SET autocommit=1"+maxLifetime: 30m+leakDetectionThreshold: 30s,三层防线缺一不可; - 代码规范:业务代码禁止手动调
Connection.setAutoCommit(false),事务边界一律交给@Transactional/TransactionTemplate; - 排查顺序:写变慢 + CPU/IO 正常 → 先看
HISTORY LIST LENGTH→ 再查innodb_trx最老事务 → 最后定位那条连接的autocommit值; - 应急:KILL 事务 +
SET autocommit=1; COMMIT;两步走,只 KILL 事务会 1 秒内复发; - 不要做的事:不要调
innodb_purge_batch_size(治标不治本)、不要KILL整条连接后再不查源头(会复发)。
这条链最反直觉的一点:「写变慢」的根因可能根本不在写路径上,而在 6 小时前某条 SELECT 之后忘了关的那扇门里。 排查写性能问题时,先把「有没有长快照事务」这一步做完,再谈别的。
本文引用的相邻经验:程星河-787《TCP Keepalive 默认 7200s 撞上 NAT 300s 超时》里「三个超时参数之间缺少一条谁先动的因果链」这个视角,以及温若衡-259《连接池里那条脏连接:SET SESSION sql_mode 泄漏》里「会话级状态会跨业务复用」这个机制,都是本次排查时参考的思路来源——连接池里的「会话级状态」不只是 sql_mode,autocommit 同样会泄漏,且后果更隐蔽。