目录
一.简介
MVCC(Multi-Version Concurrency Control):多版本并发控制机制
这是一种InnoDB引擎独有的一种控制事务隔离级别的机制,通过巧妙的设计处理写-读冲突,避免加锁造成的性能损耗,提高了数据库的并发性能。
二.适用场景
首先让我们先复习下数据库的四个隔离级别:
读未提交(Read uncommitted)
读已提交(Read committed):解决脏读问题
可重复读(Repeatable read):解决脏读和不可重复读问题
可串行化(Serializable):解决脏读,不可重复读和幻读问题
在了解MVCC前,可能很多人(包括我)原本认为:
mysql通过加上了写锁解决不可重复读的问题,在可重复读的隔离级别下,某个事务修改某一行的时候,其他事务无法对这一行数据进行修改和读取。
这是错误的!
事实上,四个隔离级别底层原理是这样实现的:
读未提交(Read uncommitted)
读已提交(Read committed):mvcc
可重复读(Repeatable read):mvcc
可串行化(Serializable):mvcc+间隙锁
也就是说,MVCC机制解决了原本我们以为必须要加写锁才能解决的并发问题,大大提升了数据库在并发情况下的性能。
不过需要注意的是,写与写依旧是冲突的,InnoDB引擎无论在什么级别下,在修改数据前会添加排他锁,确保其他事务只能对当前行数据进行读取而不能修改,完成数据修改后再释放排他锁。
那么这么神奇的MVCC机制是如何实现的呢?
三.实现原理
mvcc主要基于四个东西实现:DB_TRX_ID , DB_ROLL_PTR , undo log日志和Read View快照
3.1 隐藏字段
InnoDB引擎向数据库中的每一行添加了三个字段(使用者不可见)
DB_TRX_ID:标识当前行内数据的唯一事务ID,每次修改或删除后对事务ID进行+1。
DB_ROLL_PTR: 回滚指针,指向undo log中记录的上一个版本
DB_ROW_ID: 众所周知,InnoDB引擎默认使用b+树作为存储的底层结构,而b+树是一种有序的数据结构。InnoDB引擎会默认将用户指定的主键设置为聚簇索引(b+树排序的根据),但如果没有设置主键,则将该字段设置为聚簇索引,与MVCC无关
3.2 undo log日志
undo log日志中会存储包含隐藏字段的历史版本
例如,我们新建一个User表
CREATE TABLE User
(
id int PRIMARY KEY,
username VARCHAR(8)
)
insert into User VALUES (1,'大黄');
那么这个表的结构就是这样的(因为DB_ROW_ID与MVCC无关就省略了)
因为此时DB_TRX_ID仅为1,不存在历史版本信息,因此undo log是空的,让我们修改该行数据
update User set username = '小黑' where id = 1;
修改数据后,DB_TRX_ID递增,DB_ROLL_PTR指向undo log中历史版本信息
再次修改
update User set username = '旺财' where id = 1;
可以了解到DB_ROLL_PTR以一种链表的形式连接所有历史版本
3.3 Read View快照
翻看源码,得知Read View的主要结构如下图所示(还有一些和视图相关的可能存在的字段,与MVCC核心实现相关不大,因此暂不概述)
解释下各个字段的意义:
m_low_limit_id:创建视图时还未被分配的最小事务ID,即最大活跃事务ID+1(高水位线)
m_up_limit_id:创建视图时不包含自身的活跃事务列表中的最小事务ID(低水位线)
m_creator_trx_id:申请创建视图的事务ID
m_ids:创建视图时不包含自身的处于活跃的事务的ID集合
小小吐槽一下,这个low和up真的没有命名反吗?
而在不同隔离级别下,创建Read View快照的策略有所不同:
读已提交:每次事务发起select查询语句时创建快照
可重复读:只有当前事务第一次发起select查询语句时创建快照
让我们举个例子:
当事务2第一次发起select查询时,只有事务1已经提交,而事务3,4已经开启,因此:
m_low_limit_id(最大活跃事务ID+1)= 5
m_up_limit_id(最小活跃事务ID,不包含自身)= 3
m_creator_trx_id(发起创建快照的当前事务ID)= 2
m_ids(当前活跃事务ID集合,不包含自身)= {3,4}
如果是读已提交级别,那么当事务2第二次发起select查询时,则会创建新的Read View快照,此时事务3已经提交:
m_low_limit_id(最大活跃事务ID+1)= 5
m_up_limit_id(最小活跃事务ID,不包含自身)= 4
m_creator_trx_id(发起创建快照的当前事务ID)= 2
m_ids(当前活跃事务ID集合,不包含自身)= {4}
3.4 可见性逻辑
MVCC实质上是综合以上结构,使用视图判断当前行数据是否可见来处理写读冲突,如果不可见,则会通过回滚指针回滚到历史版本再次进行可见性判断,直到找到第一个可见的历史版本返回,即为select查询到的数据,实现了读已提交和可重复读的隔离级别。
翻看源码可以得知,核心判断可见性逻辑是由该方法实现:
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
[[nodiscard]] bool changes_visible(trx_id_t id,
const table_name_t &name) const {
ut_ad(id > 0);
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) {
return (false);
} else if (m_ids.empty()) {
return (true);
}
const ids_t::value_type *p = m_ids.data();
return (!std::binary_search(p, p + m_ids.size(), id));
}
让我们逐行解释:
入参:
- id:要检查的事务 ID。
- name:当前操作涉及的表名,用于在检查事务 ID 时确保上下文正确。
逻辑说明:
-
ut_ad(id > 0)
:进行断言检查,确保传入的id
参数大于 0。 -
如果要检查的事务 ID 小于最小活跃事务 ID 或等于当前事务 ID,则可见,返回if (id < m_up_limit_id || id == m_creator_trx_id)
:true
。 -
check_trx_id_sanity(id, name)
:验证该事务 ID 是否与指定表 (name
) 的状态一致。 -
如果要检查的事务 ID 大于等于最小未被分配的事务 ID(当前最大事务 ID + 1),那么一定不可见,返回if (id >= m_low_limit_id)
:false
。 -
在要检查的事务 ID 小于最小未被分配的事务 ID 的基础上,如果当前活跃事务集合为空,那么一定可见,返回else if (m_ids.empty())
:true
。 -
二分查找当前活跃事务集合中是否存在要检查的事务 ID。如果不存在,则可见,返回const ids_t::value_type *p = m_ids.data();
return (!std::binary_search(p, p + m_ids.size(), id))
:true
;如果存在,则不可见,返回false
。
让我们再拿先前的例子开始讲解:
如果事务隔离级别为可重复读,那么第二次查询时可见数据仍然为事务ID=2的数据,实现了可重复读
如果为读已提交,则会重新创建一份视图快照:
至于其他一些情况,我就不带着大家一起枚举或者进行数学逻辑上的验证了,感兴趣的小伙伴们可以自己尝试下。
MVCC便是通过上述结构,将可见性的问题进行层层分离,只使用了一些简单的逻辑判断便解决了事务的隔离级别和读-写冲突问题,让人不得不佩服制作者的巧思。
由此,我们就理解了MVCC是如何巧妙地处理了写-读冲突,实现了读已提交和可重复读的隔离级别。不过对于串行的隔离级别来说,我们就不得不加上间隙锁实现一个表锁的功能了。
四.总结
InnoDB引擎通过MVCC机制,巧妙地解决了并发场景下的写-读冲突。了解了MVCC的核心原理,对于以后我们自己设计一些高并发下调优的架构也有所帮助。
本文就到此结束了,希望能对大家有所帮助,祝大家都能在秋招中收获想要的offer🌷🌷🌷