MySQL: Undo日志与MVCC多版本并发控制

innodb底层,通过Undo日志实现MVCC多版本并发控制。

一、先讲问题:没有 MVCC 时,读写为什么会冲突?

假设你的数据库里有一张 users 表:

sql 复制代码
SELECT * FROM users WHERE id = 1;
-- 结果:name = 'Alice'

现在有两个事务同时操作:

时间 事务 A(写) 事务 B(读)
T1 UPDATE users SET name = 'Bob' WHERE id = 1
T2 (还没提交) SELECT name FROM users WHERE id = 1
T3 COMMIT

如果没有 MVCC,事务 B 在 T2 时刻会面临两个选择:

  1. 阻塞等待:等事务 A 提交后再读。问题是:A 可能执行很久,B 就被卡死了。

  2. 直接读 :读到 A 还没提交的 'Bob'。问题是:A 之后可能回滚,B 读到的就是脏数据

这就是读写冲突 ------读操作和写操作互相干扰,数据库的并发能力很差。


二、早期的解决方案:加锁

为了解决这个问题,数据库最初的做法是加锁:

  • 事务 A 写数据时,加写锁(X锁)

  • 事务 B 读数据时,加读锁(S锁)

  • 读锁和写锁是互斥的:B 想读,必须等 A 释放写锁

这个设计的弊端很明显:

  • 读会阻塞写,写也会阻塞读

  • 并发度 = 串行度,性能直线下降

  • 对于读多写少的互联网应用(比如电商查商品),这是致命的

所以工程师们想:能不能让"读"和"写"互不阻塞?


三、MVCC 的核心思想:读旧版本,写新版本

MVCC(Multi-Version Concurrency Control,多版本并发控制)的设计思路是:

写操作不覆盖旧数据,而是生成一个新版本;读操作去读它需要的那个旧版本。

这样:

  • 事务 A 写它的新版本(加写锁,但不影响读)

  • 事务 B 读旧版本(不需要加锁)

  • 读写互不阻塞

但这里有个关键问题:旧版本存在哪里?


四、Undo 日志:本来就要记录旧值,顺手用来存历史版本

InnoDB 里本来就有 Undo 日志,它的原始目的是:

事务回滚时,能把数据恢复到修改前的状态。

比如:

sql 复制代码
BEGIN;
UPDATE users SET name = 'Bob' WHERE id = 1;  -- 原来 name 是 'Alice'
ROLLBACK;

Undo 日志里会记录:id=1 这行数据,name 原来是 'Alice'。回滚时直接恢复。

工程师发现:Undo 日志里保存的,不就是"历史版本"吗?

既然 Undo 日志已经记录了旧值,那 MVCC 就可以直接复用它,不需要再搞一套独立的"历史版本存储系统"。


五、具体实现:每行数据有两个隐藏列

InnoDB 的每一行数据,除了你定义的列(如 id, name),还有3个隐藏列

隐藏列 含义
DB_TRX_ID 最后一次修改这行数据的事务 ID
DB_ROLL_PTR 回滚指针,指向 Undo 日志中上一个版本
DB_ROW_ID 隐藏主键(如果没有显式主键)

当发生 UPDATE 时,流程是这样的:

复制代码
修改前:
  数据行:id=1, name='Alice', DB_TRX_ID=100, DB_ROLL_PTR=null

执行 UPDATE SET name='Bob':
  1. 把旧数据写入 Undo 日志:{ id=1, name='Alice', trx_id=100 }
  2. 修改数据行:name='Bob', DB_TRX_ID=101(当前事务ID)
  3. DB_ROLL_PTR 指向 Undo 日志中的那条旧记录

修改后:
  数据行:id=1, name='Bob', DB_TRX_ID=101, DB_ROLL_PTR → Undo日志['Alice']

如果再来一个 UPDATE:

复制代码
  数据行:id=1, name='Charlie', DB_TRX_ID=102
  DB_ROLL_PTR → Undo日志['Bob'] → Undo日志['Alice']

这就形成了一条版本链


六、Read View:决定当前事务该看哪个版本

光存了历史版本还不够,事务读的时候,怎么知道该读哪个版本?

InnoDB 给每个事务生成了一个 Read View(一致性视图),里面记录了:

  • 当前活跃的事务 ID 列表

  • 最小活跃事务 ID

  • 最大事务 ID

  • 创建 Read View 时的事务 ID

判断规则(简化版):

对于一个数据行,拿到它的 DB_TRX_ID

  1. 如果 trx_id 在 Read View 的活跃列表中 :说明这行是另一个还没提交的事务改的,不可见

  2. 如果 trx_id > Read View 的最大事务 ID :说明是 Read View 创建之后才发生的事务改的,不可见

  3. 如果 trx_id < Read View 的最小事务 ID :说明在 Read View 创建前已提交,可见

  4. 如果不可见 :顺着 DB_ROLL_PTR 去 Undo 日志找上一个版本,重复判断

这就是 MVCC 的实现核心:通过 Undo 日志的版本链 + Read View 的可见性判断,让不同事务看到不同的数据版本。


七、RC 和 RR 的区别:Read View 创建时机不同

这也是面试常考的:

隔离级别 Read View 创建时机 效果
Read Committed(RC) 每条 SELECT 语句执行前创建 每次读都读最新已提交的数据
Repeatable Read(RR) 事务第一次 SELECT 时创建,之后复用 整个事务期间看到的数据一致

八、总结:设计的逻辑链条

步骤 设计逻辑
问题 读写互相阻塞,并发性能差
早期方案 加锁,但锁互斥,并发度低
新思路 读写分离------写新版本,读旧版本
旧版本存哪 复用已有的 Undo 日志(本来就要记旧值做回滚)
怎么找到对的版本 每行加隐藏列(事务ID + 回滚指针),形成版本链
怎么判断可见性 Read View,根据事务ID判断该读哪个版本
结果 读不加锁,写加锁,读写互不阻塞

【小结】:

InnoDB 利用 Undo 日志天然记录了数据旧值的特性,把它作为"历史版本"的存储载体。每行数据通过隐藏的回滚指针 串联起一条版本链,再配合事务的 Read View 判断可见性,从而实现了"读不阻塞写、写不阻塞读"的多版本并发控制机制。

相关推荐
二十雨辰1 小时前
[Java]-MySql面试题
mysql
Zhu7587 小时前
在k8s集群部署MySQL单实例,支持多个主流发行版
android·mysql·kubernetes
io无心11 小时前
Shardingsphere5分库分表
数据库·mysql
wWYy.12 小时前
Mysql:主键索引 唯一索引 普通索引 前缀索引
数据库·mysql
在世修行18 小时前
Windows 上 Docker Desktop 部署 MySQL + TDengine 全过程(含 WSL 报错与镜像加速排障实战)
windows·mysql·docker·tdengine
自律最差的编程狗20 小时前
从零搭建:VMware + Ubuntu + Docker + MySQL 完整指南
mysql·ubuntu·docker
tianyu23421 小时前
MySQL字符串处理函数完全指南:从入门到实战
mysql·字符串·函数·截取
天空之外13621 小时前
Docker安装MySQL 8.4 LTS、Docker安装Redis 7.4.x、Docker安装MinIO
java·mysql·docker
小白说大模型1 天前
【MYSQL】MYSQL学习的一大重点:索引(下)- B+树
b树·学习·mysql