【MySQL】 事务的隔离级别与 MVCC
【MySQL】 事务的隔离级别与 MVCC
Metadata
title: 【MySQL】 事务的隔离级别与 MVCC
date: 2023-06-25 21:00
tags:
- 行动阶段/完成
- 主题场景/数据存储
- 笔记空间/KnowladgeSpace/ProgramSpace/BasicsSpace
- 细化主题/数据存储
categories:
- 数据存储
keywords:
- 数据存储
description: 【MySQL】 事务的隔离级别与 MVCC
概述
并发事务问题
- 脏写( Dirty Write ) 如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写
- 脏读( Dirty Read )一个事务读到了另一个未提交事务修改过的数据
- 不可重复读(Non-Repeatable Read) 一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值
- 幻读(Phantom)一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来
事务隔离级别
- READ UNCOMMITTED :未提交读。
- READ COMMITTED :已提交读。
- REPEATABLE READ :可重复读。
- SERIALIZABLE :可串行化。
MVCC
MVCC (Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD 、REPEATABLE READ 这两种隔离级别的事务在执行普通的SEELCT 操作时访问记录的版本链的过程
版本链
- trx_id :事务的事务id
- roll_pointer 通过它来找到该记录修改前的信息
ReadView
- m_ids :表示在生成ReadView 时当前系统中活跃的读写事务的事务id 列表。
- min_trx_id :表示在生成ReadView 时当前系统中活跃的读写事务中最小的事务id ,也就是m_ids 中的最小值。
- max_trx_id :表示生成ReadView 时系统中应该分配给下一个事务的id 值。
- creator_trx_id :表示生成该ReadView 的事务的事务id 。
READ COMMITTED —— 每次读取数据前都生成一个ReadView
REPEATABLE READ —— 在第一次读取数据时生成一个ReadView
事前准备
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
然后向这个表里插入一条数据:
INSERT INTO hero VALUES(1, '刘备', '蜀');
事务隔离级别
MySQL 是一个客户端/服务器架构的软件
对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话( Session )
事务并发执行遇到的问题
脏写( Dirty Write )
如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写
脏读( Dirty Read )
一个事务读到了另一个未提交事务修改过的数据
不可重复读(Non-Repeatable Read)
一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值
幻读(Phantom)
一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来
SQL标准中的四种隔离级别
给这些问题按照严重性来排一下序:
脏写 > 脏读 > 不可重复读 > 幻读
设立一些隔离级别,隔离级别越低,越严重的问题就越可能发生
制定了一个所谓的SQL标准,在标准中设立了4个隔离级别:
- READ UNCOMMITTED :未提交读。
- READ COMMITTED :已提交读。
- REPEATABLE READ :可重复读。
- SERIALIZABLE :可串行化。
脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。
MySQL中支持的四种隔离级别
与SQL标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL在REPEATABLE READ隔离级别下,是可以禁止幻读问题的发生的
如何设置事务的隔离级别
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
其中的level 可选值有4个:
level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}
设置事务的隔离级别的语句中,在SET 关键字后可以放置GLOBAL 关键字、SESSION 关键字或者什么都不放,这样会对不同范围的事务产生不同的影响,具体如下:
- 使用GLOBAL 关键字(在全局范围影响):
- 只对执行完该语句之后产生的会话起作用。
- 当前已经存在的会话无效。
- 使用SESSION 关键字(在会话范围影响):
- 对当前会话的所有后续的事务有效
- 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。
- 如果在事务之间执行,则对后续的事务有效。
- 上述两个关键字都不用(只对执行语句后的下一个事务产生影响):
- 只对当前会话中下一个即将开启的事务有效。
- 下一个事务执行完后,后续事务将恢复到之前的隔离级别。
- 该语句不能在已经开启的事务中间执行,会报错的。
MVCC原理
版本链
聚簇索引记录中都包含两个必要的隐藏列
- trx_id :每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id 赋值给trx_id 隐藏列。
- roll_pointer :每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer 属性( INSERT 操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表
所有的版本都会被roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链
每个版本中还包含生成该版本时对应的事务id
ReadView
对于使用READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了
对于使用SERIALIZABLE 隔离级别的事务来说,设计InnoDB 的大叔规定使用加锁的方式来访问记录(加锁是啥我们后续文章中说哈)
对于使用READ COMMITTED 和REPEATABLE READ 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的
核心问题
需要判断一下版本链中的哪个版本是当前事务可见的
提出了一个ReadView 的概念,这个ReadView 中主要包含4个比较重要的内容:
- m_ids :表示在生成ReadView 时当前系统中活跃的读写事务的事务id 列表。
- min_trx_id :表示在生成ReadView 时当前系统中活跃的读写事务中最小的事务id ,也就是m_ids 中的最小值。
- max_trx_id :表示生成ReadView 时系统中应该分配给下一个事务的id 值。
- creator_trx_id :表示生成该ReadView 的事务的事务id 。
有了这个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 时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性
示例
接下来看一下READ COMMITTED 和REPEATABLE READ 所谓的生成ReadView的时机不同到底不同在哪里。
READ COMMITTED —— 每次读取数据前都生成一个ReadView
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
假设现在有一个使用READ COMMITTED 隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1 的执行过程如下:
- 在执行 SELECT 语句时会先生成一个ReadView , ReadView 的m_ids 列表的内容就是[100, 200] ,min_trx_id 为100 , max_trx_id 为201 , creator_trx_id 为0 。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name 的内容是’张飞’ ,该版本的trx_id 值为100 ,在m_ids 列表内,所以不符合可见性要求,根据roll_pointer 跳到下一个版本。
- 下一个版本的列name 的内容是’关羽’ ,该版本的trx_id 值也为100 ,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name 的内容是’刘备’ ,该版本的trx_id 值为80 ,小于ReadView 中的min_trx_id 值100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name 为’刘备’ 的记录。
之后,我们把事务id 为100 的事务提交一下,就像这样:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id 为200 的事务中更新一下表hero 中number 为1 的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero 中number 为1 的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED 隔离级别的事务中继续查找这个number 为1 的记录,如下:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'
这个SELECT2 的执行过程如下:
- 在执行SELECT 语句时会又会单独生成一个ReadView ,该ReadView 的m_ids 列表的内容就是[200] ( 事务id 为100 的那个事务已经提交了,所以再次生成快照时就没有它了), min_trx_id 为200 ,max_trx_id 为201 , creator_trx_id 为0 。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name 的内容是’诸葛亮’ ,该版本的trx_id 值为200 ,在m_ids 列表内,所以不符合可见性要求,根据roll_pointer 跳到下一个版本。
- 下一个版本的列name 的内容是’赵云’ ,该版本的trx_id 值为200 ,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name 的内容是’张飞’ ,该版本的trx_id 值为100 ,小于ReadView 中的min_trx_id 值200 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name 为’张飞’ 的记录。
总结
使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
REPEATABLE READ —— 在第一次读取数据时生成一个ReadView
只会在第一次执行查询语句时生成一个ReadView ,之后的查询就不会重复生成了。
比方说现在系统里有两个事务id 分别为100 、200 的事务在执行:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
此刻,表hero 中number 为1 的记录得到的版本链表如下所示:
假设现在有一个使用REPEATABLE READ 隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1 的执行过程如下:
- 在执行SELECT 语句时会先生成一个ReadView , ReadView 的m_ids 列表的内容就是[100, 200] ,min_trx_id 为100 , max_trx_id 为201 , creator_trx_id 为0 。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name 的内容是’张飞’ ,该版本的trx_id 值为100 ,在m_ids 列表内,所以不符合可见性要求,根据roll_pointer 跳到下一个版本。
- 下一个版本的列name 的内容是’关羽’ ,该版本的trx_id 值也为100 ,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name 的内容是’刘备’ ,该版本的trx_id 值为80 ,小于ReadView 中的min_trx_id 值100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name 为’刘备’ 的记录。
之后,我们把事务id 为100 的事务提交一下,就像这样:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id 为200 的事务中更新一下表hero 中number 为1 的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero 中number 为1 的记录的版本链就长这样:
然后再到刚才使用REPEATABLE READ 隔离级别的事务中继续查找这个number 为1 的记录,如下:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'
这个SELECT2 的执行过程如下:
- 因为当前事务的隔离级别为REPEATABLE READ ,而之前在执行SELECT1 时已经生成过ReadView 了,所以此时直接复用之前的ReadView ,之前的ReadView 的m_ids 列表的内容就是[100, 200] , min_trx_id 为100 , max_trx_id 为201 , creator_trx_id 为0 。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name 的内容是’诸葛亮’ ,该版本的trx_id 值为200 ,在m_ids 列表内,所以不符合可见性要求,根据roll_pointer 跳到下一个版本。
- 下一个版本的列name 的内容是’赵云’ ,该版本的trx_id 值为200 ,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name 的内容是’张飞’ ,该版本的trx_id 值为100 ,而m_ids 列表中是包含值为100 的事务id 的,所以该版本也不符合要求,同理下一个列name 的内容是’关羽’ 的版本也不符合要求。继续跳到下一个版本。
- 下一个版本的列name 的内容是’刘备’ ,该版本的trx_id 值为80 ,小于ReadView 中的min_trx_id 值 100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c 为’刘备’ 的记录。
也就是说两次SELECT 查询得到的结果是重复的,记录的列c 值都是’刘备’ ,这就是可重复读的含义。如果我们之后再把事务id 为200 的记录提交了,然后再到刚才使用REPEATABLE READ 隔离级别的事务中继续查找这个number 为1 的记录,得到的结果还是’刘备’ ,具体执行过程大家可以自己分析一下。
MVCC小结
所谓的MVCC (Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD 、REPEATABLE READ 这两种隔离级别的事务在执行普通的SEELCT 操作时访问记录的版本链的过程
这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
READ COMMITTD 、REPEATABLE READ 这两个隔离级别的一个很大不同就是:
生成ReadView的时机不同,
- READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,
- 而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。
关于purge
我们说insert undo 在事务提交之后就可以被释放掉了,而update undo 由于还需要支持MVCC ,不能立即删除掉。
为了支持MVCC ,对于delete mark 操作来说,仅仅是在记录上打一个删除标记,并没有真正将它删除掉。
随着系统的运行,在确定系统中包含最早产生的那个ReadView 的事务不会再访问某些update undo日志以及被打了删除标记的记录后,有一个后台运行的purge线程会把它们真正的删除掉。