PS:本文已收录到1.1K+ Star数开源项目《面试指北》,如果想要了解更多MySQL相关的技术总结,可以看一看,项目地址:https://github.com/NotFound9/interviewGuide
问题分析
首先幻读是什么?
根据MySQL文档上面的定义
The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
幻读指的是在一个事务内,同一SELECT语句在不同时间执行,得到不同的结果集时,就会发生所谓的幻读问题。
可以看看下面的例子:
这是网上找的一张图(事务的务字写错了,不过不影响我们理解)假设这个例子中的MySQL的隔离级别是提交读,也就是一个事务内可以读到其他事务提交后的结果。
那么事务1第一次查询dept表中所有部门时,结果是没有"研发部",但是由于隔离级别是提交读,在事务2插入“研发部”这一行数据后,并且提交后,事务1是可以读取到的,所以第二次查询时,结果集中会有“研发部”。这就是幻读。
SELECT语句分类
首先我们的SELECT查询分为快照读和实时读,快照读通过MVCC(并发多版本控制)来解决幻读问题,实时读通过行锁来解决幻读问题。
快照读
1.1 快照读是什么?
因为MySQL默认的隔离级别是可重复读,这种隔离级别下,我们普通的SELECT语句都是快照读,也就是在一个事务内,多次执行SELECT语句,查询到的数据都是事务开始时那个状态的数据(这样就不会受其他事务修改数据的影响),这样就解决了幻读的问题。
1.2 那么innodb是怎么解决快照读的幻读问题的?
快照读就是每一行数据中额外保存两个隐藏的列,插入这个数据行时的版本号,删除这个数据行时的版本号(可能为空),滚动指针(指向undo log中用于事务回滚的日志记录)。
事务在对数据修改后,进行保存时,如果数据行的当前版本号与事务开始取得数据的版本号一致就保存成功,否则保存失败。
当我们不显式使用BEGIN来开启事务时,我们执行的每一条语句就是一个事务,每次开始事务时,会对系统版本号+1作为当前事务的ID。
1.2.1插入操作
插入一行数据时,将事务的ID作为数据行的创建版本号。
1.2.2删除操作
执行删除操作时,会将原数据行的删除版本号设置为当前事务的ID,然后根据原数据行生成一条INSERT语句,写入undo log,用于事务执行失败时回滚。delete操作实际上不会直接删除,而是将delete对象打上delete flag,标记为删除,最终的删除操作是purge线程完成的。但是会将数据行的删除版本号设置为当前的事务的ID,这样后面的事务B即便查到这行数据由于事务B的ID>删除版本号,也会忽略这条数据。
1.2.3更新操作
更新时可以简单的认为是先将旧数据删除,然后插入一条新数据。
所以执行更新操作时,其实是会将原数据行的删除版本号设置为当前事务的ID,生成一条INSERT语句,写入undo log,用于事务执行失败时回滚。插入一条新的数据,将事务的ID作为数据行的的创建版本号。
1.2.4查询操作
数据行要被查询出来必须满足两个条件,
数据行删除版本号为空或者>当前事务版本号的数据(否则数据已经被标记删除了)
创建版本号<=当前事务版本号的数据(否则数据是后面的事务创建出来的)
简单来说,就是查询时,
如果该行数据没有被加行锁中的X锁(也就是没有其他事务对这行数据进行修改),那么直接读取数据(前提是数据的版本号<=当前事务版本号的数据,不然不会放到查询结果集里面)。
该行数据被加了行锁X锁(也就是现在有其他事务对这行数据进行修改),那么读数据的事务不会进行等待,而是回去undo log端里面读之前版本的数据(这里存储的数据本身是用于回滚的),在可重复读的隔离级别下,从undo log中读取的数据总是事务开始时的快照数据(也就是版本号小于当前事务ID的数据),在提交读的隔离级别下,从undo log中读取的总是最新的快照数据。
1.3 补充资料:undo log段是什么?
undo_log是一种逻辑日志,是旧数据的备份。有两个作用,用于事务回滚和为MVCC提供老版本的数据。
可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。
1.3.1.用于事务回滚
当事务执行失败,回退时,会读取这行数据的滚动指针(指向undo log中用于事务回滚的日志记录),就可以在undo log中找到相应的逻辑记录,读取到相应的回滚语句,执行进行回滚。
1.3.2.为MVCC提供老版本的数据
当读取的某一行被其他事务锁定时(也就是有其他事务正在改这行数据),它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户进行快照读。在可重复读的隔离级别下,从undo log中读取的数据总是事务开始时的快照数据(也就是版本号小于当前事务ID的数据),在提交读的隔离级别下,从undo log中读取的总是最新的快照数据(也就是比正在修改这行数据的事务ID修改前的数据。)。
实时读
2.1实时读是什么?
如果说快照读总是读取事务开始时那个状态的数据,实时读就是查询时总是执行这个查询时数据库中的数据。
一般使用以下这两种查询语句进行查询时就是实时读。
SELECT *** FOR UPDATE 在查询时会先申请X锁
SELECT *** IN SHARE MODE 在查询时会先申请S锁
首先看一个实时读产生幻读的案例:这是《MySQL技术内幕++InnoDB存储引擎++第2版》里面的一张图,就是先将隔离级别设置为提交读,这样第一次执行
SELECT...FOR UPDATE
查询出来的数据是a:4,事务B插入了一条新的数据,再次执行 SELECT...FOR UPDATE
语句时,查询出来就是a:4,a:5两条数据,这就是幻读的问题。
2.1那么innodb是怎么解决实时读的幻读问题的?
如果我们不在一开始将将隔离级别设置为提交读,其实是不会产生幻读问题的,因为MySQL的默认隔离级别是可重复读,在这种情况下,我们执行第一次 SELECT...FOR UPDATE
查询语句是,其实是会先申请行锁,因为一开始数据库就只有a:4一行数据,那么加锁区间其实是
(负无穷,4]
(4,正无穷)
我们查询条件是a>2,上面两个加锁区间都会可能有数据满足条件,所以会申请行锁中的next-key lock,是会对上面这两个区间都加锁,这样其他事务不能往这两个区间插入数据,事务B会执行插入时会一直等待获取锁,直到事务A提交,释放行锁,事务B才有可能申请到锁,然后进行插入。这样就解决了幻读问题。
如果大家对行锁了解得比较少,下一期会对innodb中的锁进行介绍。
最后
大家有什么想法,可以一起讨论!
参考链接:
https://dev.mysql.com/doc/refman/8.0/en/innodb-next-key-locking.html
https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html