一句话结论(30s)
Undo Log 的本质是 InnoDB 事务体系中同时承担「事务回滚」与「MVCC 版本链」双重职责的物理基础——因为事务提交后旧版本不能立即删除,可能仍有活跃 ReadView 需要通过 roll_pointer 回溯读取历史值。关键设计是回滚段(8.0 默认 128 个,每个最多 1024 slot)的分槽管理 + 异步 purge 线程回收;核心权衡是长事务会让活跃 ReadView 阻止 purge 清理 Update Undo,导致 Undo 表空间持续膨胀,这是「长事务之殇」的根因。
核心原理(2min)
Undo Log 按操作类型分两类:Insert Undo 只记录主键/行号,事务提交后即可删除(新插入行对其他事务 ReadView 天然不可见);Update Undo 记录修改前旧值 + roll_pointer,提交后必须等 purge 才能删,是版本链与膨胀的核心源。每行隐藏列 DB_TRX_ID + DB_ROLL_PTR 把历史版本串成链,读事务用 ReadView 的 min_trx_id / max_trx_id / m_ids 沿链回溯找可见版本。Purge 线程从 History List 取最老的 Undo,判断其 trx_id 是否 ≤ 所有活跃 ReadView 的 min_trx_id,是则安全删除、否则跳过等待下一周期。当某个连接开启长事务(ReadView 长期活跃)期间,其他连接对同一行的每条 UPDATE 都产生一个 Undo 版本且 purge 无法清理——这就是 Undo 膨胀的完整链路,可用 information_schema.innodb_trx 排查长事务。
底层深入(5-10min)
一、Undo Log 的双重使命
Undo Log 是 InnoDB 事务体系中仅次于 Redo Log 的”老二”,在存储引擎源码的注释里,它被描述为同时承担两项职责:
| 职责 | 说明 | 生命周期 |
|---|---|---|
| 事务回滚 | 将修改过的行恢复到旧值,物理逆操作 | 事务提交前 |
| MVCC 读视图 | 为并发读提供历史版本,逻辑可见性判断 | 事务提交后,直到无读视图需要 |
事务提交后,Undo Log 不会立即删除——因为可能还有活跃的 ReadView 需要通过它构建历史版本。这就是 purge 线程存在的根本原因。
💭 想一想:事务都提交了,为什么还要留着它回滚用的日志?——因为 Undo 的第二个身份是”历史版本仓库”:别的读事务的快照可能还在”过去”,需要靠 roll_pointer 回溯到改之前的旧值。所以 Undo 的生命周期不是”事务结束”而是”没有任何快照还需要它为止”,这是理解后续所有问题的钥匙。
二、回滚段(Rollback Segment)的分槽管理
InnoDB 在 系统表空间(5.7)或 独立 Undo 表空间(8.0+)中分配 Undo Log。回滚段是其基本管理单元:
Undo 表空间 (undo_001.ibd, undo_002.ibd)
└─ 回滚段 (Rollback Segment, 128 个/8.0)
└─ Undo Slot (最多 1024 个/回滚段)
└─ Undo Log Page(Undo 日志的实际存储页)
关键配置:
innodb_rollback_segments = 128(8.0 默认,最多支持 128 × 1024 = 131072 个并发写事务)innodb_undo_tablespaces = 2(8.0 默认,一般建议 3-5 个避免单表空间成为 IO 瓶颈)
每当一个事务执行 INSERT/UPDATE/DELETE 时,它就从某个回滚段的空闲 slot 中获取一个 Undo Segment。这个 slot 是循环复用的:事务提交后 slot 释放,等待 purge 线程清理。
事务分配回滚段的策略
// InnoDB 源码逻辑(简化)
slot = trx->id % innodb_rollback_segments; // 轮询分配
这个简单策略的后果是:即使使用了多个 Undo 表空间,事务也并非均匀分布。生产环境中推荐将 innodb_undo_tablespaces 设为奇数,与回滚段数量互质以优化负载分布。
三、Insert Undo vs Update Undo
Undo Log 根据操作类型分为两类,生命周期完全不同:
3.1 Insert Undo
INSERT INTO users VALUES (1, 'Alice');
Insert Undo 只记录插入行的行号(或主键)。回滚时直接根据主键 DELETE。
关键特性:Insert Undo 在 事务提交后即可删除。因为插入的新行对于其他事务的 ReadView 是不可见的(trx_id > 所有活跃快照),没有 MVCC 保留的必要。
3.2 Update Undo
UPDATE users SET name = 'Bob' WHERE id = 1;
DELETE FROM users WHERE id = 1;
Update Undo 记录的是 修改前的旧值(修改了哪些列、旧值是什么)以及 指向前一个版本的指针(roll_pointer)。回滚时将旧值写回。
关键特性:Update Undo 在 事务提交后不能立即删除。因为这行可能有多个历史版本,其他使用旧 ReadView 的事务需要通过版本链访问被修改前的值。
💭 想一想:为什么 Insert Undo 能”提交即删”、Update Undo 却必须等 purge?——差别在于可见性:新插入的行 trx_id 比所有已存在快照都新,天然对任何旧 ReadView 不可见,没人需要它的”前一个版本”;而 Update 改的是已存在的行,旧快照还想看修改前的旧值,所以旧值必须留到所有旧快照都退场。
3.3 两者的存储差异
| Insert Undo | Update Undo | |
|---|---|---|
| 记录内容 | 主键/行号 | 旧值 + roll_pointer |
| 提交后清理 | 可立即清理 | 需等待 purge |
| 存储位置 | 独立 undo log | 历史版本链表 |
| 对长事务影响 | 无影响 | 核心膨胀源 |
3.4 源码视角:trx_undo_t 结构体与类型常量
InnoDB 内存中描述一条 undo log 的结构体是 include/trx0undo.h 里的 trx_undo_t(节选关键字段):
struct trx_undo_t {
/*-----------------------------*/
ulint id; /*!< undo log slot number within the
rollback segment */
ulint type; /*!< TRX_UNDO_INSERT or
TRX_UNDO_UPDATE */
ulint state; /*!< state of the corresponding undo log
segment */
bool del_marks; /*!< relevant only in an update undo
log: this is true if the transaction may
have delete marked records, because of
a delete of a row or an update of an
indexed field; purge is then
necessary */
trx_id_t trx_id; /*!< id of the trx assigned to the undo
log */
trx_rseg_t *rseg; /*!< rseg where the undo log belongs */
/*-----------------------------*/
space_id_t space; /*!< space id where the undo log
placed */
page_no_t hdr_page_no; /*!< page number of the header page in
the undo log */
ulint hdr_offset; /*!< header offset of the undo log on
the page */
/*-----------------------------*/
page_no_t top_page_no; /*!< page number where the latest undo
log record was catenated */
ulint top_offset; /*!< offset of the latest undo record,
i.e., the topmost element in the undo
log if we think of it as a stack */
undo_no_t top_undo_no; /*!< undo number of the latest record */
/*-----------------------------*/
UT_LIST_NODE_T(trx_undo_t) undo_list;
/*!< undo log objects in the rollback
segment are chained into lists */
};
分析:type 区分 Insert Undo 与 Update Undo;del_marks 只有在 update undo 里才有意义——它标记该事务「可能 delete-mark 了记录(删行或改了索引列)」,因此 purge 必须来清理;rseg 指向所属回滚段,hdr_page_no/hdr_offset 定位 undo header 页。最后一段注释把 undo log 描述成一个「栈」:top_page_no/top_offset/top_undo_no 就是栈顶,新 undo 记录从栈顶追加、回滚时从栈顶弹出,所以 undo log 天然后进先出。
类型与状态常量也在同一个头文件里:
/** Types of an undo log segment */
constexpr uint32_t TRX_UNDO_INSERT = 1; /* contains undo entries for inserts */
constexpr uint32_t TRX_UNDO_UPDATE = 2; /* contains undo entries for updates
and delete markings */
/* States of an undo log segment */
constexpr uint32_t TRX_UNDO_ACTIVE = 1; /* active transaction */
constexpr uint32_t TRX_UNDO_CACHED = 2; /* cached for quick reuse */
constexpr uint32_t TRX_UNDO_TO_FREE = 3; /* insert undo can be freed */
constexpr uint32_t TRX_UNDO_TO_PURGE = 4; /* update undo waits for purge */
分析:状态的迁移串起了两条清理路径——Insert Undo 提交后进入 TRX_UNDO_TO_FREE(可立即释放),Update Undo 进入 TRX_UNDO_TO_PURGE(必须等 purge),这正对应上一节「提交后清理时机不同」的差异。TRX_UNDO_CACHED 说明 commit 后 undo segment 不是立刻销毁,而是缓存起来给下一个事务快速复用,避免频繁在回滚段里分配 slot。
四、MVCC 版本链:trx_id + roll_pointer
InnoDB 每一行数据都包含三个隐藏列:
| DB_ROW_ID (6B, 可选) | DB_TRX_ID (6B) | DB_ROLL_PTR (7B) | 用户列... |
- DB_TRX_ID:最后一次修改该行的 事务 ID(单调递增的 6 字节整数)
- DB_ROLL_PTR:指向 Undo Log 中的前一个版本(7 字节,页号 + 页内偏移)
当前事务的 ReadView(快照)记录了创建时刻的 m_ids(活跃事务 ID 集合)、min_trx_id、max_trx_id。遍历一行时:
[当前行] trx_id=105 → roll_pointer ↓
[Undo版本1] trx_id=103 → roll_pointer ↓
[Undo版本2] trx_id=99 → roll_pointer ↓
[Undo版本3] trx_id=90 → roll_pointer = NULL (首次插入)
对于 ReadView 中 min_trx_id=100 的读事务:
- trx_id=105:105 > max_trx_id → 不可见,沿 roll_pointer 回溯
- trx_id=103:103 在 m_ids 中 → 不可见,继续回溯
- trx_id=99:99 < min_trx_id → 可见,返回此版本
版本链的代价
版本链越长(即 Undo Log 历史越多),每次一致性读的成本越高。极端场景:一个长事务(如运行 8 小时的大查询)持续产生大量 UPDATE,每行可能累积成百上千个历史版本。这条链上的每一个版本都需要一次 IO(可能命中 BufferPool,但不保证)。
💭 想一想:版本链是谁拉长的?——是”最老的那个活跃 ReadView”。只要有一个事务的快照停在很早的时刻,它后面产生的每个 Update 旧版本都得为它保留,链就越滚越长。所以优化方向不是”删版本”,而是”尽快结束长事务、缩短最老 ReadView 的存活时间”。
五、Purge 线程:Undo 回收引擎
SHOW VARIABLES LIKE 'innodb_purge_threads'; -- 默认 4
SHOW VARIABLES LIKE 'innodb_purge_batch_size'; -- 默认 300
Purge 线程的工作流程:
1. 从 History List 中取出最古老的 Undo Log
2. 判断该 Undo Log 对应的 trx_id 是否 ≤ 所有活跃 ReadView 的 min_trx_id
3. 如果是 → 安全删除该 Undo Log 及其索引标记
4. 如果否 → 跳过,等待下次 purge 周期(仍被某读视图需要)
5. 清理 delete-mark 的记录(物理删除)
History List 是连接所有已提交事务 Undo Log 的链表,按 commit 顺序排列。SHOW ENGINE INNODB STATUS 中可以查看 History list length,这个值越高说明积压的 Undo 越多。
💭 想一想:purge 为什么从 History List”最老”的节点开始清?——因为越老的 undo 对应的 trx_id 越小,越不可能还被某个活跃 ReadView 需要;先清最老的能保证”清得动、清得安全”。一旦遇到年轻到可能还被需要的记录就停下,等下轮再试——这正是 purge 的清理边界逻辑。
5.1 源码:提交时把 Update Undo 挂进 History List
trx/trx0purge.cc 的 trx_purge_add_update_undo_to_history() 在事务提交时被调用,把该事务的 update undo 头插到回滚段的 History List:
/* Add the log as the first in the history list */
flst_add_first(rseg_header + TRX_RSEG_HISTORY,
undo_header + TRX_UNDO_HISTORY_NODE, mtr);
if (update_rseg_history_len) {
trx_sys->rseg_history_len.fetch_add(n_added_logs);
if (trx_sys->rseg_history_len.load() >
srv_n_purge_threads * srv_purge_batch_size) {
srv_wake_purge_thread_if_not_active();
}
}
分析:flst_add_first 把 undo 头节点插到 History List 头部,所以链表天然按「提交时间倒序」排列,越靠尾部越老、越该先 purge;rseg_history_len 就是 SHOW ENGINE INNODB STATUS 里的 History list length。当历史长度超过 线程数 × 批量大小(默认 4 × 300 = 1200)时主动唤醒 purge 线程——这是 purge 跟不上写入时的自调节入口。
5.2 源码:purge 边界由最老 ReadView 决定
purge 每跑一轮之前,先刷新「最老活跃 ReadView」并算出清理下界 trx_purge_update_oldest_needed():
static void trx_purge_update_oldest_needed() {
rw_lock_x_lock(&purge_sys->latch, UT_LOCATION_HERE);
trx_sys->mvcc->clone_oldest_view(purge_sys->view);
ut_a(purge_sys->view != nullptr);
const auto needed_by_purge_view = purge_sys->view->get_lowest_needed_trx_no();
const auto needed_by_persistor =
clone_sys->get_gtid_persistor().get_oldest_trx_no();
purge_sys->m_lowest_needed_trx_no =
std::min(needed_by_purge_view, needed_by_persistor);
rw_lock_x_unlock(&purge_sys->latch);
}
分析:clone_oldest_view() 从所有活跃 ReadView 里克隆出「最老」的那个,get_lowest_needed_trx_no() 就是那个 ReadView 的 m_low_limit_no——所有 trx_no 比它小的 undo 都不再被任何快照需要,可以安全清掉。长事务之所以让 undo 膨胀,正是因为它长期保持「最老 ReadView」不动,导致 m_lowest_needed_trx_no 卡在一个很小(很老)的值上。
5.3 源码:fetch 时的可见性闸门
真正取 undo 记录时,trx_purge_fetch_next_rec() 用同一个下界做闸门判断:
if (purge_sys->iter.trx_no >= purge_sys->m_lowest_needed_trx_no) {
return nullptr;
}
分析:purge 迭代器按 trx_no 递增遍历 History List,一旦当前记录的 trx_no 已经 >= 清理下界,说明它(以及它后面所有更年轻的记录)都可能仍被某个活跃 ReadView 需要,立刻返回 nullptr 停止本批次,等下次 trx_purge_update_oldest_needed() 刷新下界后再试。这一行就是「活跃 ReadView 阻止 purge 清理旧版本」在源码里的精确落点。
Purge 跟不上写入速度的警示信号:
History list length持续增长innodb_purge_threads已达到上限(最大 32)- 磁盘空间被 Undo 表空间撑大
Innodb_rows_deleted与Innodb_purge_trx_id_age同时增长
解决方案
-- 临时加速 purge
SET GLOBAL innodb_purge_batch_size = 1000;
SET GLOBAL innodb_max_purge_lag = 1000000;
-- 根本方案:关闭长事务,优化 Undo 表空间数
SET GLOBAL innodb_undo_tablespaces = 4;
六、长事务导致 Undo 膨胀的完整链路
假设一个连接开启了事务但没有及时提交:
BEGIN;
SELECT * FROM orders WHERE id = 1; -- 这个 ReadView 会一直保持活跃
-- ... 8 小时后才 COMMIT
在此期间,其他连接对 orders 表执行了大量 UPDATE/DELETE:
时刻 T0:BEGIN → ReadView(min_trx_id=500)
时刻 T1:UPDATE orders SET status='paid' WHERE id=1 → trx_id=501,改前行版本 → Undo
时刻 T2:UPDATE orders SET status='shipped' WHERE id=1 → trx_id=502,改前行版本 → Undo
...(重复 1000 次)
时刻 T1000:ReadView 事务 COMMIT → purge 线程终于可以清理 Undo
结果:id=1 这行产生了 1000 个 Undo 版本,占据大量磁盘空间(可能是几 MB 到几十 MB),且 purge 线程无法清理——因为活跃 ReadView(min_trx_id=500) 需要这 1000 个版本中的第一个。
查询长事务:
SELECT trx_id, trx_started, trx_mysql_thread_id,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_modified, trx_undo_mysql_log_bytes
FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING'
ORDER BY trx_started;
七、Undo 表空间的 truncate 机制
MySQL 8.0 支持 Undo 表空间自动 truncate:
SET GLOBAL innodb_undo_log_truncate = ON; -- 默认 ON
SET GLOBAL innodb_max_undo_log_size = 1073741824; -- 1GB 阈值
当 Undo 表空间超过 innodb_max_undo_log_size,InnoDB 标记该表空间为 inactive,分配一个新的 Undo 表空间,然后在后台 truncate 旧的。这是个非阻塞操作——活跃事务继续使用旧表空间中的 Undo Segment,新旧切换是一个原子步。
总结:Undo Log 不是”多写几条日志而已”——它是 MVCC 的物理基础、purge 是异步回收流水线、长事务是版本链膨胀的根源。理解时间线上 trx_id → roll_pointer → ReadView 的可见性判断链,是深入掌握 InnoDB 事务的关键一步。
章末提问
追问 1:事务已经提交了,Undo Log 为什么还不能删?谁还在用它?
回答思路:结论先行——因为 Undo 还承担 MVCC 历史版本仓库的职责,仍有旧 ReadView 需要通过它回溯旧值。因为提交只代表本事务完成,但其他并发读事务的快照可能创建在更早的时刻,它们要读的是”修改之前”的版本,这个版本就存在 Undo 里。所以 Undo 必须等所有旧快照都退场、由 purge 线程确认无引用后才回收。
追问 2:长事务为什么会导致 Undo 膨胀?完整链路是什么?
回答思路:结论先行——因为长事务让最老 ReadView 卡在很早,purge 的清理下界被它压住,后续每个 Update 旧版本都清不掉。因为 purge 判断”能否删”的依据是「该 undo 的 trx_no 是否小于所有活跃 ReadView 的最低需要值」;长事务一直持有那个最老快照,下界不前进,期间所有 UPDATE/DELETE 产生的版本全部积压,Undo 表空间就持续膨胀,可用 information_schema.innodb_trx 排查。
追问 3:purge 线程靠什么判断一条 Undo 能不能安全清理?
回答思路:结论先行——靠”最老活跃 ReadView 的 low_limit_no”作为清理下界。因为 purge 每轮先 clone_oldest_view 算出所有活跃 ReadView 中需要的最低 trx_no,再遍历 History List:记录 trx_no 小于下界就删、大于等于就停。这个下界一卡住(长事务),purge 就只能干等,所以”长事务”和”Undo 膨胀”是同一件事的两面。