一、死锁示例

考虑下面一个MySQL死锁的示例:

有如下一张表:


CREATE TABLE `test` (
  `id` int(20) NOT NULL,
  `name` varchar(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
   
   
  • 1
  • 2
  • 3
  • 4
  • 5

表中有如下数据:

mysql> SELECT * FROM test;
+----+------+
| id | name |
+----+------+
|  1 | 1    |
|  5 | 5    |
| 10 | 10   |
| 15 | 15   |
| 20 | 20   |
| 25 | 25   |
+----+------+
6 rows in set (0.00 sec)
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

当数据库的隔离级别为Repeatable Read或Serializable时,我们来看这样的两个并发事务(场景一):

session1

session2

begin;

begin;

select * from test where id = 12 for update;

select * from test where id = 13 for update;

insert into test(id, name) values(12, "test1");

锁等待中

insert into test(id, name) values(13, "test2");

锁等待解除

死锁,session 2的事务被回滚

上面两个并发事务一定会发生死锁(这里之所以限定RR和Serializable两个隔离级别,是因为只有这两个级别下才会有间隙锁/临键锁,而这是导致死锁的根本原因,后面会详细分析)。

我们再来看另外一个并发场景(场景二):

session1

session2

begin;

begin;

select * from test where id = 12 for update;

select * from test where id = 16 for update;

insert into test(id, name) values(12, "test1");

commit;

insert into test(id, name) values(16, "test2");

commit;

在这个并发场景下,两个事务均能成功提交,而不会有死锁。

在上面的示例中,我们发现,select ... for update虽然可以用于解决数据库的并发操作,但在实际项目中却需要慎重使用,原因是当查询条件对应的记录不存在时,很容易造成死锁。而造成死锁的原因和MySQL的锁机制有关。本文将详细介绍常见的七种锁机制,了解了这些锁机制之后就能理解造成场景一死锁的根本原因以及场景一和场景二差异的原因。

二、MySQL的八种锁

  • 行锁(Record Locks)

  • 间隙锁(Gap Locks)

  • 临键锁(Next-key Locks)

  • 共享锁/排他锁(Shared and Exclusive Locks)

  • 意向共享锁/意向排他锁(Intention Shared and Exclusive Locks)

  • 插入意向锁(Insert Intention Locks)

  • 自增锁(Auto-inc Locks)

    实际上,MySQL官网中还提到了一种预测锁,这种锁主要用于存储了空间数据的空间索引,本文暂不讨论。

1、行锁

这MySQL的官方文档中有以下描述:

A record lock is a lock on an index record. Record locks always lock index records, even if a table is defined with no indexes. For such cases, InnoDB creates a hidden clustered index and uses this index for record locking.

这句话说明行锁一定是作用在索引上的。

2、间隙锁

在MySQL的官方文档中有以下描述:

A gap lock is a lock on a gap between index records, or a lock on the gap before the first or after the last index record。

这句话表明间隙锁一定是开区间,比如(3,5)或者。在MySQL官网上还有一段非常关键的描述:

Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.

这段话表明间隙锁在本质上是不区分共享间隙锁或互斥间隙锁的,而且间隙锁是不互斥的,即两个事务可以同时持有包含共同间隙的间隙锁。这里的共同间隙包括两种场景:其一是两个间隙锁的间隙区间完全一样;其二是一个间隙锁包含的间隙区间是另一个间隙锁包含间隙区间子集。间隙锁本质上是用于阻止其他事务在该间隙内插入新记录,而自身事务是允许在该间隙内插入数据的。也就是说间隙锁的应用场景包括并发读取、并发更新、并发删除和并发插入

在MySQL官网上关于间隙锁还有一段重要描述:

Gap locking can be disabled explicitly. This occurs if you change the transaction isolation level to READ COMMITTED. Under these circumstances, gap locking is disabled for searches and index scans and is used only for foreign-key constraint checking and duplicate-key checking.

这段话表明,在RU和RC两种隔离级别下,即使你使用select ... in share mode或select ... for update,也无法防止幻读(读后写的场景)。因为这两种隔离级别下只会有行锁,而不会有间隙锁。这也是为什么示例中要规定隔离级别为RR的原因。

3、临键锁

在MySQL的官方文档中有以下描述:

A next-key lock is a combination of a record lock on the index record and a gap lock on the gap before the index record.

这句话表明临键锁是行锁+间隙锁,即临键锁是是一个左开右闭的区间,比如(3,5]。

在MySQL的官方文档中还有以下重要描述:

By default, InnoDB operates in REPEATABLE READ transaction isolation level. In this case, InnoDB uses next-key locks for searches and index scans, which prevents phantom rows.

个人觉得这段话描述得不够好,很容易引起误解。这里更正如下:InnoDB的默认事务隔离级别是RR,在这种级别下,如果你使用select ... in share mode或者select ... for update语句,那么InnoDB会使用临键锁,因而可以防止幻读;但即使你的隔离级别是RR,如果你这是使用普通的select语句,那么InnoDB将是快照读,不会使用任何锁,因而还是无法防止幻读

4、共享锁/排他锁

在MySQL的官方文档中有以下描述:

InnoDB implements standard row-level locking where there are two types of locks, shared (S) locks and exclusive (X) locks

  • A shared (S) lock permits the transaction that holds the lock to read a row.

  • An exclusive (X) lock permits the transaction that holds the lock to update or delete a row.

这段话明确说名了共享锁/排他锁都只是行锁,与间隙锁无关,这一点很重要,后面还会强调这一点。其中共享锁是一个事务并发读取某一行记录所需要持有的锁,比如select ... in share mode;排他锁是一个事务并发更新或删除某一行记录所需要持有的锁,比如select ... for update。

不过这里需要重点说明的是,尽管共享锁/排他锁是行锁,与间隙锁无关,但一个事务在请求共享锁/排他锁时,获取到的结果却可能是行锁,也可能是间隙锁,也可能是临键锁,这取决于数据库的隔离级别以及查询的数据是否存在。关于这一点,后面分析场景一和场景二的时候还会提到。

5、意向共享锁/意向排他锁

在MySQL的官方文档中有以下描述:

Intention locks are table-level locks that indicate which type of lock (shared or exclusive) a transaction requires later for a row in a table。

The intention locking protocol is as follows:

  • Before a transaction can acquire a shared lock on a row in a table, it must first acquire an IS lock or stronger on the table.

  • Before a transaction can acquire an exclusive lock on a row in a table, it must first acquire an IX lock on the table.

这段话说明意向共享锁/意向排他锁属于表锁,且取得意向共享锁/意向排他锁是取得共享锁/排他锁的前置条件

共享锁/排他锁与意向共享锁/意向排他锁的兼容性关系:

X

IX

S

IS

X

互斥

互斥

互斥

互斥

IX

互斥

兼容

互斥

兼容

S

互斥

互斥

兼容

兼容

IS

互斥

兼容

兼容

兼容

这里需要重点关注的是IX锁和IX锁是相互兼容的,这是导致上面场景一发生死锁的前置条件,后面会对死锁原因进行详细分析。

6、插入意向锁(IIX)

在MySQL的官方文档中有以下重要描述:

An insert intention lock is a type of gap lock set by INSERT operations prior to row insertion. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to insert values of 5 and 6, respectively, each lock the gap between 4 and 7 with insert intention locks prior to obtaining the exclusive lock on the inserted row, but do not block each other because the rows are nonconflicting.

这段话表明尽管插入意向锁是一种特殊的间隙锁,但不同于间隙锁的是,该锁只用于并发插入操作。如果说间隙锁锁住的是一个区间,那么插入意向锁锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。与间隙锁的另一个非常重要的差别是:尽管插入意向锁也属于间隙锁,但两个事务却不能在同一时间内一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。这里我们再回顾一下共享锁和排他锁:共享锁用于读取操作,而排他锁是用于更新删除操作。也就是说插入意向锁、共享锁和排他锁涵盖了常用的增删改查四个动作。

7、示例分析

到此为止,我们介绍了MySQL常用的七种锁的前六种,理解了这六种锁之后,才能很好地分析和理解开头给出的两个场景。我们先来分析场景一:

session1

session2

begin;

begin;

select * from test where id = 12 for update;

先请求IX锁并成功获取

再请求X锁,但因行记录不存在,故得到的是间隙锁(10,15)

select * from test where id = 13 for update;

先请求IX锁并成功获取

再请求X锁,但因行记录不存在,故得到的是间隙锁(10,15)

insert into test(id, name) values(12, "test1");

请求插入意向锁(12),因事务二已有间隙锁,请求只能等待

锁等待中

insert into test(id, name) values(13, "test2");

请求插入意向锁(13),因事务一已有间隙锁,请求只能等待

锁等待解除

死锁,session 2的事务被回滚

在场景一中,因为IX锁是表锁IX锁之间是兼容的,因而事务一和事务二都能同时获取到IX锁和间隙锁。另外,需要说明的是,因为我们的隔离级别是RR,且在请求X锁的时候,查询的对应记录都不存在,因而返回的都是间隙锁。接着事务一请求插入意向锁,这时发现事务二已经获取了一个区间间隙锁,而且事务一请求的插入点在事务二的间隙锁区间内,因而只能等待事务二释放间隙锁。这个时候事务二也请求插入意向锁,该插入点同样位于事务一已经获取的间隙锁的区间内,因而也不能获取成功,不过这个时候,MySQL已经检查到了死锁,于是事务二被回滚,事务一提交成功。

分析并理解了场景一,那场景二理解起来就会简单多了:

session1

session2

begin;

begin;

select * from test where id = 12 for update;

先请求IX锁并成功获取

再请求X锁,但因行记录不存在,故得到的是间隙锁(10,15)

select * from test where id = 16 for update;

先请求IX锁并成功获取

再请求X锁,但因行记录不存在,故得到的是间隙锁(15,20)

insert into test(id, name) values(12, "test1");

请求插入意向锁(12),获取成功

commit;

insert into test(id, name) values(16, "test2");

请求插入意向锁(16),获取成功

.commit;

场景二中,两个间隙锁没有交集,而各自获取的插入意向锁也不是同一个点,因而都能执行成功。

8、自增锁

最后,我们再来介绍下自增锁。在MySQL的官方文档中有以下描述:

An AUTO-INC lock is a special table-level lock taken by transactions inserting into tables with AUTO_INCREMENT columns.The innodb_autoinc_lock_mode configuration option controls the algorithm used for auto-increment locking. It allows you to choose how to trade off between predictable sequences of auto-increment values and maximum concurrency for insert operations.

这段话表明自增锁是一种特殊的表级锁,主要用于事务中插入自增字段,也就是我们最常用的自增主键id。通过innodb_autoinc_lock_mode参数可以设置自增主键的生成策略。为了便于介绍innodb_autoinc_lock_mode参数,我们先将需要用到自增锁的Insert语句进行分类:

1)Insert语句分类

  1. “INSERT-like” statements(类INSERT语句) (这种语句实际上包含了下面的2、3、4)

所有可以向表中增加行的语句,包括INSERT, INSERT ... SELECT, REPLACE, REPLACE ... SELECT, and LOAD DATA。包括“simple-inserts”, “bulk-inserts”, and “mixed-mode” inserts.

2. “Simple inserts

可以预先确定要插入的行数(当语句被初始处理时)的语句。 这包括没有嵌套子查询的单行和多行INSERT和REPLACE语句,但不包括INSERT ... ON DUPLICATE KEY UPDATE。

3. “Bulk inserts

事先不知道要插入的行数(和所需自动递增值的数量)的语句。 这包括INSERT ... SELECT,REPLACE ... SELECT和LOAD DATA语句,但不包括纯INSERT。 InnoDB在处理每行时一次为AUTO_INCREMENT列分配一个新值。

4. “Mixed-mode inserts

这些是“Simple inserts”语句但是指定一些(但不是全部)新行的自动递增值。 示例如下,其中c1是表t1的AUTO_INCREMENT列:

INSERT INTO t1 (c1,c2) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');

另一种类型的“Mixed-mode inserts”是INSERT ... ON DUPLICATE KEY UPDATE,其在最坏的情况下实际上是INSERT语句随后又跟了一个UPDATE,其中AUTO_INCREMENT列的分配值不一定会在 UPDATE 阶段使用。

2)InnoDB AUTO_INCREMENT锁定模式分类

  1. innodb_autoinc_lock_mode = 0 (“traditional” lock mode)

    这种锁定模式提供了在MySQL 5.1中引入innodb_autoinc_lock_mode配置参数之前存在的相同行为。传统的锁定模式选项用于向后兼容性,性能测试以及解决“Mixed-mode inserts”的问题,因为语义上可能存在差异。

    在此锁定模式下,所有“INSERT-like”语句获得一个特殊的表级AUTO-INC锁,用于插入具有AUTO_INCREMENT列的表。此锁定通常保持到语句结束(不是事务结束),以确保为给定的INSERT语句序列以可预测和可重复的顺序分配自动递增值,并确保自动递增由任何给定语句分配的值是连续的

    基于语句复制(statement-based replication)的情况下,这意味着当在从服务器上复制SQL语句时,自动增量列使用与主服务器上相同的值。多个INSERT语句的执行结果是确定性的,SLAVE再现与MASTER相同的数据(反之,如果由多个INSERT语句生成的自动递增值交错,则两个并发INSERT语句的结果将是不确定的,并且不能使用基于语句的复制可靠地传播到从属服务器)。

  2. innodb_autoinc_lock_mode = 1 (“consecutive” lock mode)

    这是默认的锁定模式。在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束.这适用于所有INSERT ... SELECT,REPLACE ... SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁

    而“Simple inserts”(要插入的行数事先已知)通过在mutex(轻量锁)的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。 如果另一个事务保持AUTO-INC锁,则“简单插入”等待AUTO-INC锁,如同它是一个“批量插入”。

    此锁定模式确保,当行数不预先知道的INSERT存在时(并且自动递增值在语句过程执行中分配)由任何“类INSERT”语句分配的所有自动递增值是连续的,并且对于基于语句的复制(statement-based replication)操作是安全

    这种锁定模式显著地提高了可扩展性,并且保证了对于基于语句的复制(statement-based replication)的安全性。此外,与“传统”锁定模式一样,由任何给定语句分配的自动递增数字是连续的。 与使用自动递增的任何语句的“传统”模式相比,语义没有变化,但有个特殊场景需要注意:The exception is for “mixed-mode inserts”, where the user provides explicit values for an AUTO_INCREMENT column for some, but not all, rows in a multiple-row “simple insert”. For such inserts, InnoDB allocates more auto-increment values than the number of rows to be inserted. However, all values automatically assigned are consecutively generated (and thus higher than) the auto-increment value generated by the most recently executed previous statement. “Excess” numbers are lost.

    也就说对于混合模式的插入,可能会有部分多余自增值丢失。

    在连续锁定模式下,InnoDB可以避免为“Simple inserts”语句使用表级AUTO-INC锁,其中行数是预先已知的,并且仍然保留基于语句的复制的确定性执行和安全性。

  3. innodb_autoinc_lock_mode = 2 (“interleaved” lock mode)

    在这种锁定模式下,所有类INSERT(“INSERT-like” )语句都不会使用表级AUTO-INC lock,并且可以同时执行多个语句。这是最快和最可扩展的锁定模式,但是当使用基于语句的复制或恢复方案时,从二进制日志重播SQL语句时,这是不安全

    在此锁定模式下,自动递增值保证在所有并发执行的“类INSERT”语句中是唯一且单调递增的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号),为任何给定语句插入的行生成的值可能不是连续的。

    如果执行的语句是“simple inserts”,其中要插入的行数已提前知道,则除了“混合模式插入”之外,为单个语句生成的数字不会有间隙。然而,当执行“批量插入”时,在由任何给定语句分配的自动递增值中可能存在间隙。

    如果不使用二进制日志作为恢复或复制的一部分来重放SQL语句,则可以使用interleaved lock模式来消除所有使用表级AUTO-INC锁,以实现更大的并发性和性能,其代价是由于并发的语句交错执行,同一语句生成的AUTO-INCREMENT值可能会产生GAP。

  4. innodb_autoinc_lock_mode参数的修改

    编辑/etc/my.cnf,加入如下行:

    innodb_autoinc_lock_mode=2
         
         

      直接通过命令修改会报错:

      mysql(mdba@localhost:(none) 09:32:19)>set global innodb_autoinc_lock_mode=2;
      
           
           
      • 1
    Logo

    为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

    更多推荐