第9讲:日志体系与MVCC并发控制

45 阅读7分钟

目标: 理解MySQL日志机制,掌握MVCC并发控制原理

开篇:把前面的知识串起来

第5讲 我们学了事务的ACID特性,知道了:

  • Undo Log 管回滚(原子性)
  • Redo Log 管不丢(持久性)

第8讲 我们从架构角度看了崩溃恢复:Buffer Pool里的数据修改了,还没写入磁盘就宕机,靠redo log恢复。

但还有几个问题没解决:

  • redo log、undo log、binlog三种日志到底有什么区别?
  • 事务A在修改数据,事务B同时读取,为什么不会阻塞?
  • 可重复读是怎么实现的?

今天这一讲,我们就把这些底层机制搞清楚。

一、三大日志体系

MySQL有三种核心日志:redo log、undo log、binlog。

image.png

1.1 redo log(重做日志)

作用: 保证事务的持久性(Durability)。

还记得第5讲说的吗?Redo Log 管不丢(持久性)第8讲也提到,Buffer Pool里的数据修改了,还没写入磁盘就宕机,靠redo log恢复。

现在我们来看看redo log的底层机制。

原理: WAL(Write-Ahead Logging)机制,先写日志,再写磁盘。

为什么需要redo log?

事务提交时,如果每次都把数据页刷到磁盘,性能太差(随机IO)。redo log是顺序写,先把修改记录写到redo log,事务就算提交成功了。后台线程再慢慢把数据页刷到磁盘。

循环写机制:

redo log 是固定大小的文件组,循环使用:

image.png

  • write pos:当前写入位置,顺时针移动
  • checkpoint:刷盘进度位置,顺时针移动
  • 深色区域(write pos → checkpoint):已写入的日志,数据还没落盘,不能覆盖
  • 浅色区域(checkpoint → write pos):数据已落盘,可以写入新日志

write pos 一直追着 checkpoint 跑。当 write pos 追上 checkpoint,说明没有可写空间了,必须暂停,等后台线程刷脏页,checkpoint 才能往前推进。

-- 查看redo log配置
SHOW VARIABLES LIKE 'innodb_log%';

redo log刷盘策略(innodb_flush_log_at_trx_commit):

行为安全性推荐
0每秒刷一次可能丢1秒数据不推荐
1每次提交都刷盘最安全生产推荐
2提交时写OS缓存,每秒刷盘可能丢1秒数据折中

1.2 undo log(回滚日志)

作用: 保证事务的原子性(Atomicity)+ 实现MVCC。

第5讲说过:Undo Log 管回滚(原子性)。现在我们来看它的另一个重要作用:实现MVCC。

两个用途:

  1. 事务回滚:每次修改前,先把旧值记到 undo log。ROLLBACK 时沿着版本链逆序恢复。
  2. MVCC:提供数据的历史版本,配合 ReadView 判断可见性(见第三节)
-- 查看undo log配置
SHOW VARIABLES LIKE 'innodb_undo%';

1.3 binlog(归档日志)

作用: 主从复制、数据恢复。

binlog vs redo log:

特性redo logbinlog
层级InnoDB引擎层Server层
作用崩溃恢复主从复制
内容物理日志逻辑日志
写入循环写追加写

binlog格式:

格式说明推荐
STATEMENT记录SQL语句不推荐(NOW()等函数会导致主从不一致)
ROW记录每行变化生产推荐
MIXED混合模式不推荐

查看binlog:

-- 查看binlog文件列表
SHOW BINARY LOGS;

-- 查看binlog事件
SHOW BINLOG EVENTS IN 'mysql-bin.000001' LIMIT 20;

binlog刷盘策略(sync_binlog):

行为推荐
0由OS决定不推荐
1每次提交都刷盘生产推荐

生产环境推荐双1配置: innodb_flush_log_at_trx_commit=1 + sync_binlog=1

二、两阶段提交

问题: redo log和binlog是两个独立的日志,如何保证一致性?

image.png

崩溃恢复逻辑:

崩溃时机redo log状态binlog状态恢复操作
redo log写入前无记录无记录无需处理
redo log写入后,binlog写入前prepare无记录回滚
binlog写入后prepare有记录提交

核心判断: binlog有记录就提交,没记录就回滚。

三、MVCC多版本并发控制

前面讲了 undo log 的两个用途:事务回滚和MVCC。现在来看 MVCC 是怎么工作的。

问题: 事务A在修改数据,事务B同时读取,为什么不会阻塞?

答案: MVCC(Multi-Version Concurrency Control)让读操作读取历史版本,不需要等待写操作完成。

MVCC 的实现依赖三个东西:隐藏字段 + undo log版本链 + ReadView

3.1 隐藏字段

InnoDB为每行数据添加了隐藏字段(无法通过SQL查询):

字段作用
DB_TRX_ID最后修改该行的事务ID
DB_ROLL_PTR回滚指针,指向undo log中的旧版本
DB_ROW_ID隐藏主键(仅当表没有主键时)

3.2 undo log版本链

每次修改数据,旧版本通过 DB_ROLL_PTR 串成链表,这就是版本链。

image.png

有了版本链,历史版本都能找到。但问题来了:哪个版本对当前事务可见? 这就需要 ReadView。

3.3 ReadView(读视图)

作用: 判断版本链中哪个版本对当前事务可见。

ReadView包含4个字段:

  • m_creator_trx_id:创建ReadView的事务ID
  • m_ids:当前活跃的事务ID列表(已开始但未提交)
  • m_up_limit_id:m_ids中的最小值
  • m_low_limit_id:下一个要分配的事务ID

image.png

核心逻辑:沿着版本链逐个判断,找到第一个可见的版本。

3.4 RC vs RR:ReadView的生成时机

理解了 ReadView,RC 和 RR 的区别就很简单了:ReadView 什么时候生成?

场景: 初始 age=10,事务A两次SELECT之间,事务B把age改成20并提交

时间操作RC(读已提交)RR(可重复读)
T1事务A BEGIN--
T2事务A SELECT生成ReadView,读到10生成ReadView,读到10
T3事务B UPDATE+COMMITage变成20age变成20
T4事务A SELECT生成新ReadView,读到20复用ReadView,读到10
结果不可重复读 ❌可重复读 ✅

核心区别: RC每次SELECT都生成新ReadView,RR只在第一次SELECT时生成,之后复用。

3.5 快照读 vs 当前读

前面说的都是普通 SELECT,走 MVCC 读历史版本,这叫快照读

但有些操作必须读最新数据,比如 UPDATE。如果 UPDATE 也读历史版本,就会丢失其他事务的修改。这类操作叫当前读

类型SQL读取版本是否加锁
快照读SELECT ...历史版本(走MVCC)不加锁
当前读SELECT ... FOR UPDATEUPDATEDELETEINSERT最新版本加锁

场景对比: 初始数据 age=10,RR隔离级别

事务A事务B说明
BEGIN;
SELECT age; → 读到10快照读,生成ReadView
UPDATE age=20; COMMIT;age变成20
SELECT age; → 读到10快照读,复用ReadView
UPDATE age=age+1;当前读,读到20,更新为21
SELECT age; → 读到21快照读,但自己改的能看到
COMMIT;

关键点:

  • 普通SELECT是快照读,读的是ReadView生成时的版本
  • UPDATE是当前读,读取最新版本(否则会丢失事务B的修改)

四、避坑指南

坑1:长事务导致undo log膨胀

事务不提交,undo log无法清理,磁盘空间持续增长。

-- 查询长事务
SELECT trx_id, trx_started, trx_query
FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;

坑2:binlog格式使用STATEMENT

NOW()、UUID()等函数在主从执行结果不同,导致数据不一致。改用ROW格式。

坑3:刷盘策略配置为0

可能丢失数据。生产环境使用双1配置。

五、作业

基础题

查看你的MySQL日志配置:

SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW VARIABLES LIKE 'sync_binlog';
SHOW VARIABLES LIKE 'binlog_format';

进阶题

RR隔离级别下,分析以下场景,事务A每次SELECT读到的age分别是多少?

初始数据:age=10

事务A事务B事务A读到的age
BEGIN;
SELECT age;?
UPDATE age=20; COMMIT;
SELECT age;?
UPDATE age=age+1;
SELECT age;?
COMMIT;

提示:区分快照读和当前读。

六、下一讲预告

日志和MVCC搞清楚了,但还有个问题:库存100件,为什么卖出了120件?订单系统频繁死锁怎么办?

第10讲:并发场景优化——锁机制与死锁解决

下一讲会讲:

  • MySQL锁机制:全局锁、表锁、行锁、间隙锁
  • 死锁产生原因与排查方法
  • 库存超卖问题的解决方案

下一讲见!