MySQL为什么需要引入MVCC?不是已经有锁了嘛?
我认为这是每一个程序员都必须深度掌握的核心知识——MVCC协议。文章标题这个问题一句话回答就是:“为了在高并发读写场景下,解决纯锁机制带来的性能瓶颈问题,实现非阻塞的读操作,从而大幅提升系统的整体并发能力。”核心就在于,MVCC使用了版本链+ReadView把读写解耦了,在保证必要隔离的同时大幅提升并发读写性能。
那星星将会从下面几个维度依次展开哈
1.没有MVCC时是啥样的?有了MVCC后又是啥样的?
2.不得不提事务的隔离级别了,顺便把它的并发问题也说了。
3.深入理解版本链。
4.Read View是啥?在RC和RR隔离级别下分别有啥用?
5.MVCC就没有局限性了嘛?当然还是有的
1.没有MVCC时是啥样的?有了MVCC后又是啥样的?
在没有MVCC的数据库里(这里串行化隔离界别除外哈),并发控制完全依赖于锁。这会导致几个典型的性能瓶颈:
- 读-写阻塞:这是最核心的问题。如果一个事务正在写某一行(加了排他锁),那么其他任何事务(包括只想读数据的事务)都必须等待这个锁被释放。同样,如果一个事务正在读某一行(在某些隔离级别下加了共享锁),那么写事务也必须等待。在高并发、读多写少的应用场景中,这会形成严重的“锁竞争”,导致大量事务排队等待,系统吞吐量急剧下降。
- 并发度低:由于读写操作相互阻塞,系统在同一时刻只能处理非常有限的操作,严重限制了并发能力。
MVCC的引入,彻底改变了“读”的行为方式,核心思想是:
- 多版本:同一行数据在系统中可以同时存在多个版本(通过
undo log实现)。当事务更新数据时,并不会直接覆盖原数据,而是生成一个新的版本。(版本链我待会会讲) - 快照读:当一个事务开始时,它会为自己的“读操作”创建一个快照。这个快照包含了在它开始那一刻已经提交的所有数据版本。此后,这个事务的所有普通
SELECT查询,都会基于这个快照来进行,读取的是快照创建时的数据状态。
这样一来:
- 读不阻塞写:一个事务在读取数据时,无需申请共享锁。因为它读的是历史快照,另一个事务可以同时修改当前最新数据(生成新版本),互不干扰。
- 写不阻塞读:一个事务在修改数据时(加了排他锁),其他事务仍然可以读取该数据在修改前提交的旧版本,而不会被阻塞。
2.不得不提事务的隔离级别了,顺便把它的并发问题也说了。
事务的四大隔离级别是为了解决不同的并发问题。MVCC是实现这些隔离级别(特别是READ COMMITTED和REPEATABLE READ)的关键机制。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式概览 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新数据,无MVCC也无锁(读) |
| READ COMMITTED (RC) | 避免 | 可能 | 可能 | MVCC:每次读都生成新的Read View |
| REPEATABLE READ (RR) | 避免 | 避免 | 可能(InnoDB通过间隙锁大部分避免) | MVCC:第一次读时生成Read View |
| SERIALIZABLE | 避免 | 避免 | 避免 | 纯锁机制,读加共享锁 |
并发问题解释:
- 脏读:读到了另一个未提交事务修改的数据。
- 不可重复读:在同一事务中,两次读取同一行数据,结果不同(因为被其他已提交事务修改了)。
- 幻读:在同一事务中,两次执行相同的查询,返回的结果集记录数不同(因为被其他已提交事务插入或删除了数据)。
3.深入理解版本链。
MVCC的核心数据结构就是数据的版本链。
- 隐藏字段:InnoDB每行数据都有两个(或三个)隐藏字段:
trx_id:最后修改(包括INSERT/UPDATE/DELETE)该行数据的事务ID。roll_pointer:指向该行数据上一个版本的指针。所有版本通过这个指针连接成一个链表,即版本链。- (还有一个
row_id,当没有主键时自动生成)
undo log:每次对数据进行修改,旧版本的数据都会被写入undo log中。roll_pointer字段就指向了undo log中对应的旧数据记录。undo log还用于事务回滚。
工作流程:
- 事务A(ID=100)插入一条数据
(1, 'Alice')。此时trx_id=100,roll_pointer指向一个空版本。 - 事务B(ID=200)更新该数据为
(1, 'Bob')。- 系统会先将当前行数据(
'Alice')拷贝到undo log。 - 然后更新当前行的数据为
'Bob',并将trx_id设置为200,roll_pointer指向刚刚存入undo log的旧版本('Alice')。
- 系统会先将当前行数据(
- 现在,这行数据的最新版本是
(1, 'Bob', trx_id=200),通过roll_pointer可以找到它的上一个版本(1, 'Alice', trx_id=100)。
这样就形成了一条从新到旧的数据版本链。
4. Read View是啥?Read View在RC和RR隔离级别下分别有啥用?
Read View是MVCC的“裁判”,它决定了对于一个事务而言,版本链中的哪个版本是可见的。它是实现不同隔离级别的关键。
Read View主要包含:
m_ids:生成Read View时,系统中活跃(未提交)的事务ID列表。min_trx_id:m_ids中的最小值。max_trx_id:生成Read View时,系统应该分配给下一个事务的ID。creator_trx_id:创建该Read View的事务ID
可见性算法(简化):
当访问某行数据时,从版本链的最新版本开始,逐个判断每个版本的trx_id:
- 如果
trx_id == creator_trx_id,说明是本事务修改的,可见。 - 如果
trx_id < min_trx_id,说明该版本在Read View创建前已提交,可见。 - 如果
trx_id >= max_trx_id,说明该版本在Read View创建后才开启,不可见。 - 如果
min_trx_id <= trx_id < max_trx_id,检查trx_id是否在m_ids中:- 如果在,说明该版本所属事务在
Read View创建时仍活跃,不可见。 - 如果不在,说明该版本所属事务在
Read View创建时已提交,可见。
- 如果在,说明该版本所属事务在
如果当前版本不可见,就顺着版本链roll_pointer找到上一个版本,重复上述判断,直到找到第一个可见的版本或遍历完整个链。
RC和RR的核心区别
- READ COMMITTED (RC):
- 规则:每次执行
SELECT语句时,都会生成一个新的、独立的Read View。 - 效果:这意味着它能读到在本次
SELECT语句执行之前,所有已经提交的事务所做的修改。 - 举例:
- 事务A开始。
- 事务A第一次查询某行,得到值
X。此时生成Read View1。 - 事务B提交了对该行的修改,值变为
Y。 - 事务A第二次查询该行。这时会生成一个新的
Read View2。在Read View2中,事务B已经提交,所以事务A这次就能读到值Y。这就发生了“不可重复读”。
- 规则:每次执行
- REPEATABLE READ (RR):
- 规则:只在第一次执行
SELECT语句时生成一个Read View,后续在整个事务中都使用这个相同的Read View。 - 效果:这意味着它的视图在整个事务期间是“冻结”的。它只能看到在它第一次读之前就已经提交的数据,以及本事务自身做的修改。之后其他事务的提交,对它来说都是不可见的。
- 举例:
- 事务A开始。
- 事务A第一次查询某行,得到值
X。此时生成Read View并固化。 - 事务B提交了对该行的修改,值变为
Y。 - 事务A第二次查询该行。它依然使用最开始生成的
Read View。在旧的Read View中,事务B当时可能还未提交或还未开始,因此事务B的修改对事务A不可见。事务A顺着版本链找到的还是值X。这就保证了“可重复读”。
- 规则:只在第一次执行
5.MVCC就没有局限性了嘛?当然还是有的
这个点是我做这个文章最想说的,就是说,一个技术它再怎么好,有再多的优点不管,它一定会有缺点的,这个是没法避免的,就像分布式系统下一致性和可用性就是在大多时候没法同时去强满足,往往都是ap+最终一致性嘛,就是cap理论。MVCC当然也有它的局限性。
MVCC的局限性本质上来自于它的设计选择:
-
空间换时间的权衡
-
读写分离的架构约束
-
历史快照的维护成本
-
与锁机制的协同复杂度
今天内容到这里就结束了,我们明天再见!
虚心,合作,努力。