MySQL隔离级别和实现方式(MVCC/LBCC)
一、并发访问数据库会产生哪些问题(并发一致性问题)?
1.修改丢失:两个事务同时修改同一行数据,发生了修改覆盖问题。
2.脏读:A事务读到了B事务还未提交的修改。
3.不可重复读:在避免脏读的前提下,同一个事务中,多次执行相同的查询语句,结果不同。原因是读取到了后续事务新提交的修改。
4.幻读:在避免不可重复读的前提下,两次执行相同的查询语句,发现出现了新的数据行。原因是有后续事务在数据行之间插入了新的数据并提交。
二、LBCC,三段封锁协议,读写锁解决修改丢失、脏读、不可重复读
可能你们会觉得上面这些问题都是一个问题,但是仔细想想还是有区别的,解决这些问题的方式也是不一样的,下面先从封锁的角度,提出实现方案,也就是LBCC:
1.修改丢失:修改丢失的本质就是两个事务对同一行数据同时进行了更改,即两个写事务并行,那么我们给修改的数据行加上写锁(X锁、排他锁),事务执行结束再释放写锁,问题就解决了。(一级封锁协议)
2.脏读:读取到了其他事务未提交的数据?事务A对数据行进行了修改,还没有提交,这时候事务B读入了同一个数据行。解决方式,在一级封锁的基础上,读数据要加读锁(S锁、共享锁),读完立刻释放锁(并不是等到事务结束)。这样在其他事务的修改未提交之前,无法读入数据行,避免脏读。(二级封锁协议)
3.不可重复读:同一事务多次读到了其他事务的提交,解决方案,只需要对二级封锁稍微做一下修改,读事务对数据行加读锁,等到事务结束再释放,这样就保证了,在读事务结束之前,其他任何写事务不能修改该数据行。(三级封锁协议)
4.幻读:不可重复读针对的是数据修改(update、delete),因此只要对数据行加上读锁,就能保证在读事务结束之前,该行数据不会被其他事务更新、也不会删除。但是并没有锁住数据行之间的间隙,所以即使加了行锁(读锁、写锁),其他事务也可以插入数据(insert),这就导致了幻读的出现,单单靠行锁无法解决。
三、LBCC,Next key-lock解决幻读
1.Record-lock:锁定一条数据上的索引,而不是数据本身,行锁的本质其实就是Record-lock。
2.Gap-lock:间隙锁,锁住索引之间的间隙(开区间),不包含索引本身。
3.Next key-lock:是索引锁和间隙锁的组合,左开右闭的区间,例如(1,2]。
那么Next key-lock如何解决幻读?
针对Repeatable read及以上的隔离级别(其他两个隔离级别无法解决幻读)
针对上面的图举个例子,假如id是唯一索引(不会重复),那么根本用不到Next key-lock,会退化成Record-lock,因为不可能再插入重复的数据了。
假如id是普通索引,事务A执行了查询语句select * from table where id = 3,那么事务A会为表加上一把Next key-lock,加锁区间为(2,3] + (3, 4] = (2 , 4]。如果查询语句是select * from table where id = 10,你可能会说没有id = 10,这条数据啊?是的,没有也会加锁的,加锁区间是(5,正无穷),而不是(10,正无穷)!
假如查询的索引恰好在两个间隙锁中间,那么会把两个区间连接起来,假如落在某个区间内,那么就是那个区间,关于区间边界值的处理,MySQL内部可能做了一些调整,不管是开区间还是关区间,保证不会发生幻读就OK。
四、MVCC,多版本并发控制,快照读不加锁,大大提高并发度
1.为什么要引入MVCC:可以看到通过LBCC的方式解决最基本的脏读问题都是需要加读锁的,即使是读完立刻释放读锁,也会对并发度造成一定影响;而如果想要解决不可重复读的问题,需要事务结束以后才可以释放读锁,即读写事务序列化执行,然而实际情况,读事务远远多于写事务,采用LBCC的方式去解决脏读和不可重复代价太大了,于是引入了MVCC,采用不需要加锁的快照读,大大提高了并发度。
2.MVCC针对的是Read Committed和Repeatable Read两个隔离级别。
3.快照读:先大概解释一下什么是快照读,对于数据表里面的每一行数据,每次更新都会生成一个快照,快照有一个指针,指向上一个版本的快照,这样读取数据行的时候,读取的是一个合适版本的快照,而不是数据表内的最新数据。
4. Undo 日志
Undo日志就是用来存储快照的,trx_id是修改数据行事务的id,roll_pointer是回滚指针,它将Undo日志里面一个数据行的所有快照连接起来,类似于链表结构,其实快照中除了记录了trx_id和roll_pointer还有一些其他的东西,例如操作类型、标记删除的del字段等,这里就不展开说了。事务的每一个更新操作都会生成上一个版本的快照,并且存储到Undo日志中,一些以后都不会再用到的快照会有一个清理线程进行清理。
5. ReadView
快照读
对于Read Committed和Repeatable Read两个隔离级别,普通的select语句进行的都是不加锁的快照读,例如select * from table where id = 1;会用到ReadView。
当前读
那么顺带说一下什么是不普通的select(当前读):
同样的查询语句 select * from table where id = 1 for share (MySQL 8写法)
select * from table where id = 1 lock in share mode (MySQL 5写法)会加读锁;
select * from table where id = 1 for update会加写锁;
加锁的叫做当前读,且会去数据表里面读取最新的版本并返回,不会用到ReadView。
什么是ReadView
事务在进行select操作的时候会生成一个ReadView结构,里面记录了:
TRX_IDs{TRX_ID_1, TRX_ID_2,…… }:当前系统所有未提交的事务ID列表;
TRX_ID_MIN:上述列表中最小的事务ID号;
TRX_ID_MAX:系统下一个要分配的事务ID(注意是下一个要分配的,注意和TRX_ID_MIN的区分);
CREATOR_TRX_ID:当前事务ID;
事务ID的生成时机?
只进行快照读的事务不会产生事务ID;
只有进行了修改操作才会进行分配。
ReadView如何通过不加锁的方式解决脏读、不可重复读、幻读的
前面已经说过,在进行快照读的时候会生成一个ReadView结构,之后会从最新的数据行版本开始读取。
假如数据行的trx_id < TRX_ID_MIN,说明ReadView生成之前该数据行对应的事务已经提交,则可以使用;
假如trx_id > =TRX_ID_MAX,说明这条记录是ReadView生成之后,新生成的事务提交的修改,则一定不可用;
假如TRX_ID_MIN <= trx_id < TRX_ID_MAX,如果trx_id存在于未提交的事务列表中,则表示该事务未提交,对应的数据行一定不可以使用,如果不在,说明生成ReadView之前该事务已经提交,则可以使用;
通过上面这种方式去版本链中找到一个合适版本的数据行并返回。
如果trx_id等于当前事务ID,说明是自己修改的数据,不管是否提交均可以使用。
ReadView的生成时机
Read Committed隔离级别:一个事务内,每次查询都会生成一个新的ReadView,因此可以读取其他事务新提交的数据,无法解决不可重复读和幻读。
Repeatable隔离级别:一个是事务内,只在第一次查询的时候生成ReadView,之后所有的查询复用第一次的ReadView,如此不管数据表做了任何的操作,由于ReadView不变,则读取到的数据不变。
6.MVCC的Repeatable Read解决了幻读吗?
这个怎么说都可以,因为ReadView的机制,即使数据表做了任何的修改,快照读的结果都是不变的,宏观上确实是不存在幻读了,但是实际上如果我们总时想要读取最新的数据而使用select * from table where id = 1 for share,那么只靠MVCC是无法解决幻读的问题的,所以才有了上面说的Next key-lock,MVCC加上Next key-lock就在Repeatable Read这个隔离级别上完全的解决了幻读的问题。