Java 王者修炼手册【Mysql 篇 - 日志】:吃透 MySQL redo log + undo log + binlog 底层机制

63 阅读26分钟

大家好,我是程序员强子。

2043537.jpg

昨天学习了Mysql 四大金刚之一的 索引 ,今天则专注把另一个金刚 日志 相关给弄明白~

  • redo log /undo log / binlog 核心作用,特点,工作流程,案例
  • 两阶段提交 /double write /write-aheard-logging (WAL) 到底是什么?有什么作用?使用它们目的是什么?

干货很多,来不及解释,赶紧上车~

redo log

是ACID 中的 D ,持久性

文件格式 & 内容

格式

ib_logfile + 数字序号 ,默认生成 2 个文件 ib_logfile0 和 ib_logfile1;

固定大小的文件,追加写入到文件末尾,会循环覆盖旧日志

内容

记录的是 数据页的物理修改循环写

比如:表 t 的数据页 10 中,偏移量 50 的值从 10 改成 20

而非 SQL 逻辑

因此恢复时无需解析 SQL,直接应用数据页修改,速度极快

刷盘策略

核心控制参数:innodb_flush_log_at_trx_commit

取值有 0、1、2 三种,默认值为 1(强可靠性优先)

  • 0

    • 事务提交时不刷盘,由 InnoDB 后台线程每秒刷盘,可能丢失 1 秒内的已提交数据
    • 非核心业务(如日志存储、测试环境)
  • 1(默认,最安全):

    • 事务提交时,强制刷盘(fsync),确保 redo log 持久化;
    • 金融、支付等核心业务(需严格保证 ACID)
  • 2:

    • 事务提交时,写入操作系统缓存(page cache),由操作系统每秒刷盘;
    • 大部分非核心业务(如电商普通订单、用户行为数据)

核心作用

两大核心作用: 保证事务持久性 + 提升写入性能

如何保证事务持久性?

事务提交后,redo log 会先记录 数据页要做什么修改

异步刷新数据文件(.ibd)

崩溃后可通过 redo log 恢复未刷盘的修改

如何提升写入性能?

数据修改时,若直接刷写数据文件(.ibd),会产生大量随机IO(速度慢);

为何 会产生 随机IO呢? 因数据页在磁盘上分散存储,查找也费力~

而 redo log 是 顺序IO

只需先将修改记录到 redo log

数据页(脏页)可后续由后台线程批量刷新,极大提升写入吞吐量

顺序IO 和随机IO 有什么区别?

顺序IO 顾名思义,连续地址读写 ,无跳转

随机IO 离散地址读写,频繁跳转

  • 机械硬盘 相差 10100 倍,顺序读写≈100MB/s+,随机 IO 常 < 110MB/s
  • 固态硬盘 相差 210 倍,顺序≈500MB/s1GB/s,随机≈50~200MB/s

正常事务执行流程

假设执行 SQL:update t set a=20 where id=1;

正常流程:

  • 读取数据到内存

    • InnoDB 从磁盘读取 id=1 对应的 data page(数据页)到 Buffer Pool
  • 修改内存数据页

    • 在 Buffer Pool 中修改数据页的 a 值(从 10→20),此时数据页成为脏页
  • 记录 redo log 到内存缓冲区

    • 生成一条 redo log 记录
    • 包含:数据页地址、偏移量、修改前值、修改后值、事务 ID 等)
    • 写入redo log buffer
  • 刷redo log到磁盘

    • 根据刷盘策略(由 innodb_flush_log_at_trx_commit 控制)
    • 将 redo log buffer 中的内容刷到磁盘的 redo log 文件
  • 事务提交成功

    • redo log 刷盘完成后,事务返回 提交成功,不关注脏页是否刷盘
  • 后台异步刷脏页

    • InnoDB 的 master thread 等后台线程,会在空闲时(如 Buffer Pool 满、系统负载低)
    • double write机制 保证 InnoDB 数据页的完整性
    • 将 Buffer Pool 中的脏页批量刷新到数据文件(.ibd)

什么是double write机制?跟着强子仔细研究一下:

double write机制

触发时机

属于 Buffer Pool 中的脏页批量刷新到数据文件(.ibd)

触发场景

  • 后台线程主动刷脏:InnoDB 的 master thread 等后台线程,在空闲时(如系统负载低)批量刷新 Buffer Pool 中的脏页;
  • Buffer Pool 满时刷脏:新数据页需加载到 Buffer Pool,而空间不足时,会淘汰旧脏页并触发刷盘;
  • 手动触发刷脏:执行 flush tables、alter table 等语句,或设置 innodb_max_dirty_pages_pct 阈值触发;
  • Checkpoint 触发:InnoDB 的 Checkpoint 机制(如 Sharp Checkpoint、Fuzzy Checkpoint)触发脏页批量刷盘

工作流程

  • 内存数据页修改:事务执行时,Buffer Pool 中的数据页被修改为脏页

  • 写入 double write buffer:脏页刷盘前,先将完整的数据页写入内存中的double write buffer(全局共享缓冲区);

  • 刷盘到 double write 文件:将 double write buffer 中的数据页批量刷盘到磁盘上的「double write 文件」(ibdata1 中或独立表空间),此过程为顺序写,性能高效;

  • 刷盘到数据文件:确认 double write 文件写入成功后,再将数据页刷盘到实际的 .ibd 数据文件(随机写,因数据页分散存储);

  • 崩溃恢复流程:数据页部分写入,异常中断,重启时 InnoDB 会:

    • 检测到 .ibd 中的损坏数据页;
    • double write 文件中读取该页的完整备份,覆盖损坏页;
    • 通过 redo log 对该完整页应用未完成的修改,最终恢复数据一致性。

崩溃恢复流程

触发时机:在 事务提交成功 后 ,后台异步刷脏页 前,发生故障崩溃(脏页未刷盘)

重启时触发恢复:

  • InnoDB 启动时,扫描 redo log 文件组
  • 过滤出 已提交事务的 redo log 记录,通过事务ID 区分,未提交事务的记录忽略
  • 将这些记录对应的修改,重新应用到对应的数据页
  • 应用完成后,删除已失效的 redo log 记录(已刷盘的记录) ,恢复完成

特点

  • InnoDB 独有的事务日志 ,二进制格式 的日志,非明文 SQL 或文本。

  • 采用预写日志WAL 机制)方式

  • 崩溃安全(Crash-Safe)

    • 无论正常关闭还是异常崩溃,InnoDB 重启时都会扫描 redo log
    • 已记录但未刷到数据文件 的修改重新应用到数据页,保证数据一致性
  • 与事务绑定

    • 每个 redo log 记录都包含事务 ID(XID
    • 已提交事务的修改会最终保留,未提交事务的 会在崩溃恢复时会被忽略

上文提到WAL机制 ,什么是WAL预写日志呢?

不用急,跟着强子的脚步继续探寻~

WAL

特点

  • 修改数据前,必须先将 数据修改记录 写入日志
  • 日志刷盘持久化后,再修改内存中的数据页;
  • 后续数据页会异步批量刷到磁盘(.ibd 文件)

简单来说: 日志先行,数据后写,日志是数据修改的 前置保障

作用

  • 保证crash-safe : 崩溃不丢数据
  • 极大提升写入性能 : 顺序 IO VS 随机IO
  • 减少刷盘开销: 不用每次修改都立即刷数据页到磁盘, 并且是批量,减少IO次数

binlog

由 MySQL 服务器(Server 层)生成,而非存储引擎,因此支持所有存储引擎

文件格式 & 内容

格式

  • 以文件组形式存储(默认 mysql-bin.000001、mysql-bin.000002...)
  • 按配置自动轮转,不会覆盖旧日志,需手动清理或设置过期时间

内容

记录的是数据修改的逻辑操作,而非物理地址修改

刚好 redo log 物理日志相反

仅记录已提交事务的操作(未提交事务不会写入 binlog)

具体格式分三种:

  • STATEMENT 格式

    • 记录执行的 SQL 语句(如 update t set a=20 where id=1)
    • 有SQL歧义, 有可能id =1 的数据不存在
  • ROW 格式(默认推荐)

    • 记录行数据的变更前后状态(如「id=1 的行,a 从 10 改成 20」)
    • 无 SQL 歧义,复制更精准;
  • MIXED 格式

    • 自动切换 STATEMENT/ROW 格式
    • 简单 SQL 用 STATEMENT,复杂 SQL 用 ROW

刷盘策略

控制 binlog 从内存(binlog cache)刷到磁盘的时机:

  • sync_binlog=0(默认):

    • 由操作系统决定刷盘时机(可能丢失未刷盘的 binlog);
    • 非核心业务(如日志存储、测试环境、内部管理系统)
  • sync_binlog=1(生产推荐,最安全):

    • 事务提交时强制刷盘,确保 binlog 持久化;
    • 金融、支付、核心交易业务
  • sync_binlog=N(N>1):

    • 累计 N 个事务后批量刷盘(平衡性能与安全性)
    • 高并发写入的普通业务

核心作用

主从复制

主库的 binlog 记录所有数据修改操作

从库通过复制主库的 binlog 并执行,实现主从数据一致

是 MySQL 读写分离负载均衡高可用架构(如 MGR、主从切换)的基础

时间点恢复

当数据发生误操作(如误删表误更新)时,

可通过「全量备份 + binlog 增量恢复」

将数据恢复到误操作前的任意时间点,避免数据丢失

补充事务一致性

与 redo log 通过「两阶段提交」协作,确保主从复制时 binlog 与 redo log 数据一致,避免主从数据差异

上面提到的时间点恢复具体步骤是怎么样的呢?两阶段提交又是怎么回事?

  • 通过binlog如何恢复数据?
  • 两阶段提交是怎么回事?

跟强子学习一下,有备无患,说不定哪天就用上了~

binlog恢复增量数据

核心前提

  • MySQL 已开启 binlog必须,增量恢复依赖 binlog 记录变更);
  • 存在一份 有效全量备份(备份时记录了对应的 binlog 文件名和位置,作为增量恢复的起点);
  • binlog 日志文件未被删除 / 覆盖(需提前配置 binlog 保留策略

记得检查生产环境是否都符合~不然神仙难救~~

核心逻辑

本质是:用全量备份恢复到某个 基准时间点

再通过 binlog 重放该时间点之后的所有数据变更(增删改)

最终恢复到目标状态

前期准备

环境说明

  • MySQL 版本:5.7/8.0(兼容);
  • 目标数据库:test_db(需恢复的数据库);
  • 全量备份工具:MySQL 自带 mysqldump(无需额外安装,最常用);
  • 增量恢复工具:MySQL 自带 mysqlbinlog(解析 binlog 并执行恢复)。

如何确认 binlog 已开启?

# 登录 MySQL 执行
mysql -u root -p
mysql> show variables like 'log_bin';  # 结果 Value 为 ON 表示已开启
mysql> show variables like 'binlog_format';  # 推荐 ROW 格式(恢复更精准,避免 SQL 兼容性问题)
mysql> show master status;  # 查看当前 binlog 文件名(如 binlog.000005)和当前位置(Position,如 154)

未开启 binlog?如何配置?

编辑 MySQL 配置文件 my.cnf

[mysqld]
log_bin = /var/lib/mysql/binlog  # binlog 日志文件名前缀(路径与 datadir 一致即可)
binlog_format = ROW  # 必选,基于行的格式,恢复无歧义
server-id = 1  # 主从架构必填,单机可随便设一个非 0 整数
expire_logs_days = 7  # binlog 保留 7 天(避免被自动删除,根据需求调整)

重启 MySQL 生效

systemctl restart mysqld

完整恢复流程

时间线是怎么样的?

  • T0(10:00):全量备份 test_db 数据库(基准备份);
  • T1(10:00~14:00):执行一系列数据变更(插入、更新数据);
  • T2(14:00):误操作删除 test_db.test_table 表,需恢复到 T2 之前的状态

操作步骤是怎么样的?

先删除损坏的数据库(避免冲突,谨慎操作!),记得备份做好

drop database if exists test_db;

接着准备好恢复资料

  • 确定 全量备份sql文件 比如 test_db_full_20251201_1000.sql

执行全量备份sql ,验证全量恢复结果

  • T0 前的 2 条数据
  • T1 的变更未恢复

提取增量 binlog

  • 需要找到对应的最后执行的sql定位所在的binlog,比如binlog.000005
  • 确定备份对应的 binlog 位置:154(增量恢复需从该位置开始)

查看 binlog 日志,找到 “误操作前的终点位置”

# 解析 binlog 文件,查看所有操作(按时间排序)
mysqlbinlog --base64-output=decode-rows -v /var/lib/mysql/binlog.000005

输出示例

# at 154  # 全量备份对应的起点位置
#251201 10:05:23 server id 1  end_log_pos 219 CRC32 0x...  Query  thread_id=1  exec_time=0  error_code=0
SET TIMESTAMP=1733052323/*!*/;
insert into test_table (name) values ('王五')  # 第一条增量数据
/*!*/;
# at 219
#251201 10:06:10 server id 1  end_log_pos 284 CRC32 0x...  Query  thread_id=1  exec_time=0  error_code=0
SET TIMESTAMP=1733052370/*!*/;
insert into test_table (name) values ('赵六')  # 第二条增量数据
/*!*/;
# at 284
#251201 10:07:30 server id 1  end_log_pos 359 CRC32 0x...  Query  thread_id=1  exec_time=0  error_code=0
SET TIMESTAMP=1733052450/*!*/;
update test_table set name = '张三_更新' where id = 1  # 第三条增量数据
/*!*/;
# at 359
#251201 14:00:00 server id 1  end_log_pos 424 CRC32 0x...  Query  thread_id=1  exec_time=0  error_code=0
SET TIMESTAMP=1733065200/*!*/;
drop table test_table  # 误操作(需停止在该位置之前)
/*!*/;
  • 增量恢复的起点:154(全量备份对应的位置);
  • 增量恢复的终点:359(误操作 drop table 之前的位置)

应用增量 binlog 恢复

使用 mysqlbinlog 命令解析并执行增量部分的 binlog

# 从起点 154 到终点 359,执行 binlog 中的变更
mysqlbinlog --start-position=154 --stop-position=359 /var/lib/mysql/binlog.000005 | mysql -u root -p

验证恢复结果,看结果是否恢复到误操作前的状态

关键工具与命令汇总

操作场景工具 / 命令
全量备份mysqldump -u root -p --master-data=2 --single-transaction --databases 库名 > 备份文件.sql
查看备份的 binlog 位置grep "CHANGE MASTER TO" 备份文件.sql
全量恢复mysql -u root -p < 备份文件.sql
解析 binlog(查看内容)mysqlbinlog --base64-output=decode-rows -v binlog文件
增量恢复(按位置)`mysqlbinlog --start-position = 起点 --stop-position = 终点 binlog 文件
增量恢复(按时间)`mysqlbinlog --start-datetime="2025-12-01 10:00:00"--stop-datetime="2025-12-01 14:00:00" binlog 文件

注意事项

  • binlog 必须开启且格式为 ROW

    • 避免使用 STATEMENT 格式(可能因 SQL _mode 差异导致恢复失败);
    • 查看格式:show variables like 'binlog_format';,需设为 ROW。
  • 全量备份需记录 binlog位置

    • 必须加 --master-data=2 参数,否则无法确定增量恢复的起点
    • 若备份时未加该参数,需通过 show master status 手动记录备份时的 binlog 文件名和位置。
  • binlog 文件不能丢失

    • 配置 expire_logs_days 保留足够长时间的 binlog(如 7~30 天);
    • 重要场景可定期归档 binlog 到异地存储。
  • 恢复前备份当前数据

    • 恢复前若数据库仍有残留数据,建议先备份(如 mysqldump -u root -p --databases 库名 > 临时备份.sql),避免恢复失败导致数据二次丢失。
  • 大事务处理

    • 若增量 binlog 包含大事务,恢复时可能占用较多资源,建议在业务低峰期执行。

两阶段提交

是协调redo log和binlog一致性的核心机制

事务提交时,redo log 的写入会拆成两个阶段,而非一次性提交,最终保证两个日志的记录完全同步

具体过程

第一阶段做了什么操作?

  • 事务执行完所有 SQL,InnoDB 写入该事务的所有 redo log 记录
  • 把 redo log 的状态标记为Prepare(准备就绪);
  • 此时事务未真正提交,redo log 已持久化,但 binlog 还没写

第二阶段做了什么操作?

  • Server层写入该事务的 binlog,并刷盘持久化
  • 将 redo log 的状态从Prepare改为Commit
  • 事务正式提交完成

两阶段提交的原因

MySQL 中 redo log 和 binlog 是 两个独立的日志系统用途不同必须保持一致

  • redo log:InnoDB 层的物理日志,用于崩溃恢复(保障 crash-safe);
  • binlog:Server 层的逻辑日志,用于主从复制、数据备份恢复

如果不做两阶段提交,直接 一次性写日志,会出现两种致命的不一致场景

场景 1:先写 redo log,再写 binlog(宕机在 binlog 写入前)

  • 结果:redo log 已记录事务,但 binlog 没记录该事务;

  • 问题

    • 主从复制时,从库没同步到这个事务,导致主从数据不一致;
    • 用 binlog备份恢复时,也会丢失该事务

场景 2:先写 binlog,再写 redo log(宕机在 redo log 写入前)

  • 结果:binlog 已记录事务,但 redo log 没记录;

  • 问题

    • 崩溃恢复时,InnoDB 找不到该事务的 redo log,会回滚事务,导致主库数据丢失
    • 但从库 / 备份有该数据,依然不一致

结论:两阶段提交的核心目的,是让 redo log 和 binlog 要么 都成功写入,要么 都不写入,避免因宕机导致的双日志不一致

binlog 与 redo log 差异

特性binlogredo log
所属层MySQL 服务器层(所有引擎通用)InnoDB 存储引擎层(仅 InnoDB)
日志类型逻辑日志(SQL / 行变更)物理日志(数据页修改)
核心作用主从复制、时间点恢复崩溃恢复、保证事务持久性
写入方式追加写(可轮转,无限增大)循环写(固定大小,覆盖旧日志)
刷盘时机事务提交后(按 sync_binlog 策略)事务提交时(按 innodb_flush_log_at_trx_commit 策略)
记录范围所有数据库的修改操作仅 InnoDB 表的数据页修改
恢复场景误操作恢复、跨库同步数据库崩溃后的数据恢复

undo log

是ACID 中的 A ,原子性

文件格式 & 内容

物理存储

InnoDB 引擎层的 逻辑日志 ,二进制内容

它没有独立的 专属文件,而是嵌入在 InnoDB 表空间中

  • 共享表空间(默认,MySQL 5.7 及之前):存储在 ibdata1 文件中(与数据字典、undo 段、临时表空间等共用);

  • 独立 undo 表空间(推荐,MySQL 8.0 默认开启)

    • 通过参数 innodb_undo_tablespaces 配置,生成独立文件 undo_001、undo_002(默认 2 个,可扩展),
    • 存储路径由 innodb_undo_directory 指定(默认与 datadir 一致)

逻辑存储结构

undo log 在表空间中以 段 - 区 - 页 的层级结构存储:

  • undo段(Undo Segment):每个事务会分配一个或多个 undo 段,用于存储该事务的所有 undo 记录;
  • undo页(Undo Page):undo 段由多个连续的数据页组成(默认页大小 16KB),undo 记录按顺序追加写入;
  • 回滚段(Rollback Segment):InnoDB 默认有 128 个回滚段(参数 innodb_rollback_segments 控制),每个回滚段可管理多个 undo 段,用于复用资源、减少碎片

特性

undo log 是 循环写入可回收 的:

  • 事务提交后,undo log 不会立即删除(MVCC 可能需要读取历史版本);
  • 当 undo log 对应的 历史版本 不再被任何事务引用时,InnoDB 的 purge 线程会异步清理这些过期 undo 记录,释放页空间供新事务复用;

内容

核心元数据

  • 事务 ID(TRX_ID):所属事务的唯一标识,用于关联事务和 undo 记录;
  • 回滚指针(ROLL_PTR):指向当前记录的上一个版本的 undo 记录(形成 “版本链”,支撑 MVCC 读取);
  • 表 ID(TABLE_ID):标识操作的目标表(InnoDB 内部表唯一标识,非用户可见的表名);
  • 操作类型(OP_TYPE):标记操作类型(如 INSERT、UPDATE、DELETE);
  • 主键 / 唯一键信息:定位被操作的行(如主键值,用于精准回滚时找到目标记录)

不同操作的 undo 记录内容

  • INSERT

    • 核心内容:仅记录 插入行的主键值(无需记录其他字段);
    • 回滚逻辑:根据主键直接删除这条插入的行
    • 特点:事务提交后,这类 undo 记录最容易被 purge 清理
  • UPDATE

    • 核心内容:记录 被修改字段的 “旧值”(仅修改前的原始值,不记录新值)+ 主键;
    • 回滚逻辑:用旧值覆盖当前字段的新值,恢复到修改前状态
  • DEL

    • 核心内容:记录 被删除行的完整字段值(所有列的原始数据)+ 主键;
    • 回滚逻辑:重新插入这条完整记录
    • 注意:InnoDB 的 DELETE 是 标记删除(逻辑删除),事务提交后,该行不会立即从数据页中物理删除,而是等待 purge 线程根据 undo log 清理

工作流程

undo log 的工作流程

  • 事务执行时生成
  • 事务回滚时应用
  • MVCC 读时遍历
  • 过期后清理

四个核心环节展开

InnoDB 表的每行数据默认包含三个隐藏字段,为 undo log 提供基础:

  • DB_TRX_ID:记录最后修改该行的事务 ID;
  • DB_ROLL_PTR:指向该行对应的 undo log 记录(版本链指针);
  • DB_ROW_ID:若表无主键或唯一索引,自动生成的行唯一标识

事务执行时

假设执行事务 (初始 id=1 的行 a=10)

begin
update t set a=20 where id=1
update t set a=30 where id=1
commit;
  • 事务启动,分配唯一事务ID(如 trx_id=100)

  • 第一次 update(a=10→20)

    • 读取 id=1 的数据页到 Buffer Pool;
    • 生成一条 undo log 记录(类型:UPDATE,包含旧值 a=10、DB_TRX_ID=100、上一版本指针 null);
    • 将该 undo log 写入 undo 表空间(同时生成 redo log 记录 undo log 的修改,确保持久化);
    • 更新数据行DB_TRX_ID=100,DB_ROLL_PTR 指向刚生成的 undo log 记录;
    • 修改内存数据页的 a 值为 20(脏页
  • 第二次 update(a=20→30)

    • 生成新的 undo log 记录(类型:UPDATE,包含旧值 a=20、DB_TRX_ID=100、上一版本指针→第一次的 undo log 记录);
    • 写入 undo 表空间并记录 redo log;
    • 更新数据行DB_ROLL_PTR 指向本次的 undo log 记录;
    • 修改内存数据页的 a 值为 30(脏页)
  • 此时,id=1 行的版本链为:数据页当前值(a=30,trx_id=100)→ undo log2(a=20)→ undo log1(a=10)

事务回滚时

若上述事务执行第二次 update 后,因业务错误执行 rollback;

  • InnoDB 读取该事务生成的所有 undo log 记录(按生成顺序反向遍历:先 undo log2,再 undo log1);
  • 应用 undo log2:将数据行的 a 值从 30 改回 20;
  • 应用 undo log1:将数据行的 a 值从 20 改回 10;
  • 清空该事务的 undo log 标记(后续由 purge 线程清理);
  • 事务回滚完成,数据恢复到事务开始前的状态(a=10)

MVCC 读时

假设事务 A(trx_id=200)在事务 B(trx_id=100)执行 update 期间,以「可重复读」隔离级别读取 id=1 的行:

  • 事务 A 启动时,InnoDB 会记录当前 活跃事务 ID 列表(此时包含 trx_id=100);
  • 事务 A 读取 id=1 的数据行,发现其 DB_TRX_ID=100, 属于活跃事务,不可直接读取;
  • 通过 DB_ROLL_PTR 遍历版本链,找到上一版本的 undo log2(trx_id=100,仍活跃);
  • 继续遍历到 undo log1(trx_id=100,仍活跃,不可直接读取)
  • 再往上得到初始版本,无事务 ID,确认初始版本(a=10)对事务 A 可见(无冲突),返回 a=10 给事务 A
  • 即使事务 B 后续提交(trx_id=100 变为非活跃),事务 A 再次读取时,仍通过版本链找到初始版本 a=10,保证 可重复读

undo log 清理

  • 事务提交后,其生成的 undo log 被标记为 过期(但不会立即删除,因为可能有其他事务在读取该版本链);
  • InnoDB 的 purge 线程是后台线程,定期扫描 undo 表空间,筛选出 所有活跃事务都不再访问 的过期 undo log
  • 删除这些 undo log 记录,释放 undo 表空间的存储空间,实现循环复用

核心作用

保证事务原子性

  • 事务执行过程中,若发生错误(如 SQL 执行失败、手动 rollback、系统崩溃)
  • 可通过 undo log 撤销事务已执行的修改,恢复到事务开始前的状态
  • 确保事务 要么全做要么全不做

支撑 MVCC

  • 为读取操作提供 历史数据版本

    • 当其他事务修改了数据,当前事务可通过 undo log 遍历历史版本链,读取到事务开始前或指定版本的数据
    • 实现 读不加锁写不阻塞读的并发控制
    • 避免脏读不可重复读

mvcc

MVCC(Multi-Version Concurrency Control,多版本并发控制)

是一个机制

是 InnoDB 存储引擎的核心并发控制机制

核心定位:在不依赖悲观锁的前提下,实现 读不加锁写不阻塞读 的高并发读写

底层依赖

  • undo log 构建的版本链
  • 事务 ID 机制

核心作用

解决读写冲突

MVCC 通过提供数据的 历史版本,让读操作访问旧版本、写操作修改新版本

保障事务隔离性

  • ACID 中的 I

  • 是 InnoDB 实现「读已提交(RC)」和「可重复读(RR)」的核心技术:

    • 读已提交(RC):每次查询都获取最新的 “已提交事务版本”,避免脏读;
    • 可重复读(RR):事务启动时生成数据快照,后续查询仅访问该快照,避免脏读和不可重复读

避免脏读、不可重复读

通过版本链筛选 对当前事务可见的数据版本

  • 读操作不会获取未提交事务的修改(避免脏读),
  • 同一事务内多次读取结果一致(避免不可重复读)

核心特点

非阻塞读(快照读)

  • 普通查询(如 select * from t where id=1)属于快照读,无需加锁,直接通过版本链获取历史数据,不会阻塞写操作;
  • 同时写操作也不会阻塞快照读

什么是快照读?什么是当前读?

  • 快照读:普通 select(不加锁),基于 MVCC 访问历史版本,非阻塞

  • 当前读

    • 加锁查询(如 select ... for update)、update、delete、insert,
    • 访问数据的最新版本,需加锁保证原子性,会阻塞其他写操作

基于版本链实现

依赖于

  • InnoDB 数据行的隐藏字段(DB_TRX_ID、DB_ROLL_PTR)
  • undo log 构建 版本链

最新数据存储在数据页,历史版本串联在 undo log 中,读操作通过遍历版本链找到可见版本

事务快照(Read View)

每个事务启动时或查询时,因隔离级别而异)会生成一个「Read View(读视图)」

Read View 是判断版本可见性的依据,包含 4 个关键信息:

  • m_ids:当前活跃的事务 ID 列表(未提交的事务);
  • min_trx_id:活跃事务 ID 中的最小值;
  • max_trx_id:当前已分配的最大事务 ID + 1(下一个要分配的事务 ID);
  • creator_trx_id:生成该 Read View 的事务 ID(当前事务 ID)。

工作流程

遍历版本链时,通过数据版本的 DB_TRX_ID(修改该版本的事务 ID)与 Read View 对比,判断是否可见

  • 若 DB_TRX_ID == creator_trx_id:当前事务修改的版本,可见;

  • 若 DB_TRX_ID < min_trx_id:修改该版本的事务已提交(早于所有活跃事务),可见;

  • 若 DB_TRX_ID >= max_trx_id:修改该版本的事务是未来启动的(晚于当前 Read View 生成),不可见

  • 若 min_trx_id <= DB_TRX_ID < max_trx_id:

    • 若 DB_TRX_ID 在 m_ids 中(事务未提交),不可见;
    • 若 DB_TRX_ID 不在 m_ids 中(事务已提交),可见;
  • 若当前版本不可见,通过 DB_ROLL_PTR 遍历上一个版本重复上述判断直到找到可见版本(或版本链结束返回空

不同隔离级别的流程差异

可重复读

事务启动时生成一次,整个事务生命周期内复用该 Read View

流程示例

  • 事务 A(trx_id=100)启动,生成 Read View:m_ids=[100],min_trx_id=100,max_trx_id=101;

  • 事务 B(trx_id=101)启动,执行 update t set a=20 where id=1(初始 a=10),提交;

  • 事务 A 执行 select a from t where id=1:

    • 数据页当前版本 DB_TRX_ID=101,判断:101 >= max_trx_id(101),不可见;
    • 遍历 undo log 找到上一版本(a=10,DB_TRX_ID=0,初始版本);
    • 0 < min_trx_id(100),可见,返回 a=10;
  • 事务 A 再次查询,仍复用同一个 Read View,返回 a=10(可重复读)

读已提交

Read View 生成时机:每次执行查询时生成新的 Read View;

流程示例

  • 事务 A(trx_id=100)启动,第一次查询 select a from t where id=1:

    • 生成 Read View:m_ids=[100],min_trx_id=100,max_trx_id=101;
    • 数据页版本 DB_TRX_ID=0,可见,返回 a=10;
  • 事务 B(trx_id=101)启动,执行 update t set a=20 where id=1,提交;

  • 事务 A 第二次查询 select a from t where id=1:

    • 生成新的 Read View:m_ids=[100],min_trx_id=100,max_trx_id=102;
    • 数据页当前版本 DB_TRX_ID=101,判断:101 不在 m_ids 中(事务 B 已提交),可见;
    • 返回 a=20(读已提交,每次查询获取最新已提交版本)

当前读的处理逻辑

当前读(select ... for update、update、delete 等)不依赖 MVCC 的快照,而是直接访问数据的最新版本,同时加行锁 / 表锁保证原子性:

  • 执行当前读时,先获取目标数据行的排他锁(或共享锁),阻塞其他写操作;
  • 直接读取数据页的最新版本忽略历史版本);
  • 执行修改操作后,更新数据行的 DB_TRX_IDDB_ROLL_PTR,生成新的 undo log 记录,更新版本链

总结

这次解析了与 日志 相关的很多知识点:

  • redo log /undo log / binlog
  • 两阶段提交 /double write /write-aheard-logging (WAL)
  • MVCC

不单单分析了日志的内容,格式,甚至还介绍了一下数据恢复的方法~

深入研究底层原理,未来解决问题会更精准~

熟练度刷不停,知识点吃透稳,下期接着练~