【MySQL系列】MySQL的事务管理的学习(二)_ 再次理解隔离性

「前言」文章内容大致是MySQL事务管理,续上一篇。

「归属专栏」MySQL

「主页链接」个人主页

「笔者」枫叶先生(fy)

MySQL

七、再次理解隔离性

7.1 数据库并发的场景有

数据库并发的场景有以下三种:

  • 读-读 :不存在任何问题,也不需要并发控制
  • 读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
  • 写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失

其中读-写并发是数据库当中最高频的场景,下面讨论的就是这个,读-读并发不存在任何问题,写-写并发不谈

7.2 多版本并发控制(MVCC)

多版本并发控制(Multi-Version Concurrency Control)是一种用来解决读-写冲突的无锁并发控制,主要依赖记录中的3个隐藏字段列、undo日志和Read View实现

MVCC为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题:

  • 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数 据库并发读写的性能
  • 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题

下面先介绍3个隐藏字段列、undo日志和Read View

7.3 三个隐藏字段列

数据库表中的每条记录都会有如下3个隐藏字段列:

  • DB_TRX_ID:6byte,最近修改(修改/插入)事务ID,记录创建这条记录/最后一次修改该记录的事 务ID
  • DB_ROLL_PTR:7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就 行,这些数据一般在undo log中)
  • DB_ROW_ID:6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB会自动以
    DB_ROW_ID产生一个聚簇索引

补充

  • 实际还有一个删除flag隐藏字段列,用于记录该条数据是否被删除,便于进行数据回滚(删除数据并不是真的删除了,只是修改了flag字段)

例如,有一个测试表

create table if not exists student(
name varchar(11) not null,
age int not null
);

插入一条数据

insert into student (name, age) values ('张三', 28);

在这里插入图片描述
查询该表的数据
在这里插入图片描述
查出来的记录不仅包含name和age字段,还包含三个隐藏字段
在这里插入图片描述
说明

  • 假设插入该记录的事务的事务ID为1,那么该记录的DB_TRX_ID字段填的就是1,没有就为null
  • 这是表中的第一条记录,所以隐式主键DB_ROW_ID字段填的就是1
  • 记录是新插入的,没有历史版本,所以回滚指针DB_ROLL_PTR的值设置为null

7.4 undo日志

  • undo log是MySQL数据库中的一种日志,用于记录事务的回滚信息
  • 在MySQL中,事务的回滚是通过undo log来实现的

undo log简单理解成就是 MySQL 中的一段内存缓冲区,用来保存日志数据的,必要时会将缓冲区中的数据刷新到磁盘

7.5 模拟MVCC

事务ID为10的事务

假设现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名“张三”改为“李四”:

  • 因为是要进行写操作,所以需要先给该记录加行锁

  • 在这里插入图片描述

  • 修改前,现将改行记录拷贝到undo log中,所以,undo log中就有了一行副本数据(原理就是写时拷贝)

  • 所以现在MySQL中有两行同样的记录

  • 在这里插入图片描述

  • 现在修改原始记录中的name,改成 ‘李四’,并且修改原始记录的隐藏字段 DB_TRX_ID为当前 事务10 的ID,我们默认从10开始,之后递增

  • 而原始记录的回滚指针DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它

  • 最后当事务10提交后释放锁,这时最新的记录就是学生姓名为“李四”的那条记录

  • 在这里插入图片描述

现在又有一个事务11

现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)

  • 因为是要进行写操作,所以需要先给该记录(最新的记录)加行锁
  • 修改前,先将该行记录拷贝到undo log中,此时undo log中就又有了一行副本数据
  • 然后再将原始记录中的学生年龄改为38,并将该记录的DB_TRX_ID改为11,回滚指针DB_ROLL_PTR设置成刚才拷贝到undo log中的副本数据的地址,从而指向该记录的上一个版本
  • 最后当事务11提交后释放锁,这时最新的记录就是学生年龄为38的那条记录

在这里插入图片描述

  • 这样,我们就有了一个基于链表记录的历史版本链
  • 所谓的回滚,无非就是用历史数据,覆盖当前数据

上面的一个一个版本,我们可以称之为一个一个的快照

insert和delete的记录如何维护版本链

上面已经谈了update,update可以形成版本链,那insert和delete呢?

  • 对于delete,删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入undo log中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了
  • 对于insert,新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了
  • updatedeleteinsert可以形成版本链

select不会对数据做任何修改,所以,为select维护多版本,没有意义

select读取,是读取最新的版本呢?还是读取历史版本?

先说两个概念,当前读 VS 快照读:

  • 当前读:读取最新的记录,就叫做当前读
  • 快照读:读取历史版本,就叫做快照读

事务在进行增删查改的时候,并不是都需要进行加锁保护:

  • 事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护
  • 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在

而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读

undo log中的版本链何时才会被清除?

  • 提交事务:当一个事务成功提交后,数据库系统会认为该事务的操作是永久性的,不再需要回滚。因此,与该事务相关的undo log版本链就会被清除
  • 还有其他情况就不补充了

如何保证,不同的事务,看到不同的内容呢?也就是如何实现隔离级别?

  • 下面Read View再谈
  • Read View本质是用来进行可见性判断的
  • 解决在读提交和可重复读隔离级别下,应当是当前读还是快照读的问题(select语句查询)

7.6 Read View

  • Read View就是事务进行快照读操作的时候生产的读视图 (Read View),(即使用select查看数据的时候才会产生读视图
  • 在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID,这个ID是递增的,所以最新的事务,ID值越大)
  • Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据

ReadView类的源码如下:

class ReadView {
    
    
	// 省略...
private:
	/** 高水位:大于等于这个ID的事务均不可见*/
	trx_id_t m_low_limit_id;
	
	/** 低水位:小于这个ID的事务均可见 */
	trx_id_t m_up_limit_id;
	
	/** 创建该 Read View 的事务ID*/
	trx_id_t m_creator_trx_id;
	
	/** 创建视图时的活跃事务id列表*/
	ids_t m_ids;
	
	/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
	* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
	trx_id_t m_low_limit_no;
	
	/** 标记视图是否被关闭*/
	bool m_closed;
	
	// 省略...
};

上述四个成员说明:

m_ids; //一张列表(集合),用来维护Read View生成时刻,系统正活跃的事务ID
m_up_limit_id; //记录m_ids列表中事务ID最小的ID
m_low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
m_creator_trx_id //创建该ReadView的事务ID

由于事务ID是单向增长的,因此根据Read View中的m_up_limit_idm_low_limit_id,可以将事务ID分为三个部分:

  • 事务ID小于m_up_limit_id的事务
  • 事务ID位于m_up_limit_idm_low_limit_id之间的事务
  • 事务ID大于等于m_low_limit_id的事务

ReadView就是一个对象,初始化一次之后就不再改变了(只初始化一次)

在这里插入图片描述

  • 事务ID小于m_up_limit_id的事务:一定是生成Read View时已经提交的事务,因为m_up_limit_id是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了
  • 事务ID位于m_up_limit_idm_low_limit_id之间的事务:该区间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交(所有活跃的事务都在m_ids中)
  • 事务ID大于等于m_low_limit_id的事务:一定是生成Read View时还没有启动的事务,因为m_low_limit_id是生成Read View时刻,系统尚未分配的下一个事务ID

注意:事务ID不一定是连续的,比如事务ID10,15,16,17…

上述对应的隐藏字段:

  • 第一个区间,如果 m_creator_trx_id(创建该ReadView的事务ID)== DB_TRX_ID 或者 DB_TRX_ID< m_up_limit_id,则说明该事务是历史已经提交了的(已commit),应该被当前事务看到
  • 第二个区间,如果DB_TRX_ID不在m_ids列表中,说明该事务已经提交了(已commit),应该被当前事务看到。如果在的话m_ids列表中,说明该事务与当前事务都处于活跃状态(没有commit),不应该被当前事务看到
  • 第三个区间,DB_TRX_ID >= m_low_limit_id,说明该事务是快照之后才提交的事务,不应该被当前事务看到

对应的源码策略如下:

bool changes_visible(trx_id_t id, const table_name_t& name) const 
	MY_ATTRIBUTE((warn_unused_result))
{
    
    
	ut_ad(id > 0);
	//1、事务id小于m_up_limit_id(已提交)或事务id为创建该Read View的事务的id,则可见
	if (id < m_up_limit_id || id == m_creator_trx_id) {
    
    
		return(true);
	}
	check_trx_id_sanity(id, name);
	//2、事务id大于等于m_low_limit_id(生成Read View时还没有启动的事务),则不可见
	if (id >= m_low_limit_id) {
    
    
		return(false);
	}
	//3、事务id位于m_up_limit_id和m_low_limit_id之间,并且活跃事务id列表为空(即不在活跃列表中),则可见
	else if (m_ids.empty()) {
    
    
		return(true);
	}
	const ids_t::value_type* p = m_ids.data();
	//4、事务id位于m_up_limit_id和m_low_limit_id之间,如果在活跃事务id列表中则不可见,如果不在则可见
	return (!std::binary_search(p, p + m_ids.size(), id));
}

注意Read View是一个可见性的一个类,并不是事务创建出来就有Read View,而是当这个事务(已经存在)进行快照读的时候,MySQL才会形成Read View(进行select的时候,会自动形成)

7.7 Read View理论验证

下面进行 Read View的理论验证,即 Read View的整体流程

假设当前有条记录
在这里插入图片描述
有四个事务并发进行对该记录进行操作,事务4先进行修改,事务4进行修改完成之后,事务2进行快照读
在这里插入图片描述

  • 事务4:修改name(张三) 变成name(李四)
  • 事务2对某行数据执行了快照读,数据库为该行数据生成一个 Read View 读视图
//事务2的 Read View
m_ids; 		   // 1,3
m_up_limit_id;   // 1
m_low_limit_id;  // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
m_creator_trx_id // 2

此时版本链是:
在这里插入图片描述
只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务
在这里插入图片描述
事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟m_up_limit_idm_low_limit_id和活跃事务ID列表(m_ids) 进行比较,判断当前事务2能看到该记录的版本

//事务2的 Read View
m_ids; 			 // 1,3
up_limit_id;     // 1
low_limit_id;    // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id   // 2

//事务4提交的记录对应的事务ID
DB_TRX_ID=4

//比较步骤
DB_TRX_ID(4< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中

//结论
故,事务4的更改,应该看到
所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本

八、RR与RC的本质区别

RR是可重复读的缩写,RC是读提交的缩写

我们之前的查询语句都是快照读,如果想进行当前读,执行下列语句:

-- 以加共享锁方式进行读取,对应的就是当前读
select * from 表名字 lock in share mode; -- 当前读

实验1

启动两个终端,将隔离级别都设置为可重复读,并查看此时表中的数据
在这里插入图片描述
两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息
在这里插入图片描述
左终端中的事务对表中的信息进行修改并提交,右终端中的事务看不到修改后的数据
在这里插入图片描述
左边终端提交事务;右边终端进行当前读,可以看到最新的数据

select * from account lock in share mode;

在这里插入图片描述

实验2(进行同样的操作,只是SQL语句执行顺序不同)

  • 启动两个终端,将隔离级别都设置为可重复读,并查看此时表中的数据
  • 两个终端各自启动一个事务,在左终端中的事务操作之前,右边的终端不查看表中数据

在这里插入图片描述
左终端中的事务对表中的信息进行修改并提交,然后再让右终端中的事务进行查看,这时右终端中的事务就直接看到了修改后的数据
在这里插入图片描述
右边终端进行当前读,可以看到刚才读取到的确实是最新的数据
在这里插入图片描述

实验对比

上面两次实验的唯一区别在于,右终端中的事务在左终端中的事务修改数据之前是否进行过快照读

实验一的操作流程:
在这里插入图片描述

实验2的操作流程:

事务B在事务A修改age前没有进行过快照读
在这里插入图片描述

结论

  • RR级别下要求事务内每次读取到的结果必须是相同的,因此事务首次进行快照读的地方,决定了该事务后续快照读结果的能力

RR与RC的本质区别

正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同

  • 此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更
    新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见
  • 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事 务的修改对于当前事务都是不可见的
  • 而早于Read View创建的事务所做的修改均是可见
  • 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View,这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
  • 总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View
  • 而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View
  • 正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。

--------------------- END ----------------------

「 作者 」 枫叶先生
「 更新 」 2023.9.10
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。

猜你喜欢

转载自blog.csdn.net/m0_64280701/article/details/132743279