MySQL事务隔离级别和MVCC

559 阅读12分钟

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

一、事务隔离级别

如果在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据,这样处理对性能影响大,即想保持事务的隔离性,又想服务器在处理访问同一个数据的多个事务时性能高,舍一部分隔离而取性能。


事务并发执行遇到的问题

访问相同数据的事务在不保证串行执行的情况下可能会出现的问题:\

  • 脏写(Dirty write):如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写

image.png

image.png


Session A 和 Session B 各开启一个事务,Session B中的事务先更新,然后Session A中的事务接着更新并提交事务,如果之后Session B中的事务进行了回滚,那么Session A 中的更新也将不复存在,这种现象就称之为脏写。
​\

  • 脏读(Dirty Read):如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读

image.png

image.png


Session A 和 Session B各开启了一个事务,Session B中的事务先将列更新为xxx,然后Session A中的事务再去查询这条记录,如果读到列值为xxx,而后Session B中的事务进行回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。
​\

  • 不可重复读(Non-Repeatable Read):如果一个事物只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读。

image.png

image.png


在session B 中提交了几个隐式事务(语句结束事务就提交),这些事务都修改了列值,每次事务提交之后,如果Session A中的事务都可以查看到最新值,这种现象也被之为不可重复读。
​\

  • 幻读(Phantom): 如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读。

\

image.png

image.png


Session A中的事务先根据条件id>0查询表,得到了列值xxx的记录,之后Session B中提交了一个隐式事务,该事务向表中插入了一条新记录;之后Session A中的事务再根据相同的条件查询表,得到的结果集中包含Session B中的事务新插入的记录,那这种现象也被称之为幻读。

幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,对于先前已经读到的记录,之后又读取不到这种情况,其实这相当于对每一条记录都发生了不可重复读的现象。

二、SQL标准中的四种隔离级别

并发事务执行过程中的问题,按照严重性来排一下序如下 :

脏写 > 脏读 > 不可重复读 > 幻读

制定SQL标准,在标准中设立了4个隔离级别:

  • READ UNCOMMITTED:未提交读
  • READ COMMITTED:已提交读
  • REPEATABLE READ:可重复读
  • SERIALIZABLE:可串行化

image.png

image.png

  • READ UNCOMMITTED 隔离级别下,可能发生脏读、不可重复读和幻读问题
  • READ COMMITTED 隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题
  • REPEATABLE READ 隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题
  • SERIALIZABLE 隔离级别下,各种问题都不可能发生。
  • 无论是那种隔离级别,都不允许脏写的情况发生。

三、MVCC原理(multi-version concurrency control 多版本并发控制)

对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列

  • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务ID赋值给trx_id隐藏列
  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本号写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息


假设插入该记录的事务id=80,那么此刻该条记录如下所示:\

yuque_diagram (13).jpg 假设之后两个事务分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下:\

image.png

image.png


InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。

每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表如下:\

yuque_diagram (14).jpg 对该记录每次更新后,都会将旧值放到一条undo日志中,就算是是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,把这个链表称之为版本链,版本链的头节点就是当前记录最新的值,另外,每个版本中还包含生成该版本时对应的事务id


四、ReadView

对于使用Read Uncommited 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;

对于使用Serializable隔离级别的事务来说,使用加锁的方式来访问记录;

对于使用Read Commited 和 Repeatable Read 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的,为此提出ReadView的概念,这个ReadView中主要包含4个比较重要的内容:
​\

  • m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务ID列表

  • min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务ID,也就是m_ids中的最小值

  • max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的ID值

    注意max_trx_id并不是m_ids中的最大值 ,事务id是递增分配的。比如现有id为1,2,3这三个事务,之后id为3的事务提交了,那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4

  • creator_trx_id:表示 生成该ReadView的事务的事务id

    只有在对表中的记录做改动时(执行Insert、Delete、Update时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。

有了ReadView,在访问某条记录时,需要按照下边的步骤判断记录的某个版本是否可见:
​\

  • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问
  • 如果被访问版本的trx_id属性值小于ReadView中min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当事务访问。
  • 如果访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

在Mysql中 Read commited 和Repeatable Read隔离级别的一个非常大的区别在于生成ReadView的时机不同。


READ COMMITTED ---每次读取数据前都生成一个ReadView

系统里有两个事务id分别为100、200的事务在执行

# Transaction 100
BEGIN;

UPDATE table SET name = 'xx1' WHERE id = 1;

UPDATE table SET name = 'xx2' WHERE id = 1;


# Transaction 200
BEGIN;

UPDATE table SET name = 'xx3' WHERE id = 1;

UPDATE table SET name = 'xx4' WHERE id = 1;

如果现在有一使用READ COMMITTED隔离级别的事务开始执行:
1)Transaction 100、200未提交

# SELECT1:Transaction 100200未提交
SELECT * FROM table WHERE id = 1; # 得到的列name的值为'xxx'

yuque_diagram (15).jpg Select1的执行过程如下:

  • 在执行select语句时会生成一个ReadView,ReadView的m_ids列表的内容就是【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0
  • 然后从版本链中挑选可见的记录,最新版本的列name的内容是xx2,该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是xx1,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本
  • 下一个版本的列name的内容是xxx,该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这列name=xxx的记录

2)把事务id=100的事务提交

# SELECT2:Transaction 100 提交,Transaction 200未提交
SELECT * FROM table WHERE id = 1; # 得到的列name的值为'xx2'

yuque_diagram (16).jpg

Select2的执行过程如下:

  • 在执行select语句时又会生成一个ReadView,该ReadView的m_ids列表的内容就是【200】(事务id=100的事务已经提交,所以再次生成快照时就没有它),min_trx_id为200,max_trx_id为201,creator_trx_id为0
  • 然后从版本链中挑选可见的记录,最新版本的列name的内容是xx4,该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
  • 下一个版本的列name的内容为xx3,该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本
  • 下一个版本的列name内容为xx2,该版本的trx_id值为100,小于ReadView中的min_trx_id值200,所以这个版本是符合要求的,最后返回级用户的版本就是这条记录的name为xx2

以此类推,如果事务id=200也提交了,再次查询表id=1值的记录时,得到的结果就是xx4。


REPEATABLE READ---在第一次读取数据时生成一个ReadView
# Transaction 100
BEGIN;

UPDATE table SET name = 'xx1' WHERE id = 1;

UPDATE table SET name = 'xx2' WHERE id = 1;


# Transaction 200
BEGIN;

UPDATE table SET name = 'xx3' WHERE id = 1;

UPDATE table SET name = 'xx4' WHERE id = 1;

如果现在有一使用REPEATABLE READ隔离级别的事务开始执行:
1)Transaction 100、200未提交

# SELECT1:Transaction 100200未提交 
SELECT * FROM table WHERE id = 1; # 得到的列name的值为'xxx'

yuque_diagram (17).jpg

Select1的执行过程如下:

  • 在执行select语句时会生成一个ReadView,ReadView的m_ids列表的内容就是【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0
  • 然后从版本链中挑选可见的记录,最新版本的列name的内容是xx2,该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是xx1,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本
  • 下一个版本的列name的内容是xxx,该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这列name=xxx的记录


2)把事务id=100的事务提交

# SELECT2:Transaction 100 提交,Transaction 200未提交
SELECT * FROM table WHERE id = 1; # 得到的列name的值为'xxx'

yuque_diagram (18).jpg

Select2的执行过程如下:

  • 当前事务的隔离级别为REPEATABLE READ,而之前在执行select1时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的m_ids列表的内容就是【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0
  • 然后从版本链表挑选可见的记录,最新版本的列name的内容是xx4,该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
  • 下一个版本的列name的内容是xx3,该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本
  • 同理下一个列的版本也不符合要求,继续跳到下一个版本
  • 下一个版本的列name的内容是xxx,该版本的trx_id值为80,小于Read中的min_trx_id值100,所以这个版本是符合要求的。最后返回列值xxx的记录

五、总结

MVCC指的就是在使用Read Committed、Repeatable read这两种隔离级别的事务在执行普通的select操作时访问记录的版本链的过程,可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

Read Committed、Repeatable read这两个隔离级别的不同就是:生成ReadView的时机不同,Read Committed在每一次进行普通select操作前都会生成一个ReadView,而Repeatable Read只在第一次进行普通select操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView.