MySQL---事务、死锁、mvcc原理
前言
在MySQL的众多存储引擎中,只有InnoDB支持事务,所有这里说的事务隔离级别指的是InnoDB下的事务隔离级别。
MySQL 是支持多事务并发执行的。否则来一个事务处理一个请求,处理一个人请求的时候,其它事务都等着,那估计都没人敢用MySQL作为数据库,因为用户体验太差,估计都要砸键盘了。
既然事务可以并发操作,这里就有一些问题:一个事务在写数据的时候,另一个事务要读这行数据,该怎么处理?一个事务在写数据,另一个数据也要写这行数据,又该怎么处理这个冲突?
这就是并发事务所产生的一些问题。具体来说就是:脏读
、不可重复读
和幻读
一、概念说明
以下几个概念是事务隔离级别要实际解决的问题,所以需要搞清楚都是什么意思。
1、脏读
脏读指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了并一定最终存在的数据,这就是脏读。
![](https://img2020.cnblogs.com/blog/1090617/202111/1090617-20211116140259703-1334255993.jpg)
脏读最大的问题就是可能会读到不存在的数据。比如在上图中,事务B的更新数据被事务A读取,但是事务B回滚了,更新数据全部还原,也就是说事务A刚刚读到的数据并没有存在于数据库中。
从宏观来看,就是事务A读出了一条不存在的数据,这个问题是很严重的。
2、不可重复读
不可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据出现不一致的情况。
![](https://img2020.cnblogs.com/blog/1090617/202111/1090617-20211116140359155-111661271.jpg)
事务 A 多次读取同一数据,但事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。
3、幻读
脏读、不可重复读上面的图文都很好的理解,对于幻读网上有很多文章都是这么解释的
幻读错误的理解
说幻读是 事务A 执行两次 select 操作得到不同的数据集,即 select 1 得到 10 条记录,select 2 得到 15 条记录。
这其实并不是幻读,既然第一次和第二次读取的不一致,那不还是不可重复读吗,所以这是不可重复读的一种。
正确的理解应该是
幻读,并不是说两次读取获取的结果集不同,幻读侧重的方面是某一次的 select 操作得到的结果所表征的数据状态无法支撑后续的业务操作。
更为具体一些:select 某记录是否存在,不存在,准备插入此记录,但执行 insert 时发现此记录已存在,无法插入,此时就发生了幻读。
举例
假设有张用户表,这张表的 id 是主键。表中一开始有4条数据。
![](https://img2020.cnblogs.com/blog/1090617/202111/1090617-20211116140426482-1982045382.jpg)
我们再来看下出现 幻读 的场景
![](https://img2020.cnblogs.com/blog/1090617/202111/1090617-20211116140446009-953158864.jpg)
这里是在RR级别下研究(可重复读),因为 RU / RC 下还会存在脏读、不可重复读,故我们就以 RR 级别来研究 幻读,排除其他干扰。
1、事务A,查询是否存在 id=5 的记录,没有则插入,这是我们期望的正常业务逻辑。
2、这个时候 事务B 新增的一条 id=5 的记录,并提交事务。
3、事务A,再去查询 id=5 的时候,发现还是没有记录(因为这里是在RR级别下研究(可重复读),所以读到依然没有数据)
4、事务A,插入一条 id=5 的数据。
最终 事务A 提交事务,发现报错了。这就很奇怪,查的时候明明没有这条记录,但插入的时候 却告诉我 主键冲突,这就好像幻觉一样。这才是所有的幻读。
不可重复读侧重表达 读-读,幻读则是说 读-写,用写来证实读的是鬼影
二、事务的隔离级别
上述所说的"脏读","不可重复读","幻读"这些问题,其实就是数据库读一致性问题,必须由数据库提供的事务隔离机制来进行解决。
![](https://img2020.cnblogs.com/blog/1090617/202111/1090617-20211116140507745-1304749327.jpg)
首先说读未提交,它是性能最好,也可以说它是最野蛮的方式,因为它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。
再来说串行化。串行化就相当于上面所说的,处理一个人请求的时候,别的人都等着。读的时候加共享锁,也就是其他事务可以并发读,但是不能写。写的时候加排它锁,其他事务不能并发写也不能并发读。
最后说读提交和可重复读。这两种隔离级别是比较复杂的,既要允许一定的并发,又想要兼顾的解决问题。MySQL默认事务隔离级别为可重复读(RR),oracle默认事务隔离级别为读已提交(RC),
数据库的事务隔离越严格,并发副作用越小,但付出的代价越大;因为事务隔离本质就是使事务在一定程度上处于串行状态,这本身就是和并发相矛盾的。
同时,不同的应用对读一致性和事务隔离级别是不一样的,比如许多应用对数据的一致性没那么个高要求,相反,对并发有一定要求。
这篇文章起了个开头,接下来会写文章来回答下面的问题
1、MySQL 是如何实现这几个隔离级别的呢?它们底层的工作原理是什么呢?
2、虽然MySQL默认事务隔离级别为可重复读(RR),但它如何做到做到解决部分幻读的问题?
手把手教你分析解决MySQL死锁问题
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131343170-2089218163.jpg)
在生产环境中如果出现MySQL死锁问题该如何排查和解决呢,本文将模拟真实死锁场景进行排查,最后总结下实际开发中如何尽量避免死锁发生。
一、准备好相关数据和环境
当前自己的数据版本是8.0.22
mysql> select @@version;
+-----------+
| @@version |
+-----------+
| 8.0.22 |
+-----------+
1 row in set (0.00 sec)
数据库隔离级别(默认隔离级别)
mysql> select @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+
1 row in set (0.00 sec)
自动提交关闭
mysql> select @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 1 |
+--------------+
1 row in set (0.00 sec)
mysql> set autocommit=0;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 0 |
+--------------+
1 row in set (0.00 sec)
表结构
这个age为 非唯一索引
,这点对下面整个案例非常重要。
-- id是自增主键,age是非唯一索引,name普通字段
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`age` int DEFAULT NULL COMMENT '年龄',
`name` varchar(255) DEFAULT NULL COMMENT '姓名',
PRIMARY KEY (`id`),
KEY `idx_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='用户信息表';
表中暂时先插入两条数据
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131412129-2071148859.jpg)
二、模拟出真实死锁案例
开启两个终端模拟事务并发情况,执行顺序以及实验现象如下:
1)事务A执行更新操作,更新成功
mysql> update user set name = 'wangwu' where age= 20;
Query OK, 1 row affected (0.00 sec)
- 事务B执行更新操作,更新成功
mysql> update user set name = 'zhaoliu' where age= 10;
Query OK, 1 row affected (0.00 sec)
3)事务A执行插入操作,陷入阻塞~
mysql> insert into user values (null, 15, "tianqi");
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131436099-1643699220.jpg)
4)事务B执行插入操作,插入成功,同时事务A的插入由阻塞变为死锁error。
insert into user values (null, 30, "wangba");
Query OK, 1 row affected (0.00 sec)
事务A的插入操作变成报错。
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131457388-1607639845.jpg)
上面四步操作后,我们分别对事务A和事务B进行commit操作。
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
我们再来看数据库中表的数据。
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131516998-2045226900.jpg)
我们发现,事务B的所有操作最终都成功了,而事务A的操作因为报错都回滚了。所以事务A的操作都失败。
那既然是死锁,为什么回滚事务A,而不是事务B,是随机的还是有机制在里面?
我们可以理解死锁是数据库对事务的保护机制,一旦发生死锁,MySQL会选择相对小的事务(undo较少的)进行回滚。
三、查看分析死锁日志
可以用 show engine innodb status
,查看最近一次死锁日志哈,执行后,死锁日志如下(只展示部分日志):
LATEST DETECTED DEADLOCK
------------------------
2021-12-24 06:02:52 0x7ff7074f8700
*** (1) TRANSACTION:
TRANSACTION 2554368, ACTIVE 22 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
INSERT INTO user VALUES (NULL, 15, "tianqi")
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 309 page no 5 n bits 72 index idx_age of table `mall_goods`.`user` trx id 2554368 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 309 page no 5 n bits 72 index idx_age of table `mall_goods`.`user` trx id 2554368 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000014; asc ;;
1: len 4; hex 80000002; asc ;;
*** (2) TRANSACTION:
TRANSACTION 2554369, ACTIVE 14 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
INSERT INTO user VALUES (NULL, 30, "wangba")
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 309 page no 5 n bits 72 index idx_age of table `mall_goods`.`user` trx id 2554369 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000014; asc ;;
1: len 4; hex 80000002; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 309 page no 5 n bits 72 index idx_age of table `mall_goods`.`user` trx id 2554369 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** WE ROLL BACK TRANSACTION (1)
1、事务A相关日志
1)找到关键词TRANSACTION,事务2554368
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131538853-558095532.jpg)
2)查看事务1正在执行的sql
insert into user values (null, 15, "tianqi")
- 查看当前事务已占有的锁和等待其它事务释放的锁
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131558309-1140463551.jpg)
2、事务B相关日志
1)找到关键词TRANSACTION,事务2554369
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131617880-2147213035.jpg)
2)查看事务2正在执行的sql
insert into user values (null, 30, "wangba")
- 查看当前事务已占有的锁和等待其它事务释放的锁
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131635960-282014420.jpg)
3、总结
这里把一些关键的日志截图了下
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131658515-749675655.jpg)
我们把这张图换一种方式画下
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131718115-2133529433.jpg)
1)从图中可以很明显的看出,事务1和事务2都在等对方的锁释放,所以导致了死锁问题。而且最终是事务1进行了回滚。
2)这个日志提供比较重要的信息就是我们可以看出的是哪两条sql在互相一直等待其它事务的锁释放而产生了死锁,也知道是哪个索引导致产生的死锁,同时也知道最终哪个事务
被回滚了。
3)如果上面的信息还不能帮你定位解决问题,那可以问数据库DB要详细的binlog日志来分析这段时间这两个事务具体执行的所有sql
四、总结分析案例中产生死锁的原因
1、事务A的SQL产生了哪些锁
1) 事务A的update语句产生哪些锁
我们先来看
update user set name = 'wangwu' where age= 20;
记录锁
因为是等值查询,所以这里会在满足age=20的所有数据请求一个记录锁。
间隙锁
因为这里是非唯一索引的等值查询,所以一样会产生间隙锁(如果是唯一索引的等值查询那就不会产生间隙锁,只会有记录锁),因为这里只有2条记录
所以左边为(10,20),右边因为没有记录了,所以请求间隙锁的范围就是(20,+∞),加一起就是(10,20) +(20,+∞)。
Next-Key锁
Next-Key锁=记录锁+间隙锁,所以该Update语句就有了(10,+∞)的 Next-Key锁
事务A的install语句产生哪些锁
INSERT INTO user VALUES (NULL, 15, "tianqi");
间隙锁
因为age 15(在10和20之间),所以需要请求加(10,20)的间隙锁
插入意向锁(Insert Intention)
插入意向锁是在插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号,即事务A需要插入意向锁(10,20),这个插入锁的作用就是提高插入效率的,在分析
死锁的时候我们可以不用关心它,只关心上面间隙锁就好了。
2、事务B的SQL产生了哪些锁
事务B的update语句产生哪些锁
我们先来看
update user set name = 'zhaoliu' where age= 10
记录锁
因为是等值查询,所以这里会在满足age=10的所有数据请求一个记录锁。
间隙锁
因为左边没有记录,右边有一个age=20的记录,所以间隙锁的范围是(-∞,10),右边为(10,20),一起就是(-∞,10)+(10,20)。
Next-Key锁
Next-Key锁=记录锁+间隙锁,所以该Update语句就有了(-∞,20)的 Next-Key锁
事务A的install语句产生哪些锁
INSERT INTO user VALUES (NULL, 30, "wangba")
间隙锁
- 因为age 30(左边是20,右边没有值),所以需要请求加(20,+∞)的间隙锁
插入意向锁(Insert Intention)
- (20,+∞)
锁都分析清楚了,接下来再来看下是什么地方导致死锁的呢?
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211227131742900-1741116252.jpg)
这样以来产生整个死锁的原因也就清楚了,不过这里再补充两点
1)MySQL的间隙锁虽然有左开右闭的原则,但是其实这个并不完全正确,因为它有可能是左闭右开,也可能是左开右开,它会跟你插入主键值位置有关,具体的可以看我之前写的
一篇文章里面有完整示例MySQL记录锁、间隙锁、临键锁小案例演示。所以这里间隙锁写的都是左开右开的范围,可能临界点有点模糊,但不影响分析这个案例的死锁问题。
2)通过事务A和事务B的update语句,我们可以发现其实它们都持有间隙锁(10,20)的这段范围,说明间隙锁范围是可以相互兼容的,意思就是只要你的10不在我(10,+∞)的间隙锁
范围内,那就可以产生部分重合的间隙锁,也就是这里的(10,20)。
五、实际开发中如何尽量避免死锁发生
一般来讲在实际开发中,很少会发生死锁的情况,尤其是在业务并发量不是很大的情况下。在并发很大的情况下可能会存在偶尔产生死锁。
不过呢,在自己实际开发中,有遇到过请求一个接口出现100%概率死锁的情况。
当时的场景其实很简单。一段业务代码中,有去走Dubbo调其它接口服务,这就存在了两个事务,结果各自事务提交的时候,都需要等待对方的锁释放,就导致每次都发生死锁超时。
这其实是一种代码不规范而导致死锁的发生。这里也总结下如何尽量避免死锁发生。
1)不同的应用访问同一组表时,应尽量约定以相同的顺序访问各表。对一个表而言,应尽量以固定的顺序存取表中的行。这点真的很重要,它可以明显的减少死锁的发生。
举例:好比有a,b两张表,如果事务1先a后b,事务2先b后a,那就可能存在相互等待产生死锁。那如果事务1和事务2都先a后b,那事务1先拿到a的锁,事务2再去拿a的锁,如果
锁冲突那就会等待事务1释放锁,那自然事务2就不会拿到b的锁,那就不会堵塞事务1拿到b的锁,这样就避免死锁了。
2)在主键等值更新的时候,尽量先查询看数据库中有没有满足条件的数据,如果不存在就不用更新,存在才更新。为什么要这么做呢,因为如果去更新一条数据库不存在的数据,
一样会产生间隙锁。
举例:如果表中只有id=1和id=5的数据,那么如果你更新id=3的sql,因为这条记录表中不存在,那就会产生一个(1,5)的间隙锁,但其实这个锁就是多余的,因为你去更新一个
数据都不存在的数据没有任何意义。
3)尽量使用主键更新数据,因为主键是唯一索引,在等值查询能查到数据的情况下只会产生行锁,不会产生间隙锁,这样产生死锁的概率就减少了。当然如果是范围查询,
一样会产生间隙锁。
4)避免长事务,小事务发送锁冲突的几率也小。这点应该很好理解。
5)在允许幻读和不可重复度的情况下,尽量使用RC的隔离级别,避免gap lock造成的死锁,因为产生死锁经常都跟间隙锁有关,间隙锁的存在本身也是在RR隔离级别来
解决幻读的一种措施。
看一遍就懂:MVCC原理详解
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230212415667-1675803877.jpg)
MVCC实现原理也是一道非常高频的面试题,自己在整理这篇文章的时候,感觉到网上的资料在讲这块知识点上写的五花八门,好像大家的理解并没有一致。
这里将自己所理解的做一个总结,个人会觉得这是一篇含金量挺高的一篇文章(哈哈),所以请你坚持认真的看下去,一定会对你有收获。
如果文章中哪里没有理解,或者认为我讲的不对的地方,都欢迎留言一起交流哈。
前言
一些基本概念我这里不在做阐述了。好比什么是事务? 事务的ACID? 四大隔离级别?
有关事务并发存在的问题之前有写过一篇文章:一文详解脏读、不可重复读、幻读
如果你还不清楚不可重复读和幻读的区别,非常建议看完上面这篇文章。因为好多人会把不可重复读和幻读搞在一起。
所以会认为MVCC能解决幻读,其实MVCC解决的不是幻读,而是不可重复读,下面会用实际例子来证明这一点
一、什么是MVCC
多版本控制: 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。
在内部实现中,InnoDB通过undo log保存每条数据的多个版本,并且能够找回数据历史版本提供给用户读,每个事务读到的数据版本可能是不一样的。在同一个事务中,用户只能看到该事务创建快照之前已经提交的修改和该事务本身做的修改。
MVCC只在已提交读
(Read Committed)和可重复读
(Repeatable Read)两个隔离级别下工作,其他两个隔离级别和MVCC是不兼容的。因为未提交读,总数读取最新的数据行,而不是读取符合当前事务版本的数据行。而串行化(Serializable)则会对读的所有数据多加锁。
MVCC的实现原理主要是依赖每一行记录中两个隐藏字段,undo log,ReadView
二、MVCC相关的一些概念
这里我们先来理解下有关MVCC相关的一些概念,这些概念都理解后,我们会通过实际例子来演示MVCC的具体工作流程是怎么样的。
1、事务版本号
事务每次开启时,都会从数据库获得一个自增长的事务ID,可以从事务ID判断事务的执行先后顺序。这就是事务版本号。
也就是每当begin的时候,首选要做的就是从数据库获得一个自增长的事务ID,它也就是当前事务的事务ID。
2、隐藏字段
对于InnoDB存储引擎,每一行记录都有两个隐藏列trx_id、roll_pointer,如果数据表中存在主键或者非NULL的UNIQUE键时不会创建row_id,否则InnoDB会自动生成单调递增的隐藏主键row_id。
列名 | 是否必须 | 描述 |
---|---|---|
row_id | 否 | 单调递增的行ID,不是必需的,占用6个字节。 这个跟MVCC关系不大 |
trx_id | 是 | 记录操作该行数据事务的事务ID |
roll_pointer | 是 | 回滚指针,指向当前记录行的undo log信息 |
这里的记录操作,指的是insert|update|delete。对于delete操作而已,InnoDB认为是一个update操作,不过会更新一个另外的删除位,将行表示为deleted,并非真正删除。
3、undo log
undo log可以理解成回滚日志,它存储的是老版本数据。在表记录修改之前,会先把原始数据拷贝到undo log里,如果事务回滚,即可以通过undo log来还原数据。或者如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本。
在insert/update/delete(本质也是做更新,只是更新一个特殊的删除位字段)操作时,都会产生undo log。
在InnoDB里,undo log分为如下两类:
1)insert undo log : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
2)update undo log : 事务对记录进行delete和update操作时产生的undo log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被删除。
undo log有什么用途呢?
1、事务回滚时,保证原子性和一致性。
2、如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本(用于MVCC快照读)。
4、版本链
多个事务并行操作某一行数据时,不同事务对该行数据的修改会产生多个版本,然后通过回滚指针(roll_pointer),连成一个链表,这个链表就称为版本链。如下:
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230222653290-1858853701.jpg)
5、快照读和当前读
快照读: 读取的是记录数据的可见版本(有旧的版本)。不加锁,普通的select语句都是快照读,如:
select * from user where id = 1;
当前读:读取的是记录数据的最新版本,显式加锁的都是当前读
select * from user where id = 1 for update;
select * from user where id = 1 lock in share mode;
6、ReadView
ReadView是事务在进行快照读的时候生成的记录快照, 可以帮助我们解决可见性问题的
如果一个事务要查询行记录,需要读取哪个版本的行记录呢? ReadView 就是来解决这个问题的。 ReadView 保存了当前事务开启时所有活跃的事务列表。换个角度,可以理解为: ReadView 保存了不应该让这个事务看到的其他事务 ID 列表。
ReadView是如何保证可见性判断的呢?我们先看看 ReadView 的几个重要属性
-
trx_ids: 当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。(
重点注意
:这里的trx_ids中的活跃事务,不包括当前事务自己和已提交的事务,这点非常重要) -
low_limit_id: 目前出现过的最大的事务ID+1,即下一个将被分配的事务ID。
-
up_limit_id: 活跃事务列表trx_ids中最小的事务ID,如果trx_ids为空,则up_limit_id 为 low_limit_id。
-
creator_trx_id: 表示生成该 ReadView 的事务的 事务id
访问某条记录的时候如何判断该记录是否可见,具体规则如下:
- 如果被访问版本的
事务ID = creator_trx_id
,那么表示当前事务访问的是自己修改过的记录,那么该版本对当前事务可见; - 如果被访问版本的
事务ID < up_limit_id
,那么表示生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的
事务ID > low_limit_id
值,那么表示生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的
事务ID在 up_limit_id和m_low_limit_id
之间,那就需要判断一下版本的事务ID是不是在 trx_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;
如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
画张图来理解下
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230222718140-1599553574.jpg)
这里需要思考的一个问题就是 何时创建ReadView?
上面说过,ReadView是来解决一个事务需要读取哪个版本的行记录的问题的。那么说明什么?只有在select的时候才会创建ReadView。但在不同的隔离级别是有区别的:
在RC隔离级别下,是每个select都会创建最新的ReadView;而在RR隔离级别下,则是当事务中的第一个select请求才创建ReadView(下面会详细举例说明)。
那insert/update/delete操作呢?
这样操作不会创建ReadView。但是这些操作在事务开启(begin)且其未提交的时候,那么它的事务ID,会存在在其它存在查询事务的ReadView记录中,也就是trx_ids中。
三、MVCC实现原理分析
1、如何查询一条记录
- 获取事务自己事务ID,即trx_id。(这个也不是select的时候获取的,而是这个事务开启的时候获取的 也就是begin的时候)
- 获取ReadView(这个才是select的时候才会生成的)
- 数据库表中如果查询到数据,那就到ReadView中的事务版本号进行比较。
- 如果不符合ReadView的可见性规则, 即就需要Undo log中历史快照,直到返回符合规则的数据;
InnoDB 实现MVCC,是通过ReadView+ Undo Log
实现的,Undo Log 保存了历史快照,ReadView可见性规则帮助判断当前版本的数据是否可见。
2、MVCC是如何实现读已提交和可重复读的呢?
其实其它流程都是一样的,读已提交和可重复读唯一的区别在于:在RC隔离级别下,是每个select都会创建最新的ReadView;而在RR隔离级别下,则是当事务中的第一个select请求才创建ReadView。
看完下面这个例子你应该就明白了。
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230222741858-1594110121.jpg)
四、经典面试题:MVCC能否解决了幻读问题呢
有关这个问题查了很多资料,有的说能解决,有的说不能解决,也有人说能解决部分幻读场景。这里部分解决指的是能解决快照读的幻读问题,不能解决当前读的幻读问题。
具体可以看下面这篇文章
面试题之:MVCC能否解决幻读?https://blog.csdn.net/qq_35590091/article/details/107734005
先说我的结论:
MVCC能解决不可重复读问题,但是不能解决幻读问题,不论是快照读和当前读都不能解决。RR级别解决幻读靠的是锁机制,而不是MVCC机制。
既然网上那么多人说,MVCC解决能解决快照读下的幻读问题, 那这里通过举示例来说明,MVCC解决不了快照读的幻读问题。
假设有张用户表,这张表的 id 是主键。表中一开始有4条数据。
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230222957860-1748872228.jpg)
这里是在RR级别下研究(可重复读)。
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230223019433-210214288.jpg)
1、事务A,查询是否存在 id=5 的记录,没有则插入,这是我们期望的正常业务逻辑。
2、这个时候 事务B 新增的一条 id=5 的记录,并提交事务。
3、事务A,再去查询 id=5 的时候,发现还是没有记录。
上面的文章是这样来举例说明,事务A第一次和第二次读到的是一样的,所以认为解决了幻读。我不认为这个是解决了幻读,而是解决了不可能重复读。它保证了第一次和第二次所读到的结果是一样的。
解决幻读了吗?显然没有,因为这个时候如果事务A执行一条插入操作
INSERT INTO `user` (`id`, `name`, `pwd`) VALUES (5, '田七', 'fff');
![](https://img2020.cnblogs.com/blog/1090617/202112/1090617-20211230223042875-1554341866.jpg)
最终 事务A 提交事务,发现报错了。这就很奇怪,查的时候明明没有这条记录,但插入的时候 却告诉我 主键冲突,这就好像幻觉一样。这才是幻读问题。
所以说MVCC是不能解决的,要想解决还是需要锁。
这里事务A能正常的插入的前提就是其它事务不能插入id=5并提交成功。要解决这个问题也很简单,就是事务A先获得id=5这个排它锁。
我们可以在事务A第一次查询的时候加一个排他锁
select * from `user` where id = 5 for update
那么事务B的插入动作永远属于堵塞状态,直到事务A插入成功,并提交。那么最终是事务B报主键冲突而回滚。但事务A不会因为查询的时候没有这条记录,插入失败。也就解决了幻读问题。
所以说 RR级别下解决幻读问题靠的是锁机制,而不是MVCC机制。
如果有不认同我的观点的,可以留下你的观点,我们可以一起讨论交流哈。