隔离级别和MVCC
本文源码基于MySQL 8.0.25
大家都知道数据库定义了四个隔离级别,分别对应了在多个事务同时进行时读取数据的表现:
隔离级别 | 行为 |
---|---|
读未提交 | 可以读到其它进行中的事务已写入,但是未提交的数据 |
读提交 | 读其它事务写的数据时,只有当该事务提交后才能读到 |
可重复读 | 在自己的事务中每次读取的结果必然相同 |
序列化 | 事务好像是一个个串行执行一样 |
上面的可重复读还可能产生幻读问题,所以还要序列化这个级别;MySQL幻读的一个例子是:两个事务并发写入一条id相同的数据,只有一个会成功,失败的一个会提示duplicate primary key,但是又select不到那条冲突的数据,因为写操作都是当前读,读操作是快照读,这里的快照读就是mvcc的核心;
MVCC 是mysql实现事务隔离的技术方案;它保证了在读提交和可重复读的隔离级别下,mysql中的select操作会有如下实验中的行为:
提前创建数据如下:
create table trx_t(id int primary key,name varchar(20));
insert into trx_t values(1, 'aaaa');
在隔离级别为读提交的时候,两个并发事务的行为如下:
事务1 | 事务2 |
---|---|
begin | begin |
select * from trx_t where id = 1; // 结果会返回aaaa | 无操作 |
无操作 | update trx_t set name=‘bbbb’ where id=1; |
select * from trx_t where id = 1; // 结果会返回aaaa,因为事务2没提交 | 无操作 |
无操作 | commit; |
select * from trx_t where id = 1; // 结果会返回bbbb,因为事务2已经提交了 |
在隔离级别为可重复读的时候,同样的两个并发事务的行为就不同了:
事务1 | 事务2 |
---|---|
begin | begin |
select * from trx_t where id = 1; // 结果会返回aaaa | 无操作 |
无操作 | update trx_t set name=‘bbbb’ where id=1; |
select * from trx_t where id = 1; // 结果会返回aaaa,因为事务2没提交 | 无操作 |
无操作 | commit; |
select * from trx_t where id = 1; // 结果还是会返回aaaa,因为事务2的操作对事务1不可见 |
本文会基于源码解释为何会出现上述的现象;
ReadView
很多人都了解,mysql在更新或删除一行数据时会将旧的数据行保留到undo log中,通过在每个行(最新的行以及在undo log中的行)上加上事务编号和软删除标志来实现MVCC,也就是多版本并发控制;需要注意的是,事务编号的大小和可读性之间没有绝对的关系,下文的实例中可以看到,事务编号较小的事务一样可以读到事务编号较大的事务写入的数据,只要事务编号较大的事务在事务编号较小第一次读取数据之前已经完成了提交即可;
但是实现不同事务读写之间的隔离还涉及到一个重要的概念: ReadView。ReadView 定义了当前事务可见的事务范围;当读取到一行数据时,mysql会根据readview和读取到的记录上的事务id来判断该行是否对当前事务可见。
产生上述行为的关键原因如下:在可重复读的隔离级别下,readview在事务的第一次select操作时构建,之后保持不变,因此读取到的数据也不会变;而读提交的隔离级别下,在每次select操作时会重新构建当前事务的readview,将新的已经提交的事务包括进来
MySQL中ReadView 有以下几个重要的成员变量:
class ReadView {
private:
/** 记录的trx id >= 这个值的,当前事务不可见,会设置为构建readview时已完成的最大事务号+1 */
trx_id_t m_low_limit_id;
/** 记录的trx id (<) 这个值的,当前事务可见. 会设置为构建readview时还活跃的事务id最小的事务的id */
trx_id_t m_up_limit_id;
/** 当前的事务id,只读事务设置为0 */
trx_id_t m_creator_trx_id;
/** 创建当前ReadView时活跃的读写事务编号,不包括m_creator_trx_id */
ids_t m_ids;
/** 不能看到事务号比这个还小的undo log,里面的数据已经被删除了 */
trx_id_t m_low_limit_no;
}
mysql读取一行数据的函数row_search_mvcc
中有这样一段逻辑:
dberr_t row_search_mvcc(byte *buf, page_cur_mode_t mode,
row_prebuilt_t *prebuilt, ulint match_mode,
const ulint direction) {
if (trx->isolation_level == TRX_ISO_READ_UNCOMMITTED) {
// 读未提交级别...
} else if (index == clust_index) {
if (srv_force_recovery < 5 &&
// 如果rec对当前事务不可见
!lock_clust_rec_cons_read_sees(rec, index, offsets,
trx_get_read_view(trx))) {
rec_t *old_vers;
/* 尝试从undo log中的更旧的版本读取一行数据 */
err = row_sel_build_prev_vers_for_mysql(
trx->read_view, clust_index, prebuilt, rec, &offsets, &heap,
&old_vers, need_vrow ? &vrow : nullptr, &mtr,
prebuilt->get_lob_undo());
}
..