Undo Log - 回滚日志详解
定义
Undo Log (回滚日志) 是InnoDB存储引擎的一种逻辑日志,记录事务对数据的反向操作。当事务需要回滚或数据库崩溃恢复时,Undo Log可以将数据恢复到事务开始前的状态,从而保证事务的原子性 (Atomicity)。此外,Undo Log还支撑着InnoDB的**MVCC (多版本并发控制)**机制,为不同事务提供数据的历史版本。
核心特征
| 特征 | 说明 |
|---|---|
| 日志类型 | 逻辑日志(Logical Log) |
| 记录内容 | 反向操作(如INSERT的反向是DELETE) |
| 存储位置 | ibdata1(共享表空间)或独立Undo表空间 |
| 组织方式 | Undo Segment → Undo Page → Undo Record |
| 生命周期 | 事务提交后不立即删除,供MVCC使用 |
| 清理机制 | Purge线程异步清理 |
| 版本控制 | 支持多版本历史链 |
与Redo Log的对比
Redo Log (重做日志) Undo Log (回滚日志)
┌─────────────────────┐ ┌─────────────────────┐
│ 物理日志 │ │ 逻辑日志 │
│ "在页X偏移Y处写入Z" │ │ "将记录A改回B" │
│ 保证持久性(D) │ │ 保证原子性(A) │
│ 崩溃时REDO │ │ 崩溃时UNDO │
│ 循环使用 │ │ 提交后延迟删除 │
│ ib_logfile0/1 │ │ ibdata1/undo tablespace│
└─────────────────────┘ └─────────────────────┘
↓ ↓
两者配合实现WAL机制,共同保证ACID为什么需要Undo Log?
问题1: 事务回滚
场景:
sql
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 发现错误,需要回滚
ROLLBACK;没有Undo Log会怎样?
- 无法知道事务修改前的原始值
- 无法撤销已执行的SQL语句
- 事务原子性无法保证
解决方案:
- 每次修改前,将旧值记录到Undo Log
- ROLLBACK时,按相反顺序应用Undo Record
问题2: 崩溃恢复
场景:
T1: 事务开始
T2: UPDATE accounts SET balance = 900 WHERE id = 1; (已执行)
T3: 系统崩溃! 💥
T4: MySQL重启恢复需求:
- 事务未提交,必须回滚
- Buffer Pool中的数据可能已丢失
- 需要一种机制撤销未提交的修改
解决方案:
- Redo Log重做所有修改(包括未提交的)
- Undo Log回滚未提交事务
问题3: MVCC非锁定读
场景:
sql
-- 事务1
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 事务2(同时读取)
SELECT balance FROM accounts WHERE id = 1;
-- 应该返回旧值1000,而不是900需求:
- 事务2不应被事务1阻塞
- 需要提供数据的历史版本
- 不能直接读取未提交的修改
解决方案:
- Undo Log保存历史版本
- SELECT通过Undo Chain找到可见的旧版本
- 实现快照读(Snapshot Read)
Undo Log的物理结构
存储布局
ibdata1 (共享表空间) 或 独立Undo表空间
┌──────────────────────────────────────┐
│ FSP_HDR (File Space Header) │
├──────────────────────────────────────┤
│ TRX_SYS (Transaction System Header) │ ← 事务系统头
├──────────────────────────────────────┤
│ Undo Segment #1 (回滚段) │
│ ├─ Undo Page #1 │
│ │ ├─ Undo Record #1 │
│ │ ├─ Undo Record #2 │
│ │ └─ ... │
│ ├─ Undo Page #2 │
│ └─ ... │
├──────────────────────────────────────┤
│ Undo Segment #2 │
└──────────────────────────────────────┘关键概念:
- Undo Tablespace: Undo表空间,MySQL 8.0+支持独立Undo表空间
- Rollback Segment (回滚段): 管理一组Undo Page,默认128个
- Undo Page: 存储Undo Record的页面,默认16KB
- Undo Record: 单条回滚记录
Undo Segment结构
c
/* storage/innobase/include/trx0rseg.h */
/**
* Rollback Segment (回滚段)
* 每个实例最多有128个回滚段
*/
struct trx_rseg_t {
ulint id; /* 回滚段ID */
ulint space; /* 表空间ID */
ulint page_no; /* 回滚段页号 */
/* Undo Slot数组 */
trx_undo_t* undo_slots[TRX_RSEG_N_SLOTS];
/* TRX_RSEG_N_SLOTS = 1024 */
/* History链表(用于Purge) */
UT_LIST_BASE_NODE_T(trx_undo_t, history_list);
};
/**
* Undo Slot
* 每个回滚段包含1024个Undo Slot
*/
struct trx_undo_t {
ulint state; /* 状态: ACTIVE/CACHED/TO_FREE/FREE */
ulint type; /* 类型: INSERT/UPDATE */
trx_rseg_t* rseg; /* 所属回滚段 */
ulint page_no; /* Undo日志的第一个页号 */
ulint offset; /* Undo日志头的偏移量 */
/* Undo Record链表 */
trx_ulogf_t* header; /* Undo日志头 */
/* 事务信息 */
trx_id_t trx_id; /* 事务ID */
XID xid; /* X/Open XA事务标识符 */
/* History链表节点 */
UT_LIST_NODE_T(trx_undo_t, history_list);
};Undo Record格式
c
/* storage/innobase/include/rem0rec.h */
/**
* Undo Record通用格式
*/
struct undo_rec_t {
uint8_t type; /* 记录类型 */
uint16_t info_bits; /* 信息位 */
uint32_t data_len; /* 数据长度 */
/* 字段数量 */
uint16_t n_fields;
/* 字段偏移量表 */
uint16_t field_offsets[];
/* 字段数据 */
byte field_data[];
/* 指向下一个Undo Record的指针 */
uint32_t next_rec_offset;
};
/* Undo Record类型 */
enum undo_type_t {
UNDO_INSERT = 1, /* 插入操作 */
UNDO_UPDATE = 2, /* 更新操作 */
UNDO_DELETE = 3, /* 删除操作 */
};不同类型Undo Record的内容:
UNDO_INSERT (撤销INSERT):
┌──────────────┬──────────────────┐
│ 字段名 │ 值 │
├──────────────┼──────────────────┤
│ type | UNDO_INSERT │
│ page_no | 插入的页号 │
│ heap_no | 堆编号 │
│ origin_offset│ 原点偏移 │
│ end_seg_len │ 记录总长度 │
└──────────────┴──────────────────┘
UNDO_UPDATE (撤销UPDATE):
┌──────────────┬──────────────────┐
│ 字段名 │ 值 │
├──────────────┼──────────────────┤
│ type | UNDO_UPDATE │
│ page_no | 更新的页号 │
│ heap_no | 堆编号 │
│ n_fields | 更新的字段数 │
│ field_data[] │ 旧值数据 │ ← 关键字段: 保存修改前的值
└──────────────┴──────────────────┘
UNDO_DELETE (撤销DELETE):
┌──────────────┬──────────────────┐
│ 字段名 │ 值 │
├──────────────┼──────────────────┤
│ type | UNDO_DELETE │
│ page_no | 删除的页号 │
│ heap_no | 堆编号 │
│ n_fields | 字段数 │
│ field_data[] │ 完整记录数据 │ ← 保存被删除的整行
└──────────────┴──────────────────┘InnoDB源码分析
Undo Log分配流程
cpp
/* storage/innobase/trx/trx0undo.cc */
/**
* 为事务分配Undo Log
* @param trx 事务对象
* @param type Undo Log类型(TRX_UNDO_INSERT或TRX_UNDO_UPDATE)
* @return 分配的Undo Log对象
*/
trx_undo_t* trx_undo_assign_undo(trx_t* trx, trx_undo_t* undo, ulint type)
{
trx_rseg_t* rseg;
ulint slot_no;
/* 1. 获取回滚段 */
if (type == TRX_UNDO_INSERT) {
rseg = trx->rsegs.m_insert;
} else {
rseg = trx->rsegs.m_update;
}
/* 2. 从Undo Slot数组中分配一个空闲Slot */
mutex_enter(&rseg->mutex);
slot_no = trx_rseg_find_free_slot(rseg);
if (slot_no == ULINT_UNDEFINED) {
/* 所有Slot都被占用,需要扩展 */
ut_error;
}
/* 3. 初始化Undo Slot */
undo = &rseg->undo_slots[slot_no];
undo->state = TRX_UNDO_ACTIVE;
undo->type = type;
undo->trx_id = trx->id;
/* 4. 分配第一个Undo Page */
undo->page_no = trx_undo_add_page(trx, undo, type);
undo->offset = trx_undo_header_reserve_space(undo->page_no);
mutex_exit(&rseg->mutex);
return undo;
}
/**
* 添加新的Undo Page
*/
ulint trx_undo_add_page(trx_t* trx, trx_undo_t* undo, ulint type)
{
mtr_t mtr;
page_t* page;
ulint page_no;
mtr_start(&mtr);
/* 1. 分配新页面 */
page_no = fsp_alloc_from_free_pages(undo->space, 1);
/* 2. 初始化页面为Undo Log格式 */
page = buf_page_get(undo->space, undo->zip_size, page_no, RW_X_LATCH, &mtr);
trx_undo_page_init(page, type, undo, &mtr);
/* 3. 将页面链接到Undo Log链表 */
if (undo->page_no == FIL_NULL) {
/* 这是第一个页面 */
trx_undo_set_first_page(undo, page_no, &mtr);
} else {
/* 链接到链表末尾 */
trx_undo_link_page(undo, undo->last_page_no, page_no, &mtr);
}
mtr_commit(&mtr);
return page_no;
}Undo Record写入流程
cpp
/* storage/innobase/trx/trx0rec.cc */
/**
* 写入UPDATE类型的Undo Record
* @param trx 事务对象
* @param undo Undo Log对象
* @param rec 被修改的记录
* @param index 索引对象
* @return 写入的Undo Record偏移量
*/
ulint trx_undo_report_row_operation(
trx_t* trx,
trx_undo_t* undo,
const dtuple_t* clust_entry,
const upd_t* update,
const rec_t* rec,
const dict_index_t* index)
{
mtr_t mtr;
ulint offset;
ulint size;
mtr_start(&mtr);
/* 1. 计算Undo Record大小 */
size = trx_undo_rec_build_size(update, rec, index);
/* 2. 在Undo Page中分配空间 */
offset = trx_undo_page_allocate_space(undo, size, &mtr);
if (offset == 0) {
/* 当前页空间不足,分配新页 */
ulint new_page_no = trx_undo_add_page(trx, undo, TRX_UNDO_UPDATE);
offset = trx_undo_page_allocate_space(undo, size, &mtr);
}
/* 3. 构建Undo Record */
trx_undo_rec_build(
undo,
offset,
TRX_UNDO_UPDATE, /* 类型 */
update, /* 更新信息 */
rec, /* 原始记录 */
index, /* 索引 */
&mtr);
/* 4. 更新Undo Log头 */
trx_undo_header_update(undo, offset, &mtr);
mtr_commit(&mtr);
return offset;
}
/**
* 构建Undo Record的具体内容
*/
void trx_undo_rec_build(
trx_undo_t* undo,
ulint offset,
ulint type,
const upd_t* update,
const rec_t* rec,
const dict_index_t* index,
mtr_t* mtr)
{
byte* ptr;
ulint n_fields;
ptr = trx_undo_page_get_rec(undo, offset);
/* 1. 写入记录类型 */
mach_write_to_1(ptr, type);
ptr += 1;
/* 2. 写入字段数量 */
n_fields = upd_get_n_fields(update);
mach_write_to_2(ptr, n_fields);
ptr += 2;
/* 3. 逐个字段写入旧值 */
for (ulint i = 0; i < n_fields; i++) {
const upd_field_t* upd_field = upd_get_nth_field(update, i);
const dfield_t* old_val = &upd_field->old_val;
/* 写入字段长度 */
ulint len = dfield_get_len(old_val);
ptr += mach_store_compressed_int(ptr, len);
/* 写入字段数据 */
if (len > 0) {
memcpy(ptr, dfield_get_data(old_val), len);
ptr += len;
}
}
/* 4. 写入记录位置信息 */
mach_write_to_4(ptr, page_rec_get_heap_no(rec));
ptr += 4;
/* 5. 设置next_rec_offset为0(链表末尾) */
mach_write_to_4(ptr, 0);
}事务回滚流程
cpp
/* storage/innobase/trx/trx0rollback.cc */
/**
* 回滚整个事务
* @param trx 事务对象
*/
void trx_rollback(trx_t* trx)
{
trx_undo_t* insert_undo;
trx_undo_t* update_undo;
/* 1. 检查事务状态 */
if (trx->state == TRX_STATE_COMMITTED) {
return; /* 已提交事务不能回滚 */
}
/* 2. 获取Insert Undo和Update Undo */
insert_undo = trx->rsegs.m_insert->get_active_undo();
update_undo = trx->rsegs.m_update->get_active_undo();
/* 3. 先回滚Insert Undo(后进先出) */
if (insert_undo != NULL) {
trx_rollback_insert_undo_low(trx, insert_undo);
}
/* 4. 再回滚Update Undo */
if (update_undo != NULL) {
trx_rollback_update_undo_low(trx, update_undo);
}
/* 5. 释放锁 */
lock_release(trx);
/* 6. 更新事务状态 */
trx->state = TRX_STATE_ROLLED_BACK;
trx->undo_no = 0;
}
/**
* 回滚Update Undo
*/
void trx_rollback_update_undo_low(trx_t* trx, trx_undo_t* undo)
{
ulint offset;
/* 1. 定位到最后一条Undo Record */
offset = undo->header->last_offset;
/* 2. 逆序遍历所有Undo Record */
while (offset > 0) {
trx_undo_rec_t* undo_rec;
ulint type;
ulint page_no;
ulint heap_no;
/* 3. 读取Undo Record */
undo_rec = trx_undo_get_undo_rec(undo, offset);
type = trx_undo_rec_get_type(undo_rec);
page_no = trx_undo_rec_get_page_no(undo_rec);
heap_no = trx_undo_rec_get_heap_no(undo_rec);
/* 4. 根据类型执行反向操作 */
switch (type) {
case TRX_UNDO_UPDATE:
/* 恢复旧值 */
trx_undo_apply_update(undo_rec, page_no, heap_no);
break;
case TRX_UNDO_DELETE:
/* 重新插入被删除的记录 */
trx_undo_apply_delete(undo_rec, page_no, heap_no);
break;
default:
ut_error;
}
/* 5. 移动到上一条Undo Record */
offset = trx_undo_rec_get_prev_offset(undo_rec);
}
/* 6. 释放Undo Log占用的页面 */
trx_undo_free_pages(undo);
}
/**
* 应用UPDATE类型的Undo Record
*/
void trx_undo_apply_update(
trx_undo_rec_t* undo_rec,
ulint page_no,
ulint heap_no)
{
buf_block_t* block;
page_t* page;
rec_t* rec;
ulint n_fields;
/* 1. 获取数据页 */
block = buf_page_get(undo->space, undo->zip_size, page_no, RW_X_LATCH);
page = buf_block_get_frame(block);
/* 2. 定位到记录 */
rec = page_find_rec_with_heap_no(page, heap_no);
/* 3. 读取Undo Record中的旧值 */
n_fields = trx_undo_rec_get_n_fields(undo_rec);
/* 4. 逐字段恢复旧值 */
for (ulint i = 0; i < n_fields; i++) {
const byte* old_val;
ulint len;
ulint field_no;
old_val = trx_undo_rec_get_field(undo_rec, i, &len, &field_no);
/* 5. 更新记录字段 */
rec_update_field(rec, field_no, old_val, len);
}
/* 6. 标记页面为脏页 */
buf_block_modify_clock_inc(block);
buf_block_set_dirty(block, TRUE);
buf_page_release(block);
}MVCC读取历史版本
cpp
/* storage/innobase/read/read0read.cc */
/**
* MVCC一致性读: 获取记录的可见版本
* @param rec 当前记录
* @param view 读视图
* @return 可见版本的记录
*/
const rec_t* read_view_get_consistent_read(
const rec_t* rec,
read_view_t* view,
dict_index_t* index)
{
trx_id_t trx_id;
ulint offset;
/* 1. 获取记录的事务ID */
trx_id = row_get_rec_trx_id(rec, index);
/* 2. 检查事务是否对当前视图可见 */
if (read_view_sees_trx_id(view, trx_id)) {
/* 事务已提交且在视图创建前提交,直接返回 */
return rec;
}
/* 3. 不可见,需要通过Undo Log查找旧版本 */
const rec_t* old_rec = rec;
while (TRUE) {
/* 4. 获取Undo Record */
offset = row_rec_get_undo_rec_ptr(old_rec, index);
if (offset == 0) {
/* 没有更多历史版本 */
return NULL;
}
trx_undo_rec_t* undo_rec = trx_undo_get_undo_rec_from_offset(offset);
/* 5. 获取Undo Record的事务ID */
trx_id_t prev_trx_id = trx_undo_rec_get_trx_id(undo_rec);
/* 6. 检查旧版本是否可见 */
if (read_view_sees_trx_id(view, prev_trx_id)) {
/* 7. 构建旧版本的完整记录 */
rec_t* visible_rec = row_build_rec_from_undo_rec(
undo_rec, old_rec, index);
return visible_rec;
}
/* 8. 继续追溯更早的版本 */
old_rec = trx_undo_rec_get_prev_rec(undo_rec);
if (old_rec == NULL) {
/* 没有更多版本 */
return NULL;
}
}
}
/**
* 从Undo Record构建完整记录
*/
rec_t* row_build_rec_from_undo_rec(
trx_undo_rec_t* undo_rec,
const rec_t* current_rec,
dict_index_t* index)
{
ulint n_fields;
dtuple_t* tuple;
/* 1. 创建空元组 */
tuple = dtuple_create(heap, dict_index_get_n_fields(index));
/* 2. 从Undo Record中提取旧值 */
n_fields = trx_undo_rec_get_n_fields(undo_rec);
for (ulint i = 0; i < n_fields; i++) {
const byte* data;
ulint len;
ulint field_no;
data = trx_undo_rec_get_field(undo_rec, i, &len, &field_no);
/* 3. 设置字段值 */
dfield_t* dfield = dtuple_get_nth_field(tuple, field_no);
dfield_set_data(dfield, data, len);
}
/* 4. 对于未在Undo Record中的字段,从当前记录复制 */
for (ulint i = 0; i < dict_index_get_n_fields(index); i++) {
if (!dtuple_get_nth_field(tuple, i)->is_set()) {
const dfield_t* curr_field = rec_get_dfield(current_rec, i);
dfield_copy(dtuple_get_nth_field(tuple, i), curr_field);
}
}
/* 5. 转换为物理记录格式 */
rec_t* rec = rec_convert_dtuple_to_rec(tuple, index);
return rec;
}Undo Log生命周期
完整流程
事务开始
↓
┌──────────────────────────────────────────┐
│ 分配Undo Segment │
│ - 从空闲Slot中分配 │
│ - 初始化Undo Log头 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 数据修改(INSERT/UPDATE/DELETE) │
│ - 写入Undo Record │
│ - 记录旧值或反向操作 │
│ - 多个Undo Record形成链表 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 事务提交 │
│ - Undo Log状态改为TRX_UNDO_CACHED │
│ - 加入History链表 │
│ - 不立即删除(供MVCC使用) │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Purge线程定期清理 │
│ - 检查History链表 │
│ - 判断是否有事务仍在使用该版本 │
│ - 安全时删除Undo Record │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Undo Segment释放 │
│ - 状态改为TRX_UNDO_FREE │
│ - 归还给空闲Slot池 │
└──────────────────────────────────────────┘状态转换图
分配
FREE ─────→ ACTIVE ─────→ CACHED ─────→ TO_FREE ─────→ FREE
↑ │ │
│ │ 提交 │ Purge完成
└──────────────┴──────────────┘
重用Slot状态说明:
- FREE: 空闲状态,可分配
- ACTIVE: 事务正在使用
- CACHED: 事务已提交,缓存供MVCC
- TO_FREE: Purge完成,待释放
关键配置参数
innodb_undo_tablespaces
ini
# Undo表空间数量
# MySQL 8.0+: 支持动态调整
# 默认: 0(使用共享表空间ibdata1)
# 推荐: 2~4(生产环境)
[mysqld]
innodb_undo_tablespaces = 2影响:
- 分离IO: Undo和Data分开,减少竞争
- 独立管理: 可单独调整Undo表空间大小
- 在线清理: 支持Truncate Undo表空间
innodb_undo_log_truncate
ini
# 是否启用Undo Log自动截断
# 默认: ON
[mysqld]
innodb_undo_log_truncate = ON工作机制:
1. 监控Undo表空间大小
↓
2. 超过阈值(innodb_max_undo_log_size)
↓
3. 标记为"可截断"
↓
4. 等待所有引用该表空间的事务结束
↓
5. Truncate表空间文件
↓
6. 重置为初始大小innodb_max_undo_log_size
ini
# Undo表空间最大大小
# 超过此值会触发Truncate
# 默认: 1GB
[mysqld]
innodb_max_undo_log_size = 2Ginnodb_purge_threads
ini
# Purge线程数量
# 范围: 1 ~ 32
# 默认: 4
[mysqld]
innodb_purge_threads = 4调优建议:
- 写密集型: 8~16线程
- 普通应用: 4线程
- 读多写少: 1~2线程
实际案例分析
案例1: 长事务导致Undo Log膨胀
问题现象:
bash
# 查看Undo表空间大小
ls -lh /var/lib/mysql/undo_001
-rw-r----- 1 mysql mysql 50G Apr 12 14:30 /var/lib/mysql/undo_001
# 正常应该只有几百MB原因排查:
sql
-- 1. 查找长事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_modified,
trx_tables_in_use,
trx_tables_locked
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
-- 输出:
-- +--------+-----------+---------------------+----------------+-------------------+
-- | trx_id | trx_state | trx_started | duration_sec | rows_modified |
-- +--------+-----------+---------------------+----------------+-------------------+
-- | 123456 | RUNNING | 2026-04-10 10:00:00 | 172800 | 5000000 |
-- +--------+-----------+---------------------+----------------+-------------------+
-- 2. 查看事务详情
SHOW ENGINE INNODB STATUS\G
-- 输出:
--- TRANSACTIONS ---
Trx id counter 123457
Purge done for trx's n:o < 100000 undo n:o < 0 state: running but idle
History list length 1000000
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 123456, ACTIVE 172800 sec
5000000 lock struct(s), heap size 500000000, 5000000 row lock(s)
MySQL thread id 100, OS thread handle 1234567890, query id 1000 localhost root
-- 3. 定位到具体SQL
SELECT * FROM performance_schema.events_statements_current
WHERE THREAD_ID = (
SELECT THREAD_ID FROM performance_schema.threads
WHERE PROCESSLIST_ID = 100
);根本原因:
- 一个批量更新事务运行了2天未提交
- 产生了500万条Undo Record
- Undo表空间增长到50GB
解决方案:
sql
-- 1. 终止长事务
KILL 100;
-- 2. 等待Purge线程清理
-- 监控History链表长度
SHOW ENGINE INNODB STATUS\G
-- 观察 "History list length" 逐渐减小
-- 3. 手动触发Truncate(如果启用)
SET GLOBAL innodb_undo_log_truncate = OFF;
SET GLOBAL innodb_undo_log_truncate = ON;
-- 4. 验证表空间缩小
ls -lh /var/lib/mysql/undo_001
-- 应该回到初始大小(约10MB)预防措施:
python
import mysql.connector
import time
from datetime import datetime
def safe_batch_update(connection, batch_size=1000):
"""安全的批量更新,避免长事务"""
cursor = connection.cursor()
try:
# 1. 获取总记录数
cursor.execute("SELECT COUNT(*) FROM large_table")
total = cursor.fetchone()[0]
# 2. 分批处理
for offset in range(0, total, batch_size):
# 3. 每批一个事务
cursor.execute("START TRANSACTION")
cursor.execute("""
UPDATE large_table
SET status = 'processed'
WHERE id BETWEEN %s AND %s
AND status = 'pending'
""", (offset + 1, offset + batch_size))
# 4. 立即提交
connection.commit()
print(f"[{datetime.now()}] Processed {offset + batch_size}/{total}")
# 5. 短暂休眠,降低负载
time.sleep(0.1)
except Exception as e:
# 6. 异常回滚
connection.rollback()
print(f"Error: {e}")
raise
finally:
cursor.close()
# 使用示例
connection = mysql.connector.connect(...)
safe_batch_update(connection, batch_size=1000)案例2: MVCC版本链过长
问题现象:
sql
-- 查询性能突然下降
SELECT * FROM orders WHERE id = 100;
-- 耗时: 500ms (正常应该<1ms)
EXPLAIN SELECT * FROM orders WHERE id = 100;
-- 显示使用了主键索引,但扫描行数异常原因分析:
sql
-- 1. 检查行的版本链长度
SELECT
page_number,
record_number,
trx_id,
roll_pointer,
LENGTH(history) AS version_chain_length
FROM sys.innodb_buffer_page_lru
WHERE page_number = 12345;
-- 2. 查看未Purge的Undo记录
SHOW ENGINE INNODB STATUS\G
-- 输出:
History list length 5000000
Purge done for trx's n:o < 100000 undo n:o < 5000000
-- 3. 检查Purge进度
SELECT
NAME,
TYPE,
STATUS,
COUNT
FROM performance_schema.setup_instruments
WHERE NAME LIKE '%purge%';根本原因:
- 某行记录被更新了500万次
- 每次都生成新的Undo Record
- 版本链过长,一致性读需要遍历大量版本
解决方案:
sql
-- 1. 优化Purge线程配置
SET GLOBAL innodb_purge_threads = 8;
SET GLOBAL innodb_purge_batch_size = 5000;
-- 2. 重建表(清除历史版本)
OPTIMIZE TABLE orders;
-- 3. 或者ALTER TABLE重建
ALTER TABLE orders ENGINE=InnoDB;
-- 4. 验证性能恢复
SELECT * FROM orders WHERE id = 100;
-- 耗时: 0.5ms ✓案例3: 崩溃恢复中的Undo应用
故障场景:
2026-04-12 10:00:00 [ERROR] mysqld got signal 11
2026-04-12 10:00:05 [Note] Starting crash recovery...
2026-04-12 10:00:06 [Note] InnoDB: Doing recovery: scanned up to log sequence number 1234567890
2026-04-12 10:00:07 [Note] InnoDB: Starting an apply batch of log records to the database...
2026-04-12 10:00:10 [Note] InnoDB: Progress in percent: 10 20 30 40 50 60 70 80 90 100
2026-04-12 10:00:11 [Note] InnoDB: Apply batch completed
2026-04-12 10:00:12 [Note] InnoDB: Starting final batch to recover pages
2026-04-12 10:00:13 [Note] InnoDB: Last binlog file './mysql-bin.000123'
2026-04-12 10:00:14 [Note] InnoDB: Rollback of uncommitted transactions completed
2026-04-12 10:00:15 [Note] InnoDB: Shutdown completed恢复过程解析:
Step 1: Redo阶段(REDO)
┌──────────────────────────────────────────┐
│ 扫描ib_logfile0和ib_logfile1 │
│ 找到LSN 1000000 ~ 1234567890的所有记录 │
│ │
│ 事务A (COMMIT记录存在): │
│ LSN 1000: UPDATE accounts SET ... │
│ LSN 1001: UPDATE orders SET ... │
│ LSN 1002: COMMIT │ ← 已提交
│ │
│ 事务B (无COMMIT记录): │
│ LSN 1003: INSERT INTO users ... │
│ LSN 1004: UPDATE products SET ... │
│ (无COMMIT记录) │ ← 未提交
└──────────────────────────────────────────┘
↓
Step 2: Undo阶段(UNDO)
┌──────────────────────────────────────────┐
│ 遍历所有活跃事务(未提交) │
│ │
│ 事务A: 跳过(已提交) │
│ │
│ 事务B: 需要回滚 │
│ 1. 找到事务B的Undo Log │
│ 2. 逆序遍历Undo Record链表: │
│ a) LSN 1004的Undo: │
│ UPDATE products SET price=100 │
│ WHERE id=1 │
│ → 执行: UPDATE products │
│ SET price=100 WHERE id=1 │
│ │
│ b) LSN 1003的Undo: │
│ INSERT INTO users VALUES(1,...) │
│ → 执行: DELETE FROM users │
│ WHERE id=1 │
│ │
│ 3. 标记事务B为ROLLED_BACK │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 数据库恢复到一致状态 │
│ - 事务A的修改被保留(已提交) │
│ - 事务B的修改被撤销(未提交) │
└──────────────────────────────────────────┘监控恢复进度:
sql
-- 在另一个终端监控
watch -n 1 "mysql -e 'SHOW ENGINE INNODB STATUS\G' | grep -A 5 'ROLLBACK'"
-- 输出示例:
--- TRANSACTIONS ---
Trx id counter 123458
Purge done for trx's n:o < 123457 undo n:o < 0 state: running
ROLLING BACK 1 TRX
ROLLING BACK trx id 123456, 50000 undo operations remainingUndo Log vs Redo Log详细对比
| 维度 | Undo Log | Redo Log |
|---|---|---|
| 日志性质 | 逻辑日志 | 物理日志 |
| 记录内容 | 反向操作(SQL级别) | 物理页修改(字节级别) |
| 记录粒度 | 行级(Row-level) | 页级(Page-level) |
| 写入时机 | 数据修改时立即写入 | 事务提交时批量写入 |
| 存储位置 | Undo表空间(ibdata1或独立) | Redo Log文件(ib_logfile0/1) |
| 组织方式 | Undo Segment → Page → Record | Log Group → Block → Record |
| 生命周期 | 提交后保留(MVCC),Purge时清理 | Checkpoint后可覆盖 |
| 事务特性 | 原子性(Atomicity) | 持久性(Durability) |
| 崩溃恢复 | Undo未提交事务 | Redo已提交事务 |
| MVCC支持 | ✓ 提供历史版本 | ✗ 不支持 |
| 锁机制 | 配合锁实现并发控制 | 无关 |
| 文件大小 | 动态增长,可Truncate | 固定大小,循环使用 |
| 性能影响 | 大事务频繁写入会影响性能 | 顺序追加,性能高 |
| 典型大小 | 几百MB ~ 几GB | 几十MB ~ 几GB |
最佳实践
1. 配置优化
ini
[mysqld]
# 生产环境推荐
innodb_undo_tablespaces = 2 # 分离Undo和Data IO
innodb_undo_log_truncate = ON # 启用自动清理
innodb_max_undo_log_size = 2G # 触发Truncate的阈值
innodb_purge_threads = 8 # 提高Purge能力
innodb_purge_batch_size = 5000 # 批量Purge大小2. 避免长事务
python
import contextlib
from datetime import datetime, timedelta
class TransactionGuard:
"""事务守卫,防止长事务"""
def __init__(self, connection, timeout_seconds=60):
self.connection = connection
self.timeout = timedelta(seconds=timeout_seconds)
self.start_time = None
def __enter__(self):
self.start_time = datetime.now()
cursor = self.connection.cursor()
cursor.execute("START TRANSACTION")
return cursor
def __exit__(self, exc_type, exc_val, exc_tb):
elapsed = datetime.now() - self.start_time
if elapsed > self.timeout:
# 超时,强制回滚
self.connection.rollback()
raise TimeoutError(
f"Transaction exceeded {self.timeout} seconds"
)
if exc_type is not None:
# 发生异常,回滚
self.connection.rollback()
else:
# 正常提交
self.connection.commit()
# 使用示例
with TransactionGuard(connection, timeout_seconds=30) as cursor:
cursor.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
cursor.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
# 自动提交或回滚3. 监控Undo使用
sql
-- 创建监控视图
CREATE VIEW v_undo_monitor AS
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_modified,
trx_concurrency_tickets,
trx_isolation_level,
trx_unique_checks,
trx_foreign_key_checks,
trx_weight
FROM information_schema.INNODB_TRX
ORDER BY trx_rows_modified DESC;
-- 查询监控数据
SELECT * FROM v_undo_monitor
WHERE duration_sec > 60 OR trx_rows_modified > 10000;
-- 告警规则(Prometheus + Grafana)
-- alert: UndoLogTooLarge
-- expr: innodb_history_list_length > 1000000
-- for: 5m
-- labels:
-- severity: warning
-- annotations:
-- summary: "Undo Log history list too long"4. 批量操作优化
python
def batch_delete_with_purge(connection, table_name, condition, batch_size=5000):
"""
分批删除,让Purge线程及时清理
"""
cursor = connection.cursor()
try:
while True:
# 每批一个事务
cursor.execute("START TRANSACTION")
sql = f"""
DELETE FROM {table_name}
WHERE {condition}
LIMIT %s
"""
cursor.execute(sql, (batch_size,))
affected = cursor.rowcount
# 立即提交
connection.commit()
print(f"Deleted {affected} rows")
if affected < batch_size:
break # 没有更多数据
# 短暂休眠,让Purge线程工作
time.sleep(0.5)
except Exception as e:
connection.rollback()
raise e
finally:
cursor.close()
# 使用示例
batch_delete_with_purge(
connection,
"logs",
"created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)",
batch_size=5000
)参考资料
MySQL官方文档
源码文件
storage/innobase/trx/trx0undo.cc- Undo Log管理storage/innobase/trx/trx0rec.cc- Undo Record操作storage/innobase/trx/trx0rollback.cc- 事务回滚实现storage/innobase/read/read0read.cc- MVCC一致性读storage/innobase/include/trx0rseg.h- 回滚段头文件
相关术语
技术文章
- Jeremy Cole: InnoDB transaction history
- Facebook: Undo tablespace truncation in InnoDB
- Percona: Understanding InnoDB undo logs
版本历史:
- 2026-04-12: 初始版本,全面讲解Undo Log原理、MVCC与实践