深蓝黑背景品牌封面:左侧大标题「undo log 与 redo log」,副标题「数据库事务靠什么实现」;右侧原创装饰——两卷日志,一卷带逆时针箭头寓意回滚(undo),一卷带顺时针箭头寓意重放(redo),共同托住一枚 ACID 徽章;左上标注「经典题解析 · 数据库」,右上标注「Day 04 · 10-02」 图 1:本文封面,点明两卷日志与 ACID。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「事务的 ACID 靠什么实现」是数据库面试的骨架题——undo log、redo log 两个词人人会背,但「崩溃的那一刻数据库做了什么」能把一半人问倒。

想象面试现场,面试官的问题一步到位:InnoDB 的事务是怎么实现的?undo log 和 redo log 各管什么?

第一步,先分工:两卷日志各管两个字母。

ACID 四个字母,落地的主力是两卷日志加一套并发控制。undo log 管原子性:每次修改前先把「改前镜像」记下来,事务回滚时按镜像原样还原;它还顺带支撑多版本读——别的事务要读旧版本,就从 undo log 里取(MVCC 的细节是另一道题)。redo log 管持久性:提交前把「改了哪里、改成什么」写进日志并刷盘,崩溃后用它把已提交但没落盘的修改重放回来。隔离性靠锁加多版本控制,一致性是前三者共同托出来的结果——答题时点一句就够。

第二步,一次修改的完整路径。

一笔 UPDATE 在 InnoDB 里走四步。第一,写 undo log:把改前的旧值记下来,退路先留好。第二,改内存里的数据页——页变「脏」,暂不刷盘。第三,写 redo log 并先刷盘:提交事务时,redo 必须先于数据页落盘,这就是 WAL(先写日志)的原则。第四,提交成功后,后台线程再慢慢把脏页刷回数据文件。

为什么要绕这一圈?因为数据页是随机写,慢;日志是顺序追加,快。用「顺序写日志」换「随机写数据」的延迟,提交时只需要保证日志安全落盘,响应就敢返回。MySQL 官方文档对 redo log 的定位也正是如此:它是基于磁盘的结构,用于崩溃恢复——意外关闭前没来得及写进数据文件的修改,在重启初始化、接受连接之前自动重放。

第三步,崩溃恢复:先 redo,再 undo。

数据库宕机重启后干两件事,顺序不能反。先拿 redo log 把数据重放到崩溃那一刻——已提交事务的修改一个不落全回来;再用 undo log 把当时还没提交的事务全部回滚——半途而废的修改一个不留清干净。两卷日志配合完,数据库回到一致状态,才开门接客。

第四步,用可运行代码把整套机制坐实。

下面这份代码实现一个极简 WAL 引擎,演示提交未刷盘、未提交、崩溃、恢复的全过程,用 Node.js 22.15.0 直接运行(node wal-engine.js)即可:

'use strict';

// 极简 WAL 引擎:undo log 管回滚,redo log 管崩溃恢复
const engine = {
  pages: {},        // 内存中的数据页
  diskPages: {},    // 已落盘的数据页(脏页由后台慢慢刷)
  undoLog: [],      // 改前镜像
  redoBuffer: [],   // 改了哪里、改成什么
  redoDisk: [],     // 已刷盘的 redo(提交时强制刷)
  begin() { this.txid = (this.txid || 0) + 1; return this.txid; },
  set(txid, key, value) {
    this.undoLog.push({ txid, key, old: this.pages[key] ?? null });
    this.redoBuffer.push({ txid, key, value });
    this.pages[key] = value;      // 页变脏,暂不刷盘
  },
  commit(txid) {
    for (const r of this.redoBuffer.filter((r) => r.txid === txid)) this.redoDisk.push(r); // WAL:日志先落盘
    this.committed = (this.committed || new Set()).add(txid);
  },
  rollback(txid) {
    for (const u of [...this.undoLog].reverse()) {
      if (u.txid !== txid) continue;
      if (u.old === null) delete this.pages[u.key]; else this.pages[u.key] = u.old;
    }
  },
  checkpoint() { this.diskPages = { ...this.pages }; }, // 后台线程刷脏页
  crash() { this.pages = { ...this.diskPages }; },      // 内存丢失,只剩落盘数据
  recover() {
    const undone = new Set();
    for (const r of this.redoDisk) {
      if (this.committed.has(r.txid)) this.pages[r.key] = r.value; // 先 redo 重放到崩溃时刻
      else undone.add(r.txid);
    }
    for (const txid of undone) this.rollback(txid);                 // 再 undo 未提交事务
  },
};

// 事务 1:提交但脏页还没刷盘就崩溃
let t1 = engine.begin();
engine.set(t1, 'A', 100);
engine.set(t1, 'B', 200);
engine.commit(t1);

// 事务 2:改到一半,未提交
let t2 = engine.begin();
engine.set(t2, 'A', 999);

console.log('崩溃前内存中的页:', JSON.stringify(engine.pages));
console.log('已落盘的页:', JSON.stringify(engine.diskPages));

engine.crash();
console.log('崩溃重启后从磁盘读到的页:', JSON.stringify(engine.pages));

engine.recover();
console.log('恢复完成:redo 重放已提交事务 + undo 回滚未提交事务 →', JSON.stringify(engine.pages));

// 回滚演示:正常运行中主动 rollback
let t3 = engine.begin();
engine.set(t3, 'C', 300);
engine.rollback(t3);
console.log('事务 3 主动回滚后,C 不存在:', !('C' in engine.pages));

实测输出如下:

崩溃前内存中的页: {"A":999,"B":200}
已落盘的页: {}
崩溃重启后从磁盘读到的页: {}
恢复完成:redo 重放已提交事务 + undo 回滚未提交事务 → {"A":100,"B":200}
事务 3 主动回滚后,C 不存在: true

崩溃时磁盘上一无所有,但已提交的 A=100、B=200 靠 redo 全数找回,未提交的 A=999 被 undo 清掉——两卷日志各管生死,谁也不越界。

写入路径与职责映射图:顶部是事务的一次修改——①改数据前先把「改前镜像」写入 undo log(回滚与 MVCC 旧版本都靠它);②修改内存中的数据页(页变脏);③把「改了哪里、改成什么」写入 redo log 并先刷盘(WAL:日志先于数据落盘);④提交成功后才由后台线程慢慢把脏页刷回磁盘。左下是两条日志的职责对照:undo log 管原子性(事务回滚时按镜像还原)并支撑多版本读,redo log 管持久性(崩溃后重放已提交但没落盘的修改)。右下是崩溃恢复时序:重启后先用 redo 把数据重放到崩溃时刻,再用 undo 回滚当时未提交的事务,数据库回到一致状态 图 2:一次修改的写入路径、两卷日志的职责与崩溃恢复时序。

可能的追问。

「binlog 和 redo log 什么关系?」redo 是 InnoDB 引擎层的物理日志,管崩溃恢复;binlog 是 Server 层的逻辑日志,管主从复制与归档——两阶段提交保证两者一致。「redo log 无限长吗?」不是,它循环复用固定空间,脏页刷盘推进检查点,旧日志才能覆盖。「长事务有什么坑?」undo log 为了留着旧版本不能清理,会越积越大——长事务是生产环境常见杀手。

易错点清单。

一,把两卷日志的职责说反——undo 管「回去」(回滚与旧版本),redo 管「重来」(崩溃重放)。二,以为提交时数据页必须刷盘——提交只保证 redo 落盘,脏页后台慢慢刷,这正是性能的来源。三,崩溃恢复顺序讲反——必须先 redo 重放、再 undo 清理,反了就把已提交的数据也回滚没了。四,忘记 undo log 还养着多版本读,只答「回滚用」。

回到面试现场,建议的答题节奏:先把 ACID 分工摆上桌面,再沿「undo→改页→redo 先刷→脏页后刷」走完一次修改,最后讲崩溃恢复的先 redo 后 undo。标注平台、实验系统的写入正确性全靠这套机制;WAL「先日志后数据」的思想,在模型 checkpoint 与数据管道容错里也会再见面。

你排查过数据库崩溃恢复或长事务撑爆 undo 的问题吗?欢迎分享你的复盘。