History list 只涨不降:先看 innodb_trx 的 trx_started,再谈 purge
现象
SHOW ENGINE INNODB STATUS 的 TRANSACTIONS 段落里,History list length 一路上涨,业务低峰也不回落,重启实例才清零。伴随几个表象:
- undo 表空间文件持续变大,
innodb_undo_log_truncate=ON也没能截断; - 表数据文件膨胀,purge 追不上,磁盘 IO 吃紧、写入变慢;
- 个别原本走覆盖索引的查询突然要回表,执行计划没变但
rows_examined涨了。
这几种现象共同指向一件事:某些行的旧版本(update undo)没有被丢弃,而丢弃的前提是没有任何快照还可能读它。
先记住判据:先排 information_schema.INNODB_TRX 里最老的 trx_started,再谈 purge 参数。purge 线程数不够是少见情况,长事务钉住版本链才是常态。
行版本到底是什么
InnoDB 是多版本存储引擎,为已修改的行保留旧版本信息,用于并发读和回滚,这部分信息放在 undo 表空间的回滚段里。
行本身不会被复制成多份。每行内部只有三个隐藏字段:
| 字段 | 长度 | 含义 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最后插入或更新该行的事务 ID;DELETE 在内部按 update 处理并置删除标记位 |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向回滚段中的 undo 日志记录;行被更新时,该 undo 记录包含重建更新前内容所需的信息 |
DB_ROW_ID | 6 字节 | 单调递增行 ID,仅在 InnoDB 自动生成聚簇索引时出现在索引里 |
也就是说,聚簇索引记录是原地更新的,DB_ROLL_PTR 指向的 undo 里存的是前镜像差分,读旧版本时顺着指针回溯。
undo 分两类:
- insert undo:只在回滚时需要,事务提交即可丢弃;
- update undo:还要供一致性读使用。只有当不存在「InnoDB 已为其分配快照、且该快照在一致性读中可能需要该 undo」的事务时,才能丢弃。
官方文档明确建议定期提交事务,包括只发一致性读的事务,否则 InnoDB 无法丢弃 update undo,回滚段可能把 undo 表空间撑满。
删除同样不立即物理删除。只有丢弃该删除对应的 update undo 记录时,行和索引记录才真正物理删除,这个动作叫 purge,通常和一条 DELETE 语句耗时同量级。
二级索引的处理和聚簇索引不同:二级索引记录没有隐藏系统列,也不原地更新。索引列被更新时,旧记录被 delete-mark、新记录插入,被标记的记录最终由 purge 清理。当二级索引记录被标记删除、或索引页被更新事务改过时,InnoDB 要回聚簇索引查该记录的 DB_TRX_ID,若该值在读取事务开始后被改过,就得从 undo 取正确版本——此时覆盖索引失效,必须回表。
谁在钉住 undo:ReadView
一个快照(ReadView)的关键字段:
| 字段 | 含义 |
|---|---|
m_ids | 活跃(未提交)事务 ID 列表 |
m_up_limit_id | m_ids 的最小值;m_ids 为空时等于 m_low_limit_id |
m_low_limit_id | 下一个将分配的事务 ID |
m_creator_trx_id | 创建该 ReadView 的事务 ID |
可见性判断:
trx_id == m_creator_trx_id:可见;trx_id = m_low_limit_id:不可见;- 介于两者之间:在
m_ids内不可见,不在则可见。
RC 隔离级别下每条快照读都新建 ReadView;RR 只在事务第一次快照读时建一次,之后复用(事务内发生过当前读之后,下一次快照读在部分实现里会重建,具体行为建议自行验证)。当前读——SELECT ... FOR UPDATE / LOCK IN SHARE MODE、UPDATE、DELETE、INSERT——读最新已提交版本并加锁,不走 ReadView 的可见性判断。
事务开启序上有一点值得记住:读写事务启动时拿 trx id,结束时拿 no 作为提交序;活跃读写事务集合在 trx_sys->rw_trx_ids,建立快照要在这个 mutex 保护下复制活跃列表。所有 open 的快照挂在 MVCC::m_views 链表上,最老快照就是链表里第一个 open 的快照(快照创建是串行的)。所以只要有一个长事务还抱着旧快照,比它新的 undo 就一律不能丢。
定位顺序与可执行 SQL
第一步:确认 history list 的量级
SHOW ENGINE INNODB STATUS\G
-- TRANSACTIONS 段:History list length
也可以用 metrics(先启用):
SET GLOBAL innodb_monitor_enable = 'trx_rseg_history_len';
SELECT name, count
FROM information_schema.INNODB_METRICS
WHERE name = 'trx_rseg_history_len';
这个计数就是 trx_rseg_history_len。数值本身没有绝对阈值,看的是趋势:长期不回落、且和 undo 表空间增长同步,就值得往下查。
第二步:按 trx_started 排最老的事务
SELECT trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id,
trx_rows_modified,
trx_tables_locked,
trx_isolation_level,
trx_autocommit_non_locking
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
判断要点:
age_sec远大于业务正常事务耗时(几秒 vs 几十分钟),即为嫌疑对象;trx_rows_modified = 0且状态为RUNNING的,往往是只做一致性读的事务——它照样持有 ReadView,照样钉住 undo,不要跳过;trx_state = 'ROLLBACK'的单独处理,见下一节;trx_tables_locked帮助你判断影响面。
第三步:把 trx 映射回连接和 SQL
SELECT id, user, host, db, command, time, state, info
FROM information_schema.PROCESSLIST
WHERE id = ;
time 很大、command 为 Sleep 的连接,通常就是应用侧忘了 commit 或 autocommit=0 后一直挂着的那条。
第四步:排除 purge 自身跟不上
如果第二步里最老事务的 age_sec 只有几秒,但 history list 仍然单边上涨,那问题在 DML 与 purge 的速度差:小批量、插入与删除速率相近的场景,purge 线程会落后,表因大量「死」行不断变大。此时看 innodb_max_purge_lag 是否还是 0(不施加任何延迟),并限制新行操作。
处置
KILL ;
MySQL 8.0 起,KILL 大事务只是打标记,回滚在后台进行,进程状态显示 Rolling back,INNODB_TRX 里 trx_state 变成 ROLLBACK。这段时间 undo 依然不能被 purge,history list 会继续涨,这是正常现象,不要在回滚未完成时反复 KILL 或重启。
参数层面的调节空间:
| 参数 | 默认值 | 说明 |
|---|---|---|
innodb_purge_threads | 4(1–32) | purge 工作线程数 |
innodb_purge_batch_size | 300 | 每轮 purge 处理的 undo 记录数 |
innodb_max_purge_lag | 0 | 0 表示不限制延迟;大于 0 时 history list 超过该值会对 DML 施加延迟 |
innodb_max_purge_lag_delay | 0 | 单位微秒,上限 10000000 |
innodb_undo_log_truncate | ON | undo 表空间截断开关 |
innodb_purge_rseg_truncate_frequency | 128 | 回滚段截断频率 |
innodb_max_purge_lag 的作用是给 DML 限流、把 CPU 让给 purge,属于缓解手段,不解决长事务本身。
应用侧的处理更根本:把大事务拆批;事务里不要夹 RPC、消息投递、文件 IO;一致性读也要放进短事务并及时提交;检查那些开了 autocommit=0 的连接池配置。
常见误判
- 只调
innodb_purge_threads。 瓶颈在快照不在线程数,只要最老快照不动,加线程也丢不掉 undo。 - 认为
innodb_undo_log_truncate=ON就该立刻收缩。 截断要等对应回滚段不再被 purge 需要,长事务在,undo 表空间不会缩。 - 只看
trx_state = 'RUNNING'。ROLLBACK状态的事务同样阻止 purge,且在INNODB_TRX里会持续存在一段时间。 - 忽略只读事务。
trx_rows_modified = 0不代表无害,一致性读同样建立并持有 ReadView。 - history list 高就断定是长事务。 也可能是 purge 落后于 DML 速率,两者处置方式完全不同:前者杀事务,后者限流或调
innodb_max_purge_lag。 - 看到查询变慢先怀疑索引失效。 二级索引记录被 delete-mark、索引页被更新事务改过时,覆盖索引会退化成回表,根因仍是没被清理的版本链,重建索引解决不了。