认识 MVCC
什么是 MVCC
MVCC(Mutil Version Concurrency Control)多版本并发控制,是一种并发控制的方法,用于在多个并发事务同时读写数据库时保持数据的一致性和隔离性。
它是通过在每个数据行上维护多个版本的数据来实现的。当一个事务要对数据库中的数据进行修改的时候,MVCC 会为该事务创建一个数据快照,而不是直接修改实际的数据行。(个人认为这一定上参考了COW写时复制机制)
同时,读取数据时,会根据事务的“可见性规则”,选择合适的历史版本进行读取。这种机制让“读运行”和“写操作”可以并行执行,互不阻塞。
在 MySQL 的 InnoDB 引擎中,使用了这种技术,来实现对数据库的并发访问。
上面的解释比较抽象,下面来一点一点分析。
并发事务可能产生的问题
当一个事务访问数据库的数据时,无论读、写,都不会产生并发问题。因为只有一个线程在操作共享资源
但是,当两个以上事务同时访问数据库中的相同数据时,就可能出现并发问题了,以两个为例子:
- 都读:两个事务都查询数据。当两个事务对相同数据全部是读操作时,不会产生任何并发问题。
- 读 + 写:一个事务查询数据,一个事务修改数据。这就有可能出现脏读,幻读,不可重复读的三大事务问题
- 都写:两个事务都对相同的数据同时进行写操作,这是极其容易出现问题的(回滚丢失、覆盖丢失)
那如何针对上面的问题采取策略呢?
都读不会产生并发问题,不用管
并发事务对同时出现读和写操作的时候,常规操作一般会对要操作的数据加锁来解决读写并发进行的问题
提前说一下,MySQL 的 InnoDB 实现了 MVCC 来更好地处理读写冲突,可以做到即使存在并发读写,也可以不用加锁,实现非阻塞情况的并发读
并发事务对数据的写操作,只能通过加锁,乐观锁或者悲观锁,来解决。
所以说,在没有MVCC的传统锁机制中,并发场景会面临严重的性能问题:
- 读阻塞写:当事务 A 读取某条资料时,会加共享锁(S锁),此时事务B要修改该内容,需加排他锁(X锁),但S锁和X锁互斥,事务 B 会被阻塞;
- 写阻塞读:当事务 A 修改数据加 X 锁时,事务 B 读取该数据需加 S 锁,同样会被阻塞。
假设你的银行卡余额是 1000 元,现在有两个并发操作:
- 事务 A(读):你 APP 上查余额(加 S 锁),需要 1 秒;
- 事务 B(写):别人给你转 500 元(加 X 锁),需要 0.5 秒。
- 读阻塞写的情况
- 事务 A 先执行:0 秒开始查余额,加 S 锁 → 1 秒结束,释放 S 锁;
- 事务 B 在 0.5 秒时发起转账:想加 X 锁,但 S 锁和 X 锁互斥 → 事务 B 必须等事务 A 的 S 锁释放(也就是 1 秒后)才能执行;
- 结果:原本 0.5 秒能完成的转账,硬生生等了 0.5 秒,并发效率极低。
- 写阻塞读的情况
- 事务 B 先执行:0 秒开始转账,加 X 锁 → 0.5 秒结束,释放 X 锁;
- 事务 A 在 0.2 秒时查余额:想加 S 锁,但 X 锁未释放 → 事务 A 必须等 0.5 秒后才能执行;
- 结果:你查余额要等 0.3 秒,体验极差。
也就是说,传统锁机制下,读锁和写锁强互斥,只要一个操作占着锁,另一个就必须等,这和 Java JUC 思想中的读写锁分离的情况是不一致的。
MVCC(多版本并发控制)是怎么解决这个问题的?
MVCC 的是解决读写都存在时候的并发问题的,为数据保留多个历史版本,读操作读历史版本,写操作生成新版本,二者互不干扰。
那么,具体实现过程就是
读操作 SELECT
当一个事务执行读操作时,它会使用快照读取。快照数据是基于事务开始时候数据库的数据状态创建的,因此事务不会读取其他事务尚未提交的修改。具体工作情况如下:
- 对于读取操作,事务会查找符合条件的数据行,并选择符合其事务开始时间的数据版本进行读取。
- 如果某个数据行有多个版本,事务会选择不晚于其开始时间的最新版本,确保事务只读取在它开始之前已经存在的数据。
- 事务读取的是快照数据,因此其他并发事务对数据行的修改不会影响当前事务的读取操作。
写操作 INSERT UPDATE DELETE
当一个事务执行写操作时,它会生成一个新的数据版本,并将修改后的数据写入数据库。具体工作情况如下:
- 对于写操作,事务会为要修改的数据创建一个新的版本,数据的具体修改发生在这个新版本中
- 新版本的数据会带有当前事务的版本号,以便其他事务能够正确读取和理解该版本
- 原始版本的数据不受影响,供其他事务使用快照读取,实现了并发写不影响并发读
事务的提交和回滚
- 当一个事务提交时,它所做的修改将成为数据库的最新版本,并且对其他事务可见。
- 当一个事务回滚时,它的修改将会被撤销,对其他事务不可见(可以理解为这个版本被删了)
版本回收
- 为了防止数据库中的版本无限增长,MVCC 会定期进行版本的回收。回收机制会删除已经不再需要的旧版本数据,从而释放空间。
总之,重点就是:读操作使用旧版本数据的快照,写操作创建新版本
这样就能保证原始版本的一定可用性,部分事务实现了并发执行,避免加锁,降低开销,提高了数据库的并发性能和数据一致性
一致性非锁定读和锁定读
当前读和快照读
在进一步了解MySQL中实现MVCC的细节之前,还需要了解两个定义:
当前读
读取的数据是最新版本,读取数据时还要保证其他并发事务不会修改当前的数据,所以当前读会对读取的记录加锁。
快照读
每一次修改数据,都会在 undo log 中存有快照记录,这里的快照就是读取 undo log 中的某一版本数据的快照。这种方式的优点是可以不用加锁就可以读取到数据,缺点是可能不是最新。
一般的查询都是快照读
一致性非锁定读
这是 InnoDB 中默认的读操作模式,比如普通的
SELECT,也是 MVCC 发挥作用的核心场景。
- 一致性:读出来的数据是 符合事务隔离级别 的一致性快照,不会读到未提交的修改
- 非锁定:读取时完全不加任何锁,也不会被写操作的 X 锁阻塞;
对于 一致性非锁定读(Consistent Nonlocking Reads)的实现,通常情况下不读最新数据,通常做法是加一个版本号或者时间戳字段,在更新数据的时候版本+1或者时间戳更新。
查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见。(也就是不能预知未来)
在 InnoDB 存储引擎中,多版本控制
(multi versioning) MVCC
就是对非锁定读的实现。读不阻塞写,写不阻塞读。
如果读取的行正在执行 DELETE 或 UPDATE
操作,这时读取操作不会去等待行上锁的释放。反而 InnoDB
会读取该行的一个快照数据,对于这种读取历史数据的方式,就是上面的快照读
在可重复读和读已提交的隔离级别下,如果是执行普通的
SELECT 语句(不包括加锁的SELECT和原子更新查询
select ... lock in share mode
,select ... for update)则会使用
一致性非锁定读(MVCC)。并且在 可重复读 下 MVCC
实现了可重复读和防止部分幻读
锁定读
当业务需要 强一致性,必须读到最新的、未被修改的真实数据时,普通的非锁定读就不满足需求了,因为它读的是历史版本,可能不是最新数据。这时需要用「锁定读」,主动给读取的行加锁,确保后续操作的原子性。
虽然它回到了传统锁机制的逻辑,但只锁行,不是表,性能比表锁好很多
如果执行的是下列语句,就是 锁定读(Locking Reads)
select ... lock in share modeselect ... for updateinsert、update、delete操作
在锁定读下,读取的是数据的最新版本,这种读也被称为
当前读(current read)。锁定读会对读取到的记录加锁
select ... lock in share mode- 给读取的行加共享锁(S 锁),允许其他事务加 S 锁(一起读),但阻止其他事务加 X 锁(修改);
- 一般是需要确认数据状态,且不允许别人修改
select ... for update、insert、update、delete- 给读取的行加排他锁(X 锁),阻止其他事务加任何锁(既不能读也不能改)
- 一般是读取后要立即修改,确保数据不会被其他事务篡改
在一致性非锁定读下,即使读取的记录已被其它事务加上 X
锁,这时记录也是可以被读取的,但是读取的是快照数据。
上面说了,在可重复读的隔离情况下,MVCC
防止了部分幻读,因为只能读取到第一次查询之前所插入的数据
但是是,如果是 当前读
,每次读取的都是最新数据,这时如果两次查询中间有其它事务插入数据,就会产生幻读。
所以, InnoDB 在实现 可重复读
时,如果执行的是当前读,每次都尝试读取的是最新的数据,则会对读取的记录使用
Next-key Lock ,来防止其它事务在间隙间插入数据
MVCC➕Next-key-Lock 在 RR 下防止幻读
InnoDB存储引擎在 RR 级别下通过 MVCC和
Next-key Lock 来解决幻读问题:
执行普通
select,此时会以MVCC快照读的方式读取数据在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 ReadView,并使用此事务提交。
所以在生成
ReadView之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的幻读执行 select…for update/lock in share mode、insert、update、delete 等当前读
在当前读下,读取的都是最新的数据,如果其他事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读
InnoDB使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读
标准的 SQL 隔离级别定义里,
repeatable-read是无法防止幻读的,但 InnoDB 的实现通过以下机制很大程度上避免了幻读,但是也没办法完全避免
- 快照读:普通的 SELECT 语句,通过 MVCC 机制,事务启动时创建一个数据快照,后续的快照读都读取这个版本的数据,从而避免了看到其他事务新插入的行(幻读)或修改的行(不可重复读)
- 当前读:像
SELECT ... FOR UPDATEX锁,SELECT ... LOCK IN SHARE MODES锁,INSERT,UPDATE,DELETE这些操作。InnoDB 使用 Next-Key Lock 来锁定扫描到的索引记录及其间的间隙,防止其他事务在这个间隙内插入新的记录,造成幻读。
- Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的组合。
MVCC的核心原理
MySQL 中 MVCC 主要是通过行记录中的隐藏字段,包括 隐藏主键 row_id、事务ID trx_id、回滚指针 roll_pointer,和undo log,ReadView来实现的。
行记录中的隐藏字段
InnoDB会为每一条数据记录自动添加3个隐藏列,用于记录版本信息
row_id:当数据库表没定义主键时,InnoDB 会以 row_id 为主键生成一个聚集索引。trx_id:事务ID记录了新增或者最近修改这条记录的事务的 id,事务 id 也是自增的roll_pointer:在 undo log 中,回滚指针指向当前记录的上一个版本
Undo Log
而在修改数据的时候,存储引擎会向 redo log 中记录修改的页内容,为了在数据库宕机重启后恢复对数据库的操作,也会向 undo log 记录数据原来的快照,用于回滚事务和实现 MVCC
Undo Log 不仅是事务回滚的依据,也是 MVCC 存储 历史版本数据 的载体。
当事务修改数据时候,InnoDB会先将数据的 修改前的版本 写入Undo Log,再修改当前数据并更新三个隐藏列:
- 若事务执行
ROLLBACK,可通过 Undo Log 恢复旧版本; - 若其他事务需要读取历史版本,可通过
roll_pointer从Undo Log中获取对应版本数据。
例如:事务1(TRX_ID=100)执行UPDATE user SET age=21 WHERE id=1,InnoDB会:
将数据的旧版本
(id=1, name="张三", age=20, DB_TRX_ID=0)写入Undo Log;
事务1 执行了 UPDATE 修改当前数据的
age为21,更新DB_TRX_ID=100,DB_ROLL_PTR指向Undo Log中旧版本的地址;此时数据的版本链如下:- 当前版本:
(age=21, DB_TRX_ID=100, DB_ROLL_PTR→Undo Log旧版本) - Undo
Log中的历史版本:
(age=20, DB_TRX_ID=0, DB_ROLL_PTR=NULL)
- 当前版本:
别忘了在 InnoDB 存储引擎中 undo log
分为两种:insert undo log 和
update undo log
insert undo log:指在insert操作中产生的undo log。因为insert操作的记录只对事务本身可见,对其他事务不可见,故该undo log可以在事务提交后直接删除。不需要进行purge操作
update undo log:update或delete操作中产生的undo log。该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除
ReadView 读视图
多个事务对 id=1 的数据修改后,这行记录除了最新的数据,在 undo log 中还有多个版本的快照。那其他事务查询时能查到最新版本的数据吗?如果不能,能读到哪个版本的快照呢?这就要由 ReadView 来决定了。
Read View是事务读取数据时的 可见性判断依据,它本质是一个“事务ID集合”,包含以下4个核心参数:
m_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见,不能预知未来m_up_limit_id:活跃事务列表m_ids中最小的事务 ID,如果m_ids为空,则m_up_limit_id为m_low_limit_id。小于这个 ID 的数据版本均可见m_ids:活跃事务id列表,当前系统中所有活跃的(也就是没提交的)事务的事务id列表。m_creator_trx_id:创建该Read View的事务 ID
数据可见性算法
在 InnoDB 存储引擎中,创建一个新事务后,执行每个
select 语句前,都会创建一个快照(Read
View),快照中保存了当前数据库系统中正处于活跃(没有
commit)的事务的 ID
号,当用户在这个事务中要读取某个记录行的时候,InnoDB
会将该记录行的 DB_TRX_ID 与 Read View
中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件
其实也就是保存了当前不应该被本事务看到的其他事务 ID 列表
m_ids,因为他们没有提交
具体算法如下
当版本链中记录的 trx_id 等于当前事务 id,也就是
trx_id = m_creator_trx_id,说明版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。
当版本链中记录的 trx_id 小于活跃事务的最小
id,trx_id < m_up_limit_id,版本链中这个事务已经被提交了,所以该快照记录对当前事务可见。
当版本链中记录的 trx_id 大于下一个将被分配的事务
id,trx_id > m_low_limit_id,这个事务还没有被提交,防止预知未来,不可见
当版本链中记录的 trx_id 在 m_up_limit_id 和 m_low_limit_id
之间时,m_low_limit_id >= trx_id >= m_up_limit_id,,如果版本链中记录的
trx_id 在活跃事务 id 列表m_ids中,说明生成 ReadView
时,修改记录的事务还没提交,不可见,如果不再就可见
一图总结如下
RC 和 RR 隔离级别下 MVCC 的差异
在事务隔离级别 RC 和
RR,InnoDB 存储引擎使用
MVCC(非锁定一致性读),但它们生成 Read View
的时机却不同
- 在 RC 读已提交 隔离级别下的 每次SELECT,
查询前都生成一个
Read View(m_ids 列表) - 在 RR 可重复读
隔离级别下只在事务开始后的第一次SELECT,数据前生成一个
Read View(m_ids 列表)







