MySQL 锁详解

InnoDB 锁

数据库使用锁是为了支持更好的并发,提供数据的完整性和一致性。InnoDB是一个支持行锁的存储引擎,锁的类型有:共享锁(S)、排他锁(X)、意向共享(IS)、意向排他(IX)。为了提供更好的并发,
InnoDB提供了非锁定读:不需要等待访问行上的锁释放,读取行的一个快照。该方法是通过InnoDB的一个特性:MVCC来实现的。

MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。

在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。

在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:

  • 快照读:简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)

    • select * from table where ?;
  • 当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。

    • select * from table where ? lock in share mode;

    • select * from table where ? for update;

    • insert into table values (…);

    • update table set ? where ?;

    • delete from table where ?;

    所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。

  • 共享锁【S锁】
    又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。

  • 排他锁【X锁】
    又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。

为什么将 插入/更新/删除 操作,都归为当前读?可以看看下面这个 更新 操作,在数据库中的执行流程:


MySQL 锁详解_第1张图片

从图中,可以看到,一个Update操作的具体流程。当Update SQL被发给MySQL后,MySQL Server会根据where条件,读取第一条满足条件的记录,然后InnoDB引擎会将第一条记录返回,并加锁 (current read)。待MySQL Server收到这条加锁的记录之后,会再发起一个Update请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有满足条件的记录为止。因此,Update操作内部,就包含了一个当前读。同理,Delete操作也一样。Insert操作会稍微有些不同,简单来说,就是Insert操作可能会触发Unique Key的冲突检查,也会进行一个当前读。

MySQL/InnoDB定义的4种隔离级别:

Read Uncommited
可以读取未提交记录。此隔离级别,不会使用,忽略。

Read Committed (RC)
快照读:忽略,本文不考虑。
当前读:RC隔离级别保证对读取到的记录加锁 (记录锁),存在幻读现象。

Repeatable Read (RR)
快照读:忽略,本文不考虑。
当前读:RR隔离级别保证对读取到的记录加锁 (记录锁),同时保证对读取的范围加锁,新的满足查询条件的记录不能够插入 (间隙锁),不存在幻读现象。注意:这里的不存在幻读,是指使用select ... for update 在同一个事务中查询,不会出现两次不一样的结果

Serializable
从MVCC并发控制退化为基于锁的并发控制。不区别快照读与当前读,所有的读操作均为当前读,读加读锁 (S锁),写加写锁 (X锁)。
Serializable隔离级别下,读写冲突,因此并发度急剧下降,在MySQL/InnoDB下不建议使用。

上面说的当前读就是上面列出来的 select .. for update, update , delete, insert 等语句

加锁情况

我们分析一下 RR RC级别下配合不同索引情况的加锁情况
表goods 定义是 :
id: 主键
name: unique key
stock :无索引

组合一:id主键+RC

--------------------- SESSION 1 -------------------------
mysql> begin;
mysql> select * from goods ;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  1 | prod11 |    15 |
+----+--------+-------+
mysql> update goods set stock =20 where id =1 
# 这里已经锁住了id=1的记录, 因为上面说了update,delete, insert, select ... for update都会读当前读,会触发锁机制
# 所以用这几个任何一个命令都能锁住记录
mysql> select * from goods where id = 1 for update;
# 同样锁住id=1的记录

--------------------- SESSION 2 -------------------------
mysql> select * from goods where id=1;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  1 | prod11 |    15 |
+----+--------+-------+
1 row in set (0.00 sec)
# 默认的select 不会使用锁,它是快照读,不是当前读

mysql> select * from goods where id=1 for update;
# 这里会阻塞直到session 1事务结束

结论:id是主键时,此SQL只需要在id=1这条记录上加X锁即可。

组合二:id唯一索引+RC

--------------------- SESSION 1 -------------------------
mysql> begin;
mysql> select * from goods where name="prod12" for update;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  2 | prod12 |  1000 |
+----+--------+-------+

--------------------- SESSION 2 -------------------------
mysql> update goods set stock =20 where id=2;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql> update goods set stock =20 where name="prod12";
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
# 使用唯一索引的时候, 无论使用唯一索引name, 还是主键索引id 都不能读到当前读,这是因为唯一索引会把唯一索引和主键索引都加锁
MySQL 锁详解_第2张图片
medish (1).jpg

此组合中,name是unique索引,而主键是id列。此时,加锁的情况由于组合一有所不同。由于name是unique索引,因此delete语句会选择走name列的索引进行where条件的过滤,在找到name="prod12"的记录后,首先会将unique索引上的name="prod12"索引记录加上X锁,同时,会根据读取到的id列,回主键索引(聚簇索引),然后将聚簇索引上的id=2 对应的主键索引项加X锁。为什么聚簇索引上的记录也要加锁?试想一下,如果并发的一个SQL,是通过主键索引来更新:update goods set name= "prod30" where id=2; 此时,如果delete语句没有将主键索引上的记录加锁,那么并发的update就会感知不到delete语句的存在,违背了同一记录上的更新/删除需要串行执行的约束。
所以主键和唯一索引都要加X锁,防止别的update, delete 会使用主键来查询修改

结论 若name列是unique列,其上有unique索引。那么SQL需要加两个X锁,一个对应于name unique索引上的name="prod12"的记录,另一把锁对应于聚簇索引上的[id=2, name="prod"]的记录。

组合三:非唯一索引+RC

MySQL 锁详解_第3张图片

跟组合二差不多,与组合二唯一的区别在于,组合二最多只有一个满足等值查询的记录,而组合三会将所有满足查询条件的记录都加锁。结论 对应的所有满足SQL查询条件的记录,都会被加锁。同时,这些记录在主键索引上的记录,也会被加锁。

--------------------- SESSION 1 -------------------------
mysql> select * from goods where id=7
    -> ;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  7 | prod17 |   107 |
+----+--------+-------+
1 row in set (0.00 sec)

mysql> delete from goods where stock=107;
Query OK, 1 rows affected (0.00 sec)

--------------------- SESSION 2 -------------------------
mysql> select * from goods where stock=107 for update;
# 普通索引 被锁 阻塞
mysql> select * from goods where id=7 for update;
# 聚簇索引(主键索引被锁)
mysql> select * from goods where name=7 for update;
# 其他字段页被锁,因为主键索引被锁,整行被锁

组合四:id无索引+RC
stock去掉索引

--------------------- SESSION 1 -------------------------
mysql> begin;
mysql> select * from goods where stock=20 for update;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  5 | prod15 |    20 |
| 23 | prod21 |    20 |
+----+--------+-------+
# 使用stock 来查询,stock是没有索引

--------------------- SESSION 2 -------------------------
mysql> begin;
mysql> select * from goods where id=1 for update
mysql> select * from goods for update;
mysql> update goods set stock =20 where 1;

# 无论查什么当前读,更新记录,都被阻塞,说明整个表都被锁住了。
MySQL 锁详解_第4张图片
medish (3).jpg

由于stock列上没有索引,因此只能走聚簇索引,进行全部扫描。从图中可以看到,满足条件的记录有两条,但是,聚簇索引上所有的记录,都被加上了X锁。无论记录是否满足条件,全部被加上X锁。既不是加表锁,也不是在满足条件的记录上加行锁。

有人可能会问?为什么不是只在满足条件的记录上加锁呢?这是由于MySQL的实现决定的。如果一个条件无法通过索引快速过滤,那么存储引擎层面就会将所有记录加锁后返回,然后由MySQL Server层进行过滤。因此也就把所有的记录,都锁上了。

注:在实际的实现中,MySQL有一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁 (违背了2PL的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。

结论:若stock列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL的约束。

注意!!!! 这里说的对不满足条件记录会有 加锁\放锁的动作,但是!!实际操作中,还是所有记录都被锁住了,根本没有放锁?这是为什么??

组合五:id主键+RR
这个跟 id主键+RC组合效果一样,都是锁住被查询出来的记录

组合六: id唯一索引+RR
这个跟 id唯一索引+RC组合效果一样,都是将唯一索引和聚簇索引的记录锁住

组合七:id非唯一索引+RR
还记得前面提到的MySQL的四种隔离级别的区别吗?RC隔离级别允许幻读,而RR隔离级别,不允许存在幻读。但是在组合五、组合六中,加锁行为又是与RC下的加锁行为完全一致。那么RR隔离级别下,如何防止幻读呢?问题的答案,就在组合七中揭晓。
我们先看看如果是级别RC,出现的幻读情况

--------------------- SESSION 1 -------------------------
# RC级别
mysql> begin;
mysql> select * from goods where stock=15 for update;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
| 21 | prod20 |    15 |
| 22 | prod22 |    15 |
+----+--------+-------+

--------------------- SESSION 2 -------------------------
mysql> begin;
mysql> insert into goods values(24, 'prod24', 15);
# 这里依然可以插入stock=15的记录,因为session 1直接对21 22两条stock=15记录加锁了
mysql> commit;

--------------------- SESSION 1 -------------------------
mysql> select * from goods where stock=15 for update;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
| 21 | prod20 |    15 |
| 22 | prod22 |    15 |
| 24 | prod24 |    15 |
+----+--------+-------+
# 多了session 2提交的数据,同一个事务两次select for update 居然不同了。 这就是幻读!!

再看看 RR级别会不会出现幻读

--------------------- SESSION 1 -------------------------
# RR级别
mysql> begin;
mysql> select * from goods where stock=15 for update;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
| 21 | prod20 |    15 |
| 22 | prod22 |    15 |
+----+--------+-------+

--------------------- SESSION 2 -------------------------
mysql> begin;
mysql> insert into goods values(24, 'prod24', 15);
#  这里跟RC级别不一样了, stock=15的24记录根本插不进去!
# 这就是为什么RR级别不会出现幻读的原因,因为不能给其他事务插足
MySQL 锁详解_第5张图片
medish (4).jpg

RR与RC 幻读总结

RR隔离级别其实这个多出来的GAP锁,相对于RC隔离级别,不会出现幻读的关键。确实,GAP锁锁住的位置,也不是记录本身,而是两条记录之间的GAP。所谓幻读,就是同一个事务,连续做两次当前读 (例如:select * from goods where stock=15 for update;),那么这两次当前读返回的是完全相同的记录 (记录数量一致,记录本身也一致),第二次的当前读,不会比第一次返回更多的记录 (幻象)。

如何保证两次当前读返回一致的记录,那就需要在第一次当前读与第二次当前读之间,其他的事务不会插入新的满足条件的记录并提交。为了实现这个功能,GAP锁应运而生。

mysql> select * from goods order by stock;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  7 | prod17 |    10 |
|  8 | prod18 |    10 |
| 22 | prod22 |    15 |
| 21 | prod20 |    15 |
| 23 | prod21 |    20 |
|  5 | prod15 |    20 |
| 10 | pro10  |    50 
+----+--------+-------+

上面的记录所示,有哪些位置可以插入新的满足条件的项 stock= 15,考虑到B+树索引的有序性,stock索引的存储一定是有序的,满足条件的项一定是连续存放的。
所以stock在[10, 15]之间, [15, 20]之间都是可以被其他事务插入stock=15的记录的。因此要想杜绝幻读,这个gap锁也就是间隙锁,必须锁住10-15, 15-20之间的记录。
我们继续看session 2, 上面示例看到不能插入stock=15的记录,其实10-15, 15-20之间都是不可以插入的

--------------------- SESSION 2 -------------------------
mysql> insert into goods values('', 'prod24', 11);
mysql> insert into goods values('', 'prod24', 13);
mysql> insert into goods values('', 'prod24', 18);
mysql> insert into goods values('', 'prod24', 19);
# 在10-15, 15-20之间的记录都不能插入,因为他们都有可能被放入stock=15的记录

mysql> insert into goods values('', 'prod24', 21);
Query OK, 1 row affected, 1 warning (0.00 sec)

mysql> insert into goods values('', 'prod24', 8);
Query OK, 1 row affected, 1 warning (0.00 sec)
# 超出这个范围的是可以插入的。

如果我们查的stock=14没有数据,那么会不会也有gap锁?答案是:有的。
我发现如果索引是主键也会有这个gap锁,当然查询的是一个范围

--------------------- SESSION 1 -------------------------
mysql> select * from goods where id > 16 for update;
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
| 21 | prod20 |    15 |
| 22 | prod22 |    15 |
| 23 | prod21 |    20 |
+----+--------+-------+

--------------------- SESSION 2 -------------------------
mysql> insert into goods values(9, 'prod24', 11);
ERROR 1062 (23000): Duplicate entry '9' for key 'PRIMARY'
mysql> insert into goods values(12, 'prod24', '');
# 发现>16的 还有10-15这个区间也被锁住了

总结 只要是范围查询,都会有gap锁。

组合八:id无索引+RR

MySQL 锁详解_第6张图片
medish (5).jpg

如图,这是一个很恐怖的现象。首先,聚簇索引上的所有记录,都被加上了X锁。其次,聚簇索引每条记录间的间隙(GAP),也同时被加上了GAP锁。这个示例表,只有6条记录,一共需要6个记录锁,7个GAP锁。试想,如果表上有1000万条记录呢?

在这种情况下,这个表上,除了不加锁的快照度,其他任何加锁的并发SQL,均不能执行,不能更新,不能删除,不能插入,全表被锁死。

当然,跟组合四类似,这个情况下,MySQL也做了一些优化,就是所谓的semi-consistent read。semi-consistent read开启的情况下,对于不满足查询条件的记录,MySQL会提前放锁。针对上面的这个用例,就是除了记录[d,10],[g,10]之外,所有的记录锁都会被释放,同时不加GAP锁。semi-consistent read如何触发:要么是read committed隔离级别;要么是Repeatable Read隔离级别,同时设置了 innodb_locks_unsafe_for_binlog 参数

结论:在Repeatable Read隔离级别下,如果进行全表扫描的当前读,那么会锁上表中的所有记录,同时会锁上聚簇索引内的所有GAP,杜绝所有的并发 更新/删除/插入 操作。当然,也可以通过触发semi-consistent read,来缓解加锁开销与并发影响,但是semi-consistent read本身也会带来其他问题,不建议使用。

组合九:Serializable
Serializable隔离级别,影响的是SQL1:select * from t1 where id = 10; 这条SQL,在RC,RR隔离级别下,都是快照读,不加锁。但是在Serializable隔离级别,SQL1会加读锁,也就是说快照读不复存在,MVCC并发控制降级为Lock-Based CC。

结论:在MySQL/InnoDB中,所谓的读不加锁,并不适用于所有的情况,而是隔离级别相关的。Serializable隔离级别,读不加锁就不再成立,所有的读操作,都是当前读。

锁总结

  • 在MVCC(基于多版本的并发控制协议)并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。
  • 2PL (二阶段锁):Two-Phase Locking。说的是锁操作分为两个阶段:加锁阶段与解锁阶段,并且保证加锁阶段与解锁阶段不相交。
  • 主键索引(聚簇索引) id主键+RC :只会在匹配的主键上加X锁
  • id唯一索引+RC: id是unique索引,而主键是name列。此时,由于id是unique索引,因此delete语句会选择走id列的索引进行where条件的过滤,在找到id=10的记录后,首先会将unique索引上的id=10索引记录加上X锁(注意:这里是给索引加锁,innodb的二级索引只会带上主键索引数据,其他数据需要回行查询),同时,会根据读取到的name列,回主键索引(聚簇索引),然后将聚簇索引上的name = ‘d’ 对应的主键索引项加X锁(注意:由于聚簇索引本身就是带上行数据,所以要真正锁这个聚簇索引才能真正锁行!)。为什么聚簇索引上的记录也要加锁?试想一下,如果并发的一个SQL,是通过主键索引来更新:update t1 set id = 100 where name = ‘d’; 此时,如果delete语句没有将主键索引上的记录加锁,那么并发的update就会感知不到delete语句的存在,违背了同一记录上的更新/删除需要串行执行的约束。删除需要串行执行的约束**
  • id非唯一索引+RC :也是会同时锁匹配的索引和指向的主键索引,跟上面那个原理一样,只不过这个非唯一索引匹配多条记录。(同样道理,锁住普通索引后,还得锁聚簇索引才行
  • id无索引+RC:若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL的约束。

  • id主键+RR: 同 id主键+RC,因为他们都是精确到记录,所以就加在主键索引上就可以了
  • id唯一索引+RR 同id唯一索引+RC 两个X锁,id唯一索引满足条件的记录上一个,对应的聚簇索引上的记录一个。
  • id非唯一索引+RR 幻读的重要分界点
    • RC级别下,session1查询的select .. where id=7 for update, (非唯一索引),查出有2条记录, session2事务插入一个id=7的记录(为什么可以插入?:因为session1的 where id=7 for update, 锁住了普通索引索引7,和主键索引比如a 和 b , 那么此时插入id=7的数据时可以的,因为新插入的数据,首先新的主键,没有被锁, 而且索引7的两条记录是被锁住,但是新加的记录没有被锁。所以可以继续插入,session2 这时commit事务,session1 再次查询select .. where id=7 for update, 就会出现3条记录,这就是RC级别的幻读;
    • RR级别下,session1查询的select .. where id=7 for update, (非唯一索引),查出有2条记录,session2事务插入一个id=7的记录,这里就是跟RC级别的最大差别,因为这时的插入时被阻塞的,不能插进去的!这就使得幻读不能出现,因为根本不允许插入。为什么?因为gap锁,gap锁在索引id(非唯一索引)的前后都加了锁,不允许这中间再出现可能的数据

死锁原理与分析

--------------------- SESSION 1 -------------------------
mysql> select * from goods where id =1 for update;
--------------------- SESSION 2 -------------------------
mysql> update  goods set stock=120 where id=4;

# 两个session 各自维护一个锁

--------------------- SESSION 1 -------------------------
mysql>  select * from goods where id =4 for update;
# 阻塞中,因为锁再session2  
--------------------- SESSION 2 -------------------------
update  goods set stock=120 where id=1;
# 死锁出现

session1 提示死锁:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
sesseion2 执行成功

Deadlock found when trying to get lock; try restarting transaction 说明innodb会检测死锁

死锁的发生与否,并不在于事务中有多少条SQL语句,死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。而使用本文上面提到的,分析MySQL每条SQL语句的加锁规则,分析出每条语句的加锁顺序,然后检查多个并发SQL间是否存在以相反的顺序加锁的情况,就可以分析出各种潜在的死锁情况,也可以分析出线上死锁发生的原因。

参考

重点好文:MySQL 加锁处理分析
Innodb锁机制:Next-Key Lock 浅谈
MySQL 四种事务隔离级的说明
Innodb中的事务隔离级别和锁的关系
MySQL中的锁(表锁、行锁)
mysql、innodb和加锁分析

你可能感兴趣的:(MySQL 锁详解)