事务隔离级别与MVCC (1)—mysql进阶(六十七)

2023-02-07 00:00:00 数据 版本 事务 记录 隔离

前面我们说了undo日志写入undo页面链表时,先需要把undo page header、undo segment header、undo log header等。每个事务都会有相应的undo链表,如果只存储一点数据不是很浪费吗,于是有了可重用,满足当前链表只有一个页,并且小于总空间的3/4。还介绍了回滚段,默认128个回滚段,每个段有1024个undo slot,每个slot分配给不同的事务,对应一个单独的undo页面链表。Undo日志也会记录redo日志,但临时表的undo日志写入不会记录redo日志,他的记录过程是先修改了数据,则会在系统表空间申请一个rollback segment header页面地址,循环获取,从第0号,第33~127号。分配了回滚段后,在段里查看cache是否存在undo slot,不存在就去rollback_segment_header找到一个undo slot分配该事务,如果没找到,则需要去undo log segment申请一个first undo page

我们先创建一个表

mysql> create table hero(

-> number int,

-> name varchar(100),

-> country varchar(100),

-> primary key(number)

-> )engine=innodb charset=utf8;

Query OK, 0 rows affected (0.06 sec)

INSERT INTO hero VALUES(1, '刘备', '蜀');


事务隔离级别

我们知道mysql是客户端和服务端架构的软件,对于同一个服务器,有若干个客户端与之连接,每个连接上之后,可以称为【session】。每次客户端会发送一个请求,或者一个事务给服务端,但如果用阻塞的方式,那就性能太慢,为了提高效率而保证每个事务之间都有隔离性,鱼和熊掌不可兼得,舍弃一部分隔离性取来提高性能。


事务并发执行遇到的问题

我们先看看在几个事务同时并发运行时候可能遇到的问题:


脏写(dirty write)

如果一个事务修改了另一个未提交的事务,这就发生了脏写。


场景:trx1和trx2,两个事务修改了同一条记录,但trx1还没提交,trx2把他回滚了,这时候trx1就么有效果。


导致的结果:trx1明明修改了数据,并且commit但是什么都没变化。


脏读(dirty read)

如果一个事物修改后,还未提交或者准备回滚,但是其他事务读了这条未提交的数据,就是脏读。


场景:trx1读,trx2修改了hero成关羽,但是后准备回滚,在回滚之前被并发的trx1读出来是关羽。


导致的结果:读出来是关羽。


不可重复读(Non-Repeatable Read)

当在同一个事务里,一条记录在其他事物被更改,导致多次查询出来的事务不一致,这种现象就是不可重复读。


场景:trx1读两次,trx2修改多次。


导致的结果:每次读取的数据都是不一致的。


幻读(Phantom)

幻读意味着insert,多次查询的数据,不一致,并且增多了。


场景:trx1读取两次,次读取了一条记录,第二次读取了两条记录,trx2事务insert了新纪录到表里。


导致的结果:后面读取的数据比前面读取的数据多。


注意:明确规定,删除或者修改都不算幻读,只有发生insert,多读了数据。


Sql标准中的四种隔离级别

综上所述,脏写>脏读>不可重复读>幻读


为了解决这些问题,于是mysql设计了四种隔离级别:


Read uncommit:未提交读。可能发生脏读,不可重复读,幻读。


Read commit:提交读。只发生不可重复读,幻读。


Repeatable read:可重复读。只发生幻读。


Serializable:可串行化。全部不会发生。


为啥没有脏写,因为脏写问题太严重了,任何情况下都不予许发生。(你想在你修改数据的时候,其他事物帮你吧数据回滚。。)


不同的数据库厂商对sql标准不同,比如oracle就只支持read committed和serializable。Mysql默认是repeatable read,但是可以禁止幻读发生。(后面会说如何禁止)


如何设置mysql隔离级别

mysql> set transaction isolation level read committed;


Query OK, 0 rows affected (0.00 sec)


后面的level参数可以写入四个隔离级别


这种只对下一个transaction有效,当下一个transaction结束,则恢复到之前的隔离级别。并且该语句不能再事务执行期间执行,否则会报错。


当我们给transaction前面加一个关键字的时候:


使用GLOBAL关键字(在全局范围影响):


比方说 set global transaction isolation level read committed;


则只对执行完该语句之后产生的会话起作用,当前已存在的会话。


使用Session关键字(在会话范围影响):


比方说 set session transaction isolation level read committed;


则对当前会话所有后续事务有效。


该语句可以在已开启事务中间执行,但不会影响正在执行的事务。


如果在事务之间执行,则对后续事务有效。


如果我们在服务器启动时想改变默认隔离级别,可以修改启动参数transaction-isolation的值,比方我们在启动mysql的时候加上

--transaction-isolation=serializable,那么事务的隔离级别就变成了serializable。

查看的话我们可以通过show variables like ‘transaction_isolation’;


MVCC原理

版本链

我们前面说过innoDB包含两个必要的隐藏链,一个是trx_id和roll_pointer(row_id不是必须的,当没有主见和索引才会创建。)


Trx_id:每次一个事务对某个聚簇索引记录进行更改,都会吧事务的id赋值给trax_id。就是属于某个事务的id,回滚对应的每个事务是独立且互相隔离,每个事物都有四个undo页面链表,临时表和普通表,insert undo 和update undo。


Roll Pointer:每次对页面改动的时候,会吧旧的日志记录到undo日志,这个指针可以找到他。指向回滚的页面,如果指向的是delete页面,delete有一个old roll pointer会指向上一个执行的sql,也就是insert 的undo页面。

比如我们在事务id为80的事务里插入一条sql,


mysql> SELECT * FROM hero;

+--------+--------+---------+

| number | name | country |

+--------+--------+---------+

| 1 | 刘备 | 蜀 |

+--------+--------+---------+

1 row in set (0.07 sec)


这时候这个数据结构就是:

Number:1

Name:刘备

Country:蜀

Trx_id:80

Roll_Pointer:指向inser undo页面。


(注意:实际上insert undo只有在事务未提交前起作用,当事务提交后,就没用了,它占用的undo log segment也会被系统收回,也就是undo日志占用 的undo页面链表要么被重用,要么被释放)。虽然insert undo占用的日志被释放,但是roll_pointer的值并不会被清除,roll_pointer属性占用7个字节,个比特位就是指向undo日志类型,如果该比特位值为1,那么代表它指向undo日志类型为insert undo。


当trx100和trx200两个事务同时并行修改一条数据会发生什么呢?

trx100:Begin。

trx200:begin。

trx100:update hero set name = ‘关羽’where number = ‘1’。

trx100:update hero set name = ‘张飞’where number = ‘1’。

trx100:commit。

trx200:update hero set name = ‘赵云’where number = ‘1’。

trx200:update hero set name = ‘诸葛亮’where number = ‘1’。

trx200:commit。

这时候交叉更新同一条记录不是会发生脏数据吗,后面会提到mysql的行锁。


每次对数进行update修改,都会记录一条undo日志,每条undo日志都会对应一个roll_pointer属性,可以吧上面这些数据串联起来,串联起来的头部条数据就是页面的真实数据,每次修改都会吧修改的数据放入undo页面。这个链表我们就称他为【版本链】,每个版本链还对应着事务id。


ReadView

对于使用read uncommitted隔离级别的事务来说,由于可以读取未提交的修改数据,所以直接读新的数据就好。对于serializable隔离级别的,mysql选择用锁的方式来访问记录。对于read committed和repeatable read隔离级别的事务,都必须保证已经提交的事务修改过的记录,也就是另一个事务还未提交,其他是不能读取新的数据。核心问题就是:判断下版本链中,哪个版本是对当前事务可见的。于是innoDB设计出readView的概念,这里面有四个比较重要的内容:


M_ids:表示生在readView时当前系统中活跃的读写事务的事务id列表。


Min_trx_id:表示在生成readView时当前系统中活跃的读写事务中小的事务id,也就是m_ids中的小值。


Max_trx_id:表示生成readView时系统应该分配给下一个事务的id值。


(注意:max_trx_id并不是m_ids里的大值,比如m_ids里有三个事务,1,2,3,当事务3提交了,m_ids只有1,2两个事务,那么新的事务在生成readView时候max_trx_id就是4)


Creator_trx_id:表示生成该readView的事务id。


(前面说过只有再给表做改动时候才有事务id,select没有,如果不存在事务id,creator_trx_id为0)


有了这个readView,这样在访问某条记录时,只要按下面步骤判断记录的某个版本是否可见:


如果被访问的版本trx_id值与readView中的creator_trx_id值相同,意味着当前事务在访问他字节修改过的记录,所以该版本可以在当前事务访问。

如果被访问的版本trx_id值小于 readView中的creator_trx_id值,表名生成该版本的事务在当前事务生成readView前已经提交,所以该版本可以被当前事务访问。

如果被访问的版本trx_id值大于readView中的creator_trx_id值,表名生成该版本的事务在当前事务生成readView后才开启,所以该版本不可以被当前事务访问。

如果被访问的版本trx_id值在readView的max_trx_id和min_trx_id之间,那么就需要判断一下trx_id是否在m_ids列表,如果在,说明创建readView时生成该版本事务还是活跃的,该版本不可以被访问。如果不在,说明创建readView时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,就会顺着版本找到下一个版本的数据,继续按照上面的步骤判断可见性,以此类推,直到找到后一个版本为止。如果后一个版本也不可见,则意味着该条记录读当前事务不可见。


在mysql中,read committed和repeatable read非常大的区别就是生成read view的时机不同。我们以hero表为例,假如表里有一条事务id为80的事务插入一条数据。


mysql> SELECT * FROM hero;

+--------+--------+---------+

| number | name | country |

+--------+--------+---------+

| 1 | 刘备 | 蜀 |

+--------+--------+---------+

1 row in set (0.07 sec)


Reac committed每次读取数据前生成一条readView

比方说两个事务id分别为100、200的事务在执行:


# Transaction 100

BEGIN;

UPDATE hero SET name = '关羽' WHERE number = 1;

UPDATE hero SET name = '张飞' WHERE number = 1;


# Transaction 200

BEGIN;

# 更新了一些别的表的记录

...

(注意:为什么这里事务200要更新别的,因为事务只有在执行delete,update,insert的时候才会分配事务id,这个id是自增的,所以我们目的是为了让他分配事务id)


此刻hero表的number为1的版本链如下:


1,张飞,蜀,100,roll_pointer

1,关羽,蜀,100,roll_pointer

1,刘备,蜀,80,roll_pointer

这里面1是页面中的记录,2,3是undo日志,一起就组成了版本链。

假设现在使用repatable read事务开始执行:


# 使用READ COMMITTED隔离级别的事务

BEGIN;

# SELECT1:Transaction 100、200未提交

SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'


这个select的执行过程如下:


在执行select语句时会生成一个read View,readView的m_ids列表的内容是【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0。


然后从版本链中挑选可见的记录,从上可以看到,版本链可见的是‘张飞’,该版本的trx_id为100,在m_ids内,所以不符合可见性,根据roll_pointer跳到下一个版本。


下一个版本的name是‘关羽‘,该版本的trx_id也是100,也在m_ids列表内,所以也不符合可见性,跳到下一个版本。


下一个版本的name是‘刘备‘,该版本的trx_id是80,小于readView的min_trx_id的值100,所以这个版本符合要求,吧name为刘备返回给用户。


我们看下一个场景,吧事务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的链表如下:


1,诸葛亮,蜀,200,call_pointer

1,赵云,蜀,200,call_pointer

1,张飞,蜀,200,call_pointer

1,关羽,蜀,200,call_pointer

1,刘备,蜀,200,call_pointer

其中1是页面显示的记录,后面2,3,4,5是undo日志的记录。

继续使用刚repeatable read隔离级别读取数据:


# 使用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的值仍为'刘备'

因为当前事务隔离级别是repatable read ,而之前执行selet1的时候已经生成了readView,所以直接复用之前的readView,之前readView里的m_ids里有【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0。

然后从版本链中挑选可见记录,因为诸葛亮的trx_id是200,包含在m_ids,所以不可见,查看下一条。

赵云的trx_id也是200,所以继续看下一条。

张飞的trx_id是100,不符合可见,继续看下一条。

关羽的trx_id也是100,继续看下一条。

刘备的trx_id是80,小于min_trx_id,所以符合版本可见,返回刘备给用户。

也就是说,两次结果select是相同的数据,这就是可重复读。如果我们吧事务200也提交,继续在事务中读一次,结果还是刘备。


MVCC小结

所谓的mvcc,就是multi version concurrent controller,多版本控制并发,在mysql中指在read committed、repeatable read在执行select的时候,可以并发操作,这样可以在不同事务读写和写读并发操作,从而提升性能。Read committed和repeatable read区别就是,read committed是每次普通select 都会生成一个readView,而compatable read则是次select生成,后面都是重复利用。

相关文章