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,不存在幻读现象。