1、背景
在工作过程中,时不时会有一些开发童鞋咨询MySQL/InnoDB的加锁分析处理。对于一条SQL语句,InnoDB会对它加什么样的锁?线上使用会带来什么风险?对此,我来做相应的介绍,主要是介绍一种思路,根据该思路,拿到任何一条SQL,都能分析出这条SQL会加什么锁?
2、InnoDB的MVCC
MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议-MVCC(Multi-Version Concurrency Control)。它可以在MySQL实现事务隔离的特性下,尽量提高并发处理能力。简单而言,就是读不加锁、读写不冲突。在读多写少的OLTP应用中,读写不冲突显得非常重要,这极大的增加了系统的并发性能。
3、当前读、快照读
在InnoDB的MVCC并发控制中 ,读操作可以分成两类:快照读(snapshot read)、当前读(current read)。究竟什么是当前读、什么是快照读呢?
当前读,也就是读取的是记录的最新版本,并且当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
快照读,也就是读取的是可见的版本(有可能是历史版本),不用加锁。
那对于MySQL的InnoDB存储引擎来说,哪些操作是快照读,哪些操作是当前读呢?例如:
快照读:简单的select操作,不加锁。
select * from table_name where ?;
当前读:比较特殊的”读“操作,如insert/update/delete,需要加锁
select * from table where ? lock in shard mode;
select * from table where ? for update;
insert into table values(...);
update table set ? where ?;
delete from table where ?;
对于以上sql,都属于当前读,它读取的是记录的最新版本。读取之后,还需要保证其他并发事务不能修改当前记录。第一条sql,加的是S锁(共享读锁),其他的sql加的都是X锁(排它锁)。
4、聚簇索引(Cluster Index)
InnoDB存储引擎的数据组织方式,是聚簇索引表:完整的行记录,存储在主键索引中,通过主键索引,就可以获取记录的所有列值。
5、事务隔离级别(Isolation Level)
隔离级别:Isolation Level,是关系型数据库的一个关键特性。有4种隔离级别:Read Uncommited(读未提交)、Read Committed(读提交)、Repeatable Read(可重复读)、Serializable(串行化)。对于这4种隔离级别,这里作一个简单的介绍(如果想获得更详细的介绍,请自行google或者查看官方文档)。
Read Uncommited
可以读取到其他事务还没提交的记录,这种隔离级别,一般不会使用。
Read Committed
大多数数据库系统使用Read Committed作为默认隔离级别(Oracle也是,但MySQL不是)。只能读取到其他事务已经提交的数据,该隔离级别下,会存在幻读的现象。
Repeatable Read
这是MySQL的默认事务隔离级别,它可以确保同一个事务的多个并发读取数据时,看到的还是同样的数据行。该隔离级别下,使用Next Key Lock(Gap Lock+Record Lock)可以防止幻读的产生。
Serializable
在Serializable隔离级别下,读写冲突,不区分快照读和当前读,所有的读操作均为当前读,读加S锁,写加X锁。读写冲突,会导致并发度急剧下降,一般不建议使用。
6、一个简单的SQL加锁分析
对一些MySQL的知识作了一个简单的介绍之后,这里我选了一个简单的例子来阐述。
SQL1:select * from table_1 where id=100;
SQL2:delete from table_1 where id=100;
针对这两个SQL,你能分析出InnoDB会加什么锁吗?
其实仅仅拿出两个SQL,是没法对其进行分析的,为啥?因为已知条件并不足够,还缺少一些必要的信息,根据完整的信息,我们才能正确的对其进行分析。那还缺少哪些必要的信息呢?
1、id列是不是主键?
2、当前系统的隔离级别是什么?
3、id列如果不是主键,那么id列是否索引列?
4、id列如果是二级索引,那么这个索引是否唯一索引?
5、两个SQL的explain执行计划是什么?走索引呢,还是全表扫描。
对此,可以对这些条件进行组合,逐一分析每种组合下,对应的SQL会加哪些锁?
组合一:id列是主键,Read Committed隔离级别
组合二:id列是二级唯一索引,Read Committed隔离级别
组合三:id列是二级非唯一索引,Read Committed隔离级别
组合四:id列上没有索引,Read Committed隔离级别
组合五:id列是主键,Read Repeatable隔离级别
组合六:id列是二级唯一索引,Read Repeatable隔离级别
组合七:id列是二级非唯一索引,Read Repeatable隔离级别
组合八:id列上没有索引,Read Repeatable隔离级别
组合九:Serializable隔离级别
......
组合条件还是比较多的。接下来根据哪种组合,我们就可以分析出SQL需要加什么锁。
组合一:id列是主键,Read Committed隔离级别
create table T1 (id int not null primary key,name char(5) not null);
select * from T1;
+----+------+
| id | name |
+----+------+
| 1 | aa |
| 4 | cc |
| 7 | bb |
| 10 | aa |
| 20 | dd |
| 30 | bb |
+----+------+
该组合下:
SQL1:select操作不加锁,采用快照读。
SQL2: delete from T1 where id = 10;由于id列是主键,并且delete操作是走
主键索引扫描,故采用的是Record Lock(记录锁),只需要将id=10的记录加上
X锁。
组合二:id列是二级唯一索引,Read Committed隔离级别
create table T2 (name char(5) not null primary key,id int not null unique key);
select * from T2;
+------+----+
| name | id |
+------+----+
| ff | 1 |
| zz | 2 |
| bb | 3 |
| aa | 5 |
| cc | 6 |
| dd | 10 |
+------+----+
该组合下:
SQL1: select操作不加锁,采用快照读。
SQL2: delete from T2 where id = 10;由于id是唯一索引,故delete语句会走id列的索引进行where条件的过滤,找到id=10的记录,然后将id=10的索引记录加上Record Lock(X锁)。同时,还会对相应的name值,将name='dd'加上X锁。为什么还要对name='dd'加上X锁呢?
试想一个场景:
如果此时有一个并发的SQL,是通过主键索引来更新的:update T2 set id=100 where name='dd',如果不对主键索引的记录加锁,那么很显然,违背了同一记录(id=10)的更新/删除操作需要串行执行的约束。
组合三:id列是二级非唯一索引,Read Committed隔离级别
create table T3 (name char(5) not null primary key,id int not null);
alter table T3 add key id
(id
);
select * from T3;
+------+----+
| name | id |
+------+----+
| zz | 2 |
| cc | 6 |
| bb | 10 |
| dd | 10 |
| ff | 11 |
| aa | 15 |
+------+----+
该组合下:
SQL1: select操作不加锁,采用快照读。
SQL2: delete from T3 where id = 10;id列的约束降低了,id列不再唯一,只是一个
普通索引。delete语句仍然会通过id列上的索引进行where条件的过滤,对于id列索引,
会对id=10的索引记录都加上X锁,同时还会根据读取到的name列,回主键索引(聚簇索引),然后将聚簇索引上的完整行记录[name='bb',id=10]、[name='dd',id=10]加锁。和组合二的唯一区别在于,组合二最多只有一个满足查询条件的记录,而组合三可能有多个满足查询条件的记录。
组合四:id列上没有索引,Read Committed隔离级别
create table T4 (name varchar(5) not null primary key,id int not null);
select * from T4;
+------+----+
| name | id |
+------+----+
| aa | 5 |
| bb | 3 |
| dd | 10 |
| ff | 2 |
| gg | 10 |
| zz | 9 |
+------+----+
该组合下:
SQL1: select操作不加锁,采用快照读。
SQL2: delete from T4 where id = 10;由于id列上没有索引,故delete语句只能走
聚簇索引,进行全表扫描。满足删除条件的记录有两条,但由于是全表扫描,故所有
记录都被加上了X锁。
为什么不是只在满足条件的记录上加锁呢?这是由于MySQL的实现决定的。如果一个条
件无法通过索引快速过滤,那么存储引擎层面会将所有记录加锁后返回,然后由MySQL
Server 层进行过滤。故会把所有的记录,都锁上了。
结论:若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤记录是由
MySQL Server层面进行的,因此每条记录,无论是否满足条件,都会被存储引擎加
上X锁。
组合五:id列是主键,Read Repeatable隔离级别
该组合下:
SQL1: select操作不加锁,采用快照读。
SQL2:delete from T5 where id = 10;不管name列是否唯一索引,还是普通索引,还是
非索引列,由于id列是主键列(聚簇索引列),并且一定会走主键索引进行扫描,故只会
对满足条件的记录加X锁。和组合一”组合一:id列是主键,Read Committed隔离级别“
加锁实现一样。
组合六:id列是二级唯一索引,Read Repeatable隔离级别
该组合下:
SQL1: select操作不加锁,采用快照读。
SQL2:delete from T6 where id = 10;id列是唯一索引,故会对所有满足条件的记录
加上X锁,并且会对相应的name列(簇聚索引)的记录加上X锁。为什么会对主键索引
列还要加一把锁呢?同样的道理,如果不对聚簇索引的记录加锁,在一个SQL并发的
环境下,如果有一个SQL是通过主键索引来更新的:update T6 set id=50 where name='dd';
这样则违背了同一条记录的更新/删除需要串行执行的约束。
组合七:id列是二级非唯一索引,Read Repeatable隔离级别
create table T7(name char(5) not null primary key,id int not null,KEY(id));
select * from T7;
+------+----+
| name | id |
+------+----+
| zz | 2 |
| cc | 6 |
| bb | 10 |
| dd | 10 |
| ff | 11 |
| aa | 15 |
+------+----+
在Read Repeatable隔离级别下,不仅仅存在Record Lock,还存在Gap Lock(间隙锁)。由于Read Repeatable存在Gap Lock,故不存在幻读。Read Committed隔离级别不存在Gap Lock,其只有Record Lock,所以存在幻读的可能。什么是幻读?该组合下,又如何防止幻读呢?
什么是幻读?打个比方,在事务并发的环境下,在同一个事务中,连续两次SQL:select * from T7 where id=10 for update;那么如果这两次返回的是相同的记录(记录数量一致,记录本身也一致),第二次的SQL不会比第一次返回不同的记录(幻读)。
为了保证两次返回一致的记录,那么就需要在第一次SQL和第二次SQL之间,其他事务不会插入新的满足条件的记录并且提交。通过Gap Lock(间隙锁),可以实现。
Key(id)
id 2 6 10 10 11 15
name zz cc bb dd ff aa
哪些位置可以插入新的满足条件的的项(id=10),由于B+树索引的有序性,满足条件的
项一定是连续存放的。在记录[6,c]之前,不会插入id=10的记录;[6,cc]与[10,bb]
之间可以插入[10,xx];[10,bb]与[10,dd]之间可以插入[10,aa]等;[10,dd]和[11,ff]
之间可以插入满足条件的[10,ee],[10,zz]等。但是[11,ff]之后则不会插入满足条件
的记录。故为了防止幻读的产生,使用Gap Lock将[6,cc]和[10,bb]之间、[10,bb]和
[10,dd]之间,[10,dd]和[11,ff]之间不会插入新的满足条件的记录。这样,两次SQL
读取到的则是相同的记录,不会出现幻读了。
既然在Read Repeatable隔离级别下,需要靠Gap Lock来防止幻读,那么组合五、组合
六也是Read Repeatable隔离级别,为什么却不需要加Gap Lock呢?原因在于:组合五
id是主键、组合六,id是unique 唯一键,都能够保证唯一性,不可能再插入相同的id
值,因此也就避免了Gap Lock的使用。
对此,我抛出一个问题,如果select * from T7 where id=10 for update;第一次查询,
返回空,没有满足条件的记录,那么Gap Lock是否还会被加上。为此,我实践了一把,
事实证明,第一次查询实际返回为空,仍然会加上Gap Lock。如果不加上Gap Lock,
那么可能的情况是另一个事务可以插入满足条件的项,比如[10,ww],那么第二次查询,
则不会返回空,两次查询的结果不一致,产生幻读。
结论:Read Repeatable隔离级别下,id列上有一个二级非唯一索引,
delete from T7 where id = 10;会对满足条件的记录加上X锁,同时还会在相应的间隙上加上间隙锁,然后对相应的聚簇索引的记录加X锁。
组合八:id列上没有索引,Read Repeatable隔离级别
create table T8 (name char(5) not null primary key,id int not null);
select * from T8;
+------+----+
| name | id |
+------+----+
| aa | 5 |
| bb | 3 |
| dd | 10 |
| ff | 2 |
| gg | 10 |
| zz | 9 |
SQL1: select操作不加锁,采用快照读。
SQL2: delete from T7 where id = 10;由于id列无索引,只能进行全表扫描。
聚簇索引上的所有记录都都被加上x锁,并且聚簇索引每条记录间的间隙(Gap)
会被加上Gap Lock。
这种情况下,除了快照读不会加锁之外,其他任何操作都会被堵住,这是非常
糟糕的一种状况。
组合九:Serializable隔离级别
SQL1:所有读操作都会加上S锁,不存在快照读,所有读操作都被视为当前读。
SQL2:delete from T9 where id = 10;它的表现和在Read Repeatable隔离级别下,
加的锁是一样的。同样存在Gap Lock,不存在幻读现象。