Skip to content

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 = 2G

innodb_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 remaining

Undo Log vs Redo Log详细对比 ​

维度Undo LogRedo Log
日志性质逻辑日志物理日志
记录内容反向操作(SQL级别)物理页修改(字节级别)
记录粒度行级(Row-level)页级(Page-level)
写入时机数据修改时立即写入事务提交时批量写入
存储位置Undo表空间(ibdata1或独立)Redo Log文件(ib_logfile0/1)
组织方式Undo Segment → Page → RecordLog 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与实践

Released under MIT License.