建表和插入数据的语句如下。
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
在这个表中有一个主键索引和一个普通索引c,并且向表中插入了6条数据。
下面的语句序列是怎么加锁的?加的锁是什么时候释放的呢?
begin;
select * from t where d=5 for update;
commit;
这个语句会在d=5的数据行,也就是id=5的数据行上加上写锁,因为这是一个for update语句,并且这个写锁会在提交事务的时候释放。
但是字段d上并没有建立索引,所以在执行这条查询语句时会进行全表扫描,那么其他被扫描到的不满足条件的数据行上会加上写锁吗?
Innodb存储引擎的默认隔离级别是可重复读隔离级别,所以接下来的分析都是在可重复读隔离级别下进行的。
如果在执行这条查询语句时只在id=5的数据行上加锁了,别的数据行都没有加锁,那么会发生什么?
先看这么一个场景。
在会话A中执行了三次查询,三次查询的sql语句都是相同的,都是select from t where d=5 for update;,这个语句的作用是取出表中所有d=5的数据行,读取到的数值都是最新的,在满足条件的数据行加上写锁。
那么这三个语句的执行结果是相同的吗?并不是,第一条查询语句只能查到id=5这一行,由于在会话B中把id=0的数据行的d值改成了5,所以第二条查询语句查到了id=0和id=5两个数据行,在会话C中向表t中插入了一个新的数据行,此时第三条查询语句会查到id=0、id=1和id=5三个数据行。为什么三条查询语句能看到这些数据行呢?能分析一下吗?
当会话B中
如果在一个事务中在不同时间执行了同一条查询语句,但是后一次查询看到了前一次查询没有看到的数据行,这时我们就说发生了幻读现象。
在可重复读隔离级别下,普通的查询语句,也就是说没有for update的查询语句,是从事务创建的快照中读取数据的,也就是说并不是从最新状态的表中读取的数据,此时不同时刻的查询语句的执行结果是完全相同的。如果我们想要看到幻读的话,执行的查询语句必须是从最新状态的表中读取数据的,在逻辑上是有这么一个最新状态的表的,也就是说,想要发生幻读现象,读取数据的方式必须是”当前读”而不是”快照读”,也就是说使用for update语句才能看到幻读。而且只有在看到新的数据行时,我们才说发生了幻读,如果是已有的数据行中的值发生了修改,这种不叫幻读。
当前读的规则是能够读到所有已提交事务对表所做的修改,所以会话A中的后两条查询语句是要能够看到会话B和会话C中的语句对表所做的修改的,执行结果也说明它们确实看到了。
但是幻读现象显然不是我们想要的,幻读也会带来一些问题。
select from t where d=5 for update;语句的语义是我要把表中所有d=5的数据行锁住,不准别的事务对d=5的数据行进行读写操作,但是我们按照上面的语句序列来分析的话,就会发现这个语句的语义被破坏了,也就是说这个语句并没有做到把表中d=5的数据行都锁住。
会话B中的第二条语句update t set c=5 where id=0;的语义是把id=0,d=5的数据行中的c值设置为5。
在T1时刻,只在id=5这个数据行加上了写锁,其它的数据行,包括id=0的数据行并没有加写锁,所以会话B中的两条update语句是能够被正常执行的,既然update t set c=5 where id=0 and d=5;语句能够被正常执行那就说明select from t where d=5 for update;语句并没有把表中所有d=5的数据行锁住。会话C反映的是相同的问题。
update语句和select … for update;语句一样,都是在读写数据之前先给数据行加上写锁。select from t where d=5 for update;语句的语义是给表中所有d=5的数据行加上写锁,加上写锁是干什么的?就是为了保证在执行update语句之前的时间里d=5数据行中的值不会被修改。在执行select … for update语句后事务A已经持有了所有d=5的数据行上的写锁,在执行update语句之前会检测事务A是否持有所有d=5数据行上的写锁,如果只持有一部分d=5数据行上的写锁,那么会先去获取没持有写锁的数据行上的写锁,如果有的数据行的写锁没办法获取,这个语句就会被阻塞。在执行update语句之前需要再次获取写锁吗?Mysql中的写锁是可重入锁吗?
那么按照上图中的语句序列执行完之后,表中的数据发生了什么样的变化?
经过T1时刻,id=5的数据行变成了(5,5,100),这个值在事务提交后才能被其它事务看到。经过T2时刻,id=0的数据行变成了(0,5,5),经过T4时刻,表里面多了一行(1,5,5),其它行的数据和这个语句序列没有关系,仍然是原有的值。
接下来我们再来看binlog文件中的内容发生了什么变化。
在T2时刻,会话B中提交了两个事务,就会向binlog文件中写入两条sql语句,在T4时刻,会话C中提交了事务,也会向binlog文件中写入两条sql语句,在T6时刻,会话A提交了一个事务,向binlog文件中写入了update t set d=100 where d=5;这条sql语句。
提交事务和向binlog文件写入sql语句的关系,在提交事务过程中的其中一步就是向binlog文件写入sql语句。
把写入binlog文件的5条sql语句放到一起,就如下所示。
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/*所有d=5的行,d改成100*/
但是这么一个语句序列,不管是拿到备库去做主从同步,还是用binlog文件来复制一个库,id=0的数据行变成了(0,5,5),id=1的数据行变成了(1,5,5),然后id=0的数据行变成了(0,5,100),id=1的数据行变成了(1,5,100),id=5的数据行变成了(5,5,100)。
而表中此时的实际数据是id=0(0,5,5),id=1(1,5,5),id=5(5,5,100),也就是说根据binlog文件恢复的数据和表中的实际数据不一样。在id=0和id=1的数据行上发生了数据不一致,数据不一致的问题是很严重的。
前面我们设置的是只给id=5的数据行加锁,会导致数据不一致的问题,那么我们把设定改成扫描到的数据行都加上写锁,会解决数据不一致的问题吗?
如果会话A把所有的数据行都加上了写锁,所以会话B中在执行第一个update语句的时候就被阻塞住了,需要等到T6会话A提交之后,会话B中的语句才能继续执行。会话C中的语句不会被阻塞,因为会话C中的语句是在已有数据行之间插入新的数据行,以及对新的数据行进行修改,所以不会被阻塞。
按照这个顺序执行的话,表中的数据就是(1,5,5),(5,5,100),(0,5,5)。在可重复读隔离级别下,事务对表中数据的修改在提交之前只有本事务能够看到,别的事务只有在。那么binlog文件里的语句序列是什么样的呢?
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/*所有d=5的行,d改成100*/
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
按照这个语句序列执行的话,恢复出的数据是(1,5,5)->(1,5,100),(5,5,100),(0,5,5)。对比之后我们能够发现id=1的数据行发生了数据不一致。我们都已经给所有扫描到的数据行加上锁了,为什么还会出现数据不一致呢?
经过分析我们可以发现,只要在会话A中事务提交之前别的事务能够向表中插入数据行,那么就可能会出现数据不一致的现象。
那么是不是我们不允许别的事务向表中插入数据行,就不会出现数据不一致的问题了?既然不允许别的事务向表中插入数据行,那么会话C中的语句也会被阻塞。那么表中的数据就会变成(5,5,100),(0,5,5),(1,5,5),写入到binlog文件中的语句序列是什么呢?
update t set d=100 where d=5;
update t set d=5 where id=0;
update t set c=5 where id=0;
insert into t values(1,1,5);
update t set c=5 where id=1;
那么根据binlog文件恢复出的数据是(5,5,100),(0,5,5),(1,5,5),所以我们能够看到当select … for update;语句能够阻塞别的事务向数据行之间插入数据行时,就不会出现数据不一致的问题了。现在select … for update;语句阻塞插入语句解决了数据不一致的问题,那么它也能解决语义不一致的问题吗?也就是说select … for update;语句能够做到在表中所有d=5的数据行加上锁吗?如果这个语句不能阻塞别的事务向数据行之间插入数据行,那么select … for update;语句只能锁住在执行这个语句时存在的d=5的数据行,而不能锁住在执行这个语句之后再插入到表中的d=5的数据行,而如果select … for update;语句能够阻塞别的事务向数据行之间插入数据行的话,那么在select语句所在事务提交之前,在表上不可能会出现新的d=5的数据行,如果我们把在执行select语句时的d=5的数据行都加上锁了,就是把表中所有的d=5的数据行都加上锁了。因为表中的d=5的数据行只有两种,一种是在执行select语句时存在的d=5的数据行与在执行select语句之后插入的新的d=5的数据行,如果不存在新插入的数据行,那么当前存在的数据行就是所有的数据行。
那么我们怎么做到不允许别的事务向表中插入数据行的呢?我们通过间隙锁来实现,通过给数据行之间的间隙进行加锁,阻塞别的事务在数据行之间插入数据行。也就是说,在我们执行select from t where d=5 for update;语句的时候,不仅给所有扫描到的数据行上加了锁,还在数据行之间加上了间隙锁。那么加上了多少个间隙锁呢?加上了什么间隙锁呢?执行这个sql语句,就会加上了7个间隙锁,分别是(-∞,0)间隙锁,(0,5)间隙锁,(5,10)间隙锁,(10,15)间隙锁,(15,20)间隙锁,(20,25)间隙锁,(25,+∞)间隙锁,正是由于加上了(0,5)间隙锁,所以会话C中的插入语句就会被阻塞,直到会话A提交之后才会执行,那么会话B和会话C都被会话A阻塞了,在会话A提交之后,会话B和会话C中的语句哪个先执行呢?会话和事务的关系是什么?
select from t where d=5 for update;语句的执行过程,是对主键索引树做全表扫描,正是在对主键索引树做全表扫描的过程中,给所有扫描到的数据行加上了行锁,在数据行之间加上了间隙锁。
那么间隙锁有什么特别的地方呢?
对于行锁来说,它分为读锁和写锁,在一个数据行上我们可以同时加上两个读锁,但是不能同时加上一个读锁一个写锁,或者是两个写锁。也就是说,行锁和行锁之间是存在阻塞关系的。
但是间隙锁和行锁不同,不同的语句可以在同一个区间加上间隙锁,一个事务给一个区间加上间隙锁,并不影响另一个事务也给这个区间加上间隙锁,但是在一个事务给一个区间加上间隙锁之后,别的事务都不能在这个区间上插入数据行了,但是加上间隙锁的事务中是可以插入的。
举一个例子。
select … lock in share mode语句的执行过程同样是对主键索引树进行全表扫描,既然做了全表扫描,那么就会在数据行之间加上了间隙锁,如果在扫描的过程中找到了满足条件的数据行,就在这个数据行上加上读锁,不满足条件的数据行上加锁吗?我觉得应该也会,因为for update语句和lock in share mode语句的区别只是一个加的是读锁,一个加的是写锁吧。
也就是说会话A中的语句执行后会在扫描到的数据行上加读锁,以及数据行之间加上间隙锁。但是在这个表中并没有c=7的数据行,所以只会在已有的数据行上加读锁,与数据行之间加上间隙锁。
那么会话B中的语句会在哪些地方加锁呢?
select … for update;语句会在所有扫描到的数据行上加写锁,以及数据行之间加上间隙锁,但是文章里说会话B中的语句并不会被会话A中的语句阻塞,所以lock in share mode语句应该只会在满足条件的数据行上加读锁,而不是在所有扫描到的数据行上加读锁。
加间隙锁的目的是不让别的事务在这些间隙中插入新的数据行,而不是不让别的事务也在这些间隙加上锁。
在这里有一个新的概念,那就是临键锁,临键锁就是间隙锁加上行锁。一个间隙锁和它右边的行锁加起来,就构成了一个临键锁,所以以表t中的数据行为例,在select * from t for update;语句的过程中,就会在表t上加7个临键锁,分别是(-∞,0]临键锁,(0,5]临键锁,(5,10]临键锁,(10,15]临键锁,(15,20]临键锁,(20,25]临键锁,(25,+supremum]临键锁。
这个+supremum是什么呢?可以把+supremum理解为一个总是大于
我们通过间隙锁和临键锁解决了幻读的问题,但是这两个锁也带来了一些问题。
我们看下面的语句序列。
begin;
select * from t where id=N for update;
/*如果行不存在*/
insert into t values(N,N,N);
/*如果行存在*/
update t set d=N set id=N;
commit;
select … for update;语句也只锁满足条件的数据行,而不是把所有扫描的数据行都锁上。
这个语句序列存在什么问题呢?
我们看下面的并发场景。
如果有两个线程同时执行上面的语句序列,那么就有可能会出现死锁。
如果在会话A中不先执行select … for update;语句给所有d=5的数据行加上锁,其它的语句执行顺序不变会发生什么呢?在T1时刻会更新d=5的数据行,并且update语句的加锁规则和select … for update;语句的加锁规则是一样的,也就是说在T1时刻会话A会在所有d=5的数据行上加写锁,并且在数据行之间加上间隙锁,那么执行select … for update;语句在加锁上有什么意义呢?有必要在操作某个数据行之前先用select … for update;语句锁起来吗?
会话A中执行select … for update;语句,由于id=9的数据行不存在,所以只会加上(5,10)间隙锁,然后会话B中执行select … for update;语句,加的也是(5,10)间隙锁,会话B中试图插入一条id=9的数据行,但是被会话A加的间隙锁挡住了,会话A中也试图插入一条id=9的数据行,也被会话B加的间隙锁挡住了,会话A持有会话B需要的锁,会话B也持有会话A需要的锁,会话A和会话B都在等待对方释放自己需要的锁,那么会话A和会话B就出现了死锁。
然后Innodb的死锁检测机制会很快的发现这个死锁,然后回退死锁链条中的一个事务,释放它持有的锁,回退事务的外在表现就是在客户端中报错(Deadlock found)。
所以说,间隙锁是有缺点的,它的缺点就是会锁住更大的范围,在小范围的锁上能同时执行的线程当锁的范围变大时可能就不能同时执行了,原本不会发生死锁的时序现在会发生死锁了,能够同时执行的线程的数目就会变少,也就是并发度变低了。如果大范围锁是小范围锁的扩展,那么当锁是大范围的时候两个线程能够同时执行的话,锁是小范围的时候这两个线程也一定能够同时执行。
那么我们还有没有别的解决幻读问题的办法呢?
在可重复读隔离级别下才存在间隙锁,如果隔离级别是读提交级别,那么就