本文首发于公众号【看点代码再上班】,欢迎关注。
原文:美团面试官:MySQL可重复读如何解决幻读?
“全文共计4427字,预计阅读时间6分钟
大家好,我是*tin*,这是我的第27篇原创文章
***幻读(phantom read) ********,是指在一个事务中前后两次相同的查询产生不同的结果集,后一次查询看到了前一次查询没有看到的记录行。
MySQL InnoDB默认的事务隔离级别是可重复读,可重复读的要旨在于同一数据行记录在一个事务内无论何时查询结果都是一样的。
从定义可以知道,可重复读解决的问题和幻读问题有实质性的区别,一个针对同一行记录,一个说的是数据行数,那么,MySQL又是怎么解决幻读问题的呢,今天就来一探究竟,先上一个目录:
一、MySQL如何解决幻读
1.1 快照读和当前读
1.2 快照读如何解决幻读
1.3 当前读如何解决幻读
二、可重复读完全解决幻读了么?
2.1 鲜为人知的幻读
三、结语
一、MySQL如何解决幻读
首先,我们的前提是在MySQL数据库内,使用的引擎是InnoDB引擎,且事务的隔离级别是可重复读。
前面文章有讲过,MySQL InnoDB依靠MVCC实现事务隔离级别。MVCC又称多版本并发控制,它的全称是Multi-Version Concurrency Control,直白说就是在同一时刻同一条记录在系统中可以存在多个版本。
如果不记得MVCC,可以点击看这里:美团面试官:可重复读隔离级别实现原理是什么?(一文搞懂MVCC机制)
1.1 快照读和当前读
当前读: MySQL的MVCC决定了同一数据行可能会同时存在多个版本的情况,当前读表示读取的记录是最新版本的,且读取的时候,如果有其他并发事务要修改同一数据行,当前事务会通过加锁让其他事务阻塞等待。
比如select lock in share mode(共享锁)、select for update 、update、insert 、delete(排他锁)等操作都是一种当前读,这些操作会对读取的记录进行加锁。
快照读: 表示不加锁的非阻塞读,像普通的select操作就是快照读。快照读的实现基于MVCC,它实现了事务内任何时刻读取的数据都是历史某个版本的数据,不一定是当前时刻最新的数据。
MVCC这种实现方式也是一种锁的变种,但它避开了加锁操作,大大降低系统的开销,从而提高系统的性能。
需要特别注意的是,快照读在MySQL的串行隔离级别下会上升为当前读,即使是select操作也会加锁。
1.2 快照读如何解决幻读
假如我们有一张账户余额表bank_balance,其结构如下,里面的初始数据行有9行。
CREATE TABLE
bank_balance
(
id
int NOT NULL AUTO_INCREMENT,
user_name
varchar(45) NOT NULL COMMENT '用户名',
balance
int NOT NULL DEFAULT '0' COMMENT '余额,单位:人民币分,比如100表示人民币1元,默认是0',
wealth
tinyint NOT NULL DEFAULT '0' COMMENT '富有程度,0:贫穷,1:富有',PRIMARY KEY (
id
),UNIQUE KEY
idx_bank_balance_user_name
(user_name
)) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
初始数据行:
mysql> select *from bank_balance;
+----+-----------+-----------+--------+
| id | user_name | balance | wealth |
+----+-----------+-----------+--------+
| 1 | 小埃 | 0 | 0 |
| 2 | 小克 | 300000000 | 0 |
| 3 | Tom | 500 | 0 |
| 4 | Eric | 100 | 0 |
| 5 | AI | 0 | 0 |
| 6 | Alex | 100 | 0 |
| 7 | Max | 100 | 0 |
| 8 | Mike | 100 | 0 |
| 9 | Lyn | 200 | 0 |
+----+-----------+-----------+--------+
9 rows in set (0.01 sec)
假设现在有两个事务,事务A和事务B,同时操作这张余额表,两个事务的操作时间线如下:
事务A有两次查询,分别在③和⑤,都是采用相同的SQL语句:select * from bank_balance where balance > 0(普通select是一种快照读),目的都是查询所有balance > 0的rows。
- ①和②:开启事务。
- ③:事务A通过select * from where balance > 0得到的结果是7 Rows,如下:
-
④:事务B插入一行记录 (10, 'Loop', 100,0)。
-
⑤:事务A通过select * from bank_balance where balance>0再次查询得到的结果,同样还是7 Rows。
-
⑥和⑦:提交事务。
为什么第⑤处查询时结果还是7 Rows呢?大家应该都还记得MVCC,事务A在第③处就会生成一个ReadView记录当前的活跃事务,事务B就在活跃事务范围内,在第⑤处事务B insert的记录隐藏列事务id不满足事务A读取,事务A会顺着undo log的版本链查到满足的记录为止(当然,该记录是事务B新增的,顺着版本链找最终只能找到null,所以该记录不返回)。
1.3 当前读如何解决幻读
同样是上面的表和查询时间线,只是查询语句换成了当前读的查询select * from bank_balance where balance > 0 for update,假设没有锁,那么就会发生幻读现象,如下:
- ①和②:开启事务。
- ③:事务A通过select * from bank_balance where balance>0 for update得到的结果是7 Rows,如下:
- ④:事务B插入一行记录 (10, 'Loop', 100,0)。
- ⑤:事务A通过select * from bank_balance where balance > 0 for update再次查询得到的结果是8 Rows,如下:
-
⑥和⑦:提交事务。
第③和第⑤同样是查询bank_balance > 0 的记录但得到的结果却不一样,这就是幻读现象。
为了解决幻读问题,MySQL InnoDB 引擎引入了next-key lock,其等同于间隙锁+记录锁的组合。
记录锁,顾名思义,就是给数据行加的锁,那何为间隙锁?
假设,bank_balance表中只存在余额balance>0且主键id 为4和6的记录,那么当一个事务使用select * from where balance>0 for update查询时,其他事务就无法插入 id = 5的记录,就像是事务A把(4,6)这个范围锁住了,这就是间隙锁。
如果再把id=4和6的记录也同时一起锁了,合起来变成一个闭区间[4, 6],那么整个区间锁也叫next-key lock。
还是以上的例子,事务B在事务A查询后进行insert操作:
事务 A 在③处执行了select * from bank_balance where balance > 0 for update这条锁定读语句后,就会把整个表所有记录锁上(因为balance字段无索引),并根据主键id和表记录形成多个next-key lock,分别是:(-∞, 1]、(1, 2]、(2, 3]、(3, 4]、(4, 5]、(5, 6]、(6, 7]、(7, 8]、(8, 9]、(9, +∞],每个next-key lock都是前开后闭区间。
然后,事务 B 在④处执行插入语句,发现id=10被事务 A 加了 next-key lock,于是事物 B 会生成一个写锁,开始阻塞等待,直到事务 A 提交了事务才会执行。这就避免了上述所说的幻读问题。
以上的例子比较特殊,如果我们的表中只有两条记录,分别是(4, 'Eric', 100,0)、(10, 'Loop', 100,0),那么当我们执行select *from bank_balance where id > 8 for update时,就只会形成两个next-key lock,它就是(4, 10],(10, +∞],如果我们执行insert into bank_balance values(5,'MALL',100,0)将会被阻塞,但是我们执行insert into bank_balance values(2,'MALL',100,0)就不会被阻塞,因为id=2没有被锁住。
特别说明一下,next-key lock基于记录形成,不是基于查询条件形成,有些同学问到上文的例子中两个next-key lock为什么不是(8, 10]、(10, +∞],就是这个原因。
二、可重复读完全解决幻读了么
2.1 鲜为人知的幻读
MySQL InnoDB默认的可重复读隔离级别加上next-key lock一定程度上解决了幻读问题,但依然存在特殊的情况下产生幻读问题。
*第一种情况, *先启动的事务A使用快照读,后启动的事务B插入新的数据行并提交,然后事务A再更新,其后A的查询都能查事务B新增的数据行。
③:表中没有id=5的记录行,所以事务A查询的结果是0Rows。
④-⑥:事务B启动,并插入一条id=5的记录,后提交事务。
⑦:事务A更新id=5的记录。
⑧:事务A查询id=5的记录,结果Rows=1,产生了幻读。
按MVCC的原理,第⑧处事务A查询结果不应该返回id=5的记录,但因为有update在先,所以该记录背查询了出来。(此处很绕,需要认真看一看这个文章才能理解:美团面试官:可重复读隔离级别实现原理是什么?(一文搞懂MVCC机制))
快照读不会加锁,导致事务B可以insert成功,而update语句又是当前读,能够更新id=5的数据,所以,当执行⑧时,快照读也就能够查询出来id=5的记录了。
*第二种情况, *如果事务一开始没有使用当前读,当其他事务插入数据并提交后再使用当前读就会发生幻读现象。
③:表中没有id=5的记录行,所以事务A采用快照读方式查询的结果是0Rows。
④-⑥:事务B启动,并插入一条id=5的记录,后提交事务。
⑦:事务A采用当前读的方式查询id=5的行,结果Rows为1,产生了幻读。
这种情况是因为快照读不生成next-key lock导致,其他事务可以插入本事务查询范围内的记录行,所以,当其他事务插入数据后再执行当前读,就能查到新的记录,从而产生幻读问题。
一般在开发过程中建议开启一个事务时尽快采用for update的查询方式,以生成next-key lock,避免幻读问题。
三、结语
我是tin,一个在努力让自己变得更优秀的普通工程师。自己阅历有限、学识浅薄,如有发现文章不妥之处,非常欢迎加我提出,我一定细心推敲并加以修改。
看到这里请安排个“三连”(分享、点赞、在看)再走吧,坚持创作不容易,你的正反馈是我坚持输出的最强动力,谢谢!