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 时刻会面临两个选择:
-
阻塞等待:等事务 A 提交后再读。问题是:A 可能执行很久,B 就被卡死了。
-
直接读 :读到 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:
-
如果 trx_id 在 Read View 的活跃列表中 :说明这行是另一个还没提交的事务改的,不可见
-
如果 trx_id > Read View 的最大事务 ID :说明是 Read View 创建之后才发生的事务改的,不可见
-
如果 trx_id < Read View 的最小事务 ID :说明在 Read View 创建前已提交,可见
-
如果不可见 :顺着
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 判断可见性,从而实现了"读不阻塞写、写不阻塞读"的多版本并发控制机制。