众所周知,select for update 语句会加行锁,假设,注意这里是假设奥!!!假设事务 1 的 select * from user where name = 'Jack' for update 只在 id = 1 的这一行上加行锁
可以看到,事务 1 执行了三次查询,都是要查出 name = "Jack" 的记录行。注意我们假设这里只在 name = 'Jack' 行上加行锁
第一次查询只返回了 id = 1 这一行
在第二次查询之前,事务 2 把 id = 2 这一行的 name 值改成了 "Jack",因此事务 1 第二次查询的结果是 id = 1 和 id = 2 这两行
在第三次查询之前,事务 3 插入了一个 name = "Jack" 的新数据,因此事务 1 第三次查询的结果是 id = 1、id = 2 以及 id = 3 这三行
显然,第三次查询读到的 id = 3 这一行的现象,就是幻读
但其实从逻辑上来说,这似乎是没有问题的。
因为这三个查询都是加了 for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值,所以第二次查询和第三次查询就是应该看到事务 2 和事务 3 的操作效果。
那么,幻读到底有啥问题?
首先是语义上的。事务 1 在第一次查询的时候就声明了,我要把所有 name = "Jack" 的行锁住,拒绝别的事务对 name = "Jack" 的行进行读写操作。
但是,实际上,这个语义被破坏了,举个例子,我再往事务 2 里加一条 SQL 语句(黄色框框):
事务 2 的第二条语句的意思是 "把 id = 2 这一行的 age 值改成了 40",这行的 name 值是 "Jack"。
而在这之前,事务 1 只是给 id = 5 的这一行加了行锁,并没有给 id = 2 这行加锁。所以,事务 2 是可以执行这条 update 语句的。
这样,事务 2 先将 id = 2 的 name 改为 Jack,然后再将 age 改为 40,破坏了事务 1 对要把所有 "name = Jack 的行锁住" 的声明
其次,最重要的是,是数据一致性的问题。
众所周知,加锁是为了保证数据的一致性,这个一致性,不仅包括数据的一致性,还包括数据和日志的一致性,举个例子:
给事务 1 再加上一条 SQL 语句(黄色框框)
update user set name = "Jack" where id = 2
update user set age = "40" where id = 2 /*(2, Jack, 40)*/
T3 时刻,事务 3 提交,写入了 1 条语句;
insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/
T4 时刻,事务 1 提交,binlog 中写入了 update user set name = "Tom" where name = "Jack" 这条语句
update user set name = "Tom" where name = "Jack"
就是说,把所有 name = Jack 的行,都给我改成 name = "Tom"
这样,问题就来喽,binlog 一般都是用于备库同步主库的对吧,这个 binlog 一执行,那岂不是原先 (2, Jack, 40) 和 (3, Jack, 30) 这两行的 name 全都变成了 Tom。
也就是说,id = 2 和 id = 3 这两行,发生了数据不一致。
注意!这个数据不一致到底是怎么发生的?是假设事务 1 的 select * from user where name = 'Jack' for update 只在 id = 1 的这一行上加行锁导致的。
很显然,分析到这里,我们已经明白,只锁这一行是不合理的。那好办,让 select for update 把所有扫描到的行都给锁住不就行了?
这样,事务 2 在 T2 时刻就会被阻塞住,直到事务 1 在 T4 时刻 commit 释放锁
由于 session A 把所有的行都加了写锁,所以 session B 在执行第一个 update 语句的时候就被锁住了。需要等到 T6 时刻 session A 提交以后,session B 才能继续执行。
But,这样看似没问题,是否真的没问题呢?
来看 binlog,执行序列是这样的:
事务 3:
insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/
事务 1:
update user set name = "Tom" where name = "Jack"
事务 2:
update user set name = "Jack" where id = 2
update user set age = "40" where id = 2 /*(2, Jack, 40)*/
这也是为什么幻读问题会被单独拿出来解决的原因,即使我们把所有的的记录都加上锁,还是阻止不了新插入的记录。 MySQL 如何解决幻读
现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,操作的是锁住的行之间的 “间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。
这样,当你执行 select * from user where name = 'Jack' for update 的时候,就不止是给数据库中已有的 n 个记录加上了行锁,还同时加了 n + 1 个间隙锁(这两个合起来也成为 Next-Key Lock 临键锁)。也就是说,在数据库一行行扫描的过程中,不仅扫描到的行加上了行锁,还给行两边的空隙也加上了锁。这样就确保了无法再插入新的记录。
这里多提一嘴,update、delete 语句用不上索引是很恐怖的。
对非索引字段进行 select .. for update、update 或者 delete 操作,由于没有索引,走全表查询,就会对所有行记录 以及 所有间隔 都进行上锁。而对于索引字段进行上述操作,只有索引字段本身和附近的间隔会被加锁。
总结下 MySQL 解决幻读的手段:
隔离级别:可重复读