一文搞懂 MySQL 锁分类并浅谈 InnoDB 加锁机制

124 阅读10分钟

前言:在多个事务并发情况下,对同一行数据的访问如果没有加锁一定会造成数据不统一的问题。比如事务一和事务二能同时将 id = 1 的数据行里面的某个属性值进行 +1 操作吗?这样会不会有数据问题?因此数据库 MySQL 锁的存在是必要的。

锁分类

按照理论概念进行划分

理论概念上来说,MySQL 里的锁根据维度的不同会有几种不同的分类。

  • 根据操作细粒度可以分为:
    • 表级锁
    • 行级锁(InnoDB 引擎)
    • 页级锁(BDB 引擎)。
    • 全局锁(Flush tables with Read Lock,了解即可,只有数据库备份的时候使用)
  • 根据操作类型可以分为:
    • 读锁:也称为 S 锁,共享锁,和操作系统中的读写锁一样,S 锁可以在多个事务中同时使用,也就是锁,可以同时进行读操作,同时加共享锁。
    • 写锁:也称为 X 锁,排他锁,X 锁不能与其他锁共存,倾向独占数据的意思,可以对当前数据进行读取和修改,不允许其他事务进行读取和修改。
  • 根据操作性能可以分为:
    • 悲观锁:认为修改的时候一定为有其他事务来修改,所以先对数据进行加锁。MySQL 中的锁基本都是悲观锁。
    • 乐观锁:认为修改的时候不会有其他事务来修改,只在更新的时候进行冲突检测。当然,MySQL 中没有这种锁,一般需要在业务代码层面进行实现,比如通过版本号来实现。在表中多加一个属性,版本号,更新的时候采用update table set name=xxx where id=1 and version=1;

这里对上述的读写锁进行一下补充:InnoDB 最基本的就是 S 锁和 X 锁了。并且,普通的 select 语句是不会对数据进行加锁的,如果要显式的对数据加 S 锁,可以通过 select ... lock in share mode; 或者 select ... for update;

按照源码进行划分

MySQL 源码上将锁分为两类,锁类型(lock_type)和锁模式(lock_mode)。

  • 锁类型就是上面描述的表锁、行锁。
  • 锁模式指读锁和写锁:
/* Basic lock modes */
enum lock_mode {
    LOCK_IS = 0, /* intention shared */
    LOCK_IX,    /* intention exclusive */
    LOCK_S,     /* shared */
    LOCK_X,     /* exclusive */
    LOCK_AUTO_INC,  /* locks the auto-inc counter of a table in an exclusive mode*/
};
  1. LOCK_IS:读意向锁;
  2. LOCK_IX:写意向锁;
  3. LOCK_S:读锁;
  4. LOCK_X:写锁;
  5. LOCK_AUTO_INC:自增锁;

MySQL 中的锁通常是由 锁类型 + 锁模式 构成的。

接下来详细介绍各种锁。

表级锁

1. 普通表级锁

普通表级锁(比如 lock table tableName read/write根据 read/write 进行表级别加读锁/写锁。 )。

2. MDL 表级锁(元数据锁)

MDL 锁的作用是确保更改表结构的时候不然增删改数据,不然的话,会出现事务一加了数据的同时,事务二删除该列,事务一发现没有了这个列名的情况。

MDL 锁是默认加的,在执行 sql 语句的时候。机制如下:

  • MySQL 执行增删改查的时候会添加 MDL 读锁。执行更改表结构语句时会添加 MDL 写锁(5.6版本开始引入了 Online DDL 机制(了解即可),大部分 alter 语句会在拿到 MDL 写锁后退化为 MDL 读锁)。
  • 读锁不互斥,也就是说可以有多个线程对同一个表进行增删查改。
  • 读写互斥,写写互斥,也就是说,如果有两个线程同时更改表结构,另一个线程需要等待。
3. 意向锁

意向锁,分为意向读锁(LOCK_IS)意向写锁(LOCK_IX)。意向锁是表锁,且意向锁不互斥。

当要对行记录加 S 锁时,会先给这个表加意向读锁,当要对行记录加 X 锁时,会先给这个表加意向写锁。

作用:

  • 当外部要对这个表加表锁时,会优先判断这个表有没有加意向读写锁,这样就不用去遍历表里的每一行数据看看有没有被加了锁。细分下来比如:
    • 要对表加整表 S 锁,如果发现这个表已经有了意向写锁,就停止,不用再去遍历。
    • 要对表加整表 X 锁,如果发现这个表已经有了意向读/写锁,就停止,不用再去遍历。

也就是说:意向锁只会阻塞表级读锁/表级写锁(如上述的 lock table tableName read)。

真正的作用其实是提升了并发性能/并发度。

行级锁

MySQL 的存储引擎 InnoDB 里的行锁是最重要的锁内容。(MyISAM 引擎只有表锁。)

#define LOCK_REC    32  /* record lock */
 
/* Precise modes */
#define LOCK_ORDINARY   0       /* Next-key lock */
#define LOCK_GAP    512         /* Gap lock */
#define LOCK_REC_NOT_GAP 1024   /* Record lock */
#define LOCK_INSERT_INTENTION 2048

InnoDB 的行锁是通过对索引数据页上的记录加锁实现的。 也就是说,行锁是通过对索引加锁实现的。

  • RecordLock:记录锁,锁定一行记录。(RC,RR 隔离级别都支持。)
  • GapLock:间隙锁,锁定索引记录之间的间隙,以确保索引之间的间隙不变。(范围锁,RR 隔离级别支持)
  • Next-Key Lock:记录锁和间隙锁的结合,锁定的是(记录前~记录]的范围,即前开后闭(概念上是前开后闭的区间,实际要看情况)。间隙锁 + 右边界,所以称为 next-key lock。(记录锁+范围锁,RR 隔离级别支持)
  • Insert-Intention Lock:插入意向锁(II GAP)。这个是 insert 语句专门使用的,和上面表锁的意向锁完全没有关系。是一种特殊是 Gap 锁。

可以看出,只有在 RR 隔离级别才会有间隙锁和 Next-key lock

在 RR 隔离级别,InnoDB 对于记录加锁的行为都是先采用 next-key lock,但是当 SQL 语句中含有唯一索引时,会降级为 RecordLock,仅锁住索引本身而非范围。

行锁的兼容矩阵图

image.png

上面的矩阵图,最上面的第一行表示已有的锁,最左边的第一列表示在已有的锁的基础上要加的锁。打勾表示兼容,即可以加锁成功。

可以看到

  • 对于插入意向锁 II GAP:
    • 已有插入意向锁的前提下,对其他行锁不影响。
    • 在已有 Record 锁的基础上可以加 II CAP。
  • 除了 II GAP 之外,Gap 锁则对其他所有行锁都兼容。

下面对一些语句的加锁机制进行举例说明。

加锁机制

具体的加锁过程太过繁琐,后面单独写篇文章来讲。简要的结果如下:

  1. select ... from 语句:InnoDB 引擎采用 MVCC 机制实现非阻塞读,所以对于普通的 select 语句 InnoDB 不加锁。
  2. select... from lock in share mode 语句:加了共享锁,InnoDB会使用 Next-Key Lock 锁进行处理,如果扫描发现唯一索引,可以降级为 RecordLock 锁。
  3. select ...from for update 语句:加了排他锁,InnoDB 会使用 Next-Key Lock 锁进行处理,如果扫描发现唯一索引,可以降级为 RecordLock 锁。
  4. update ... where 语句:InnoDB会使用 Next-Key Lock 锁进行处理,如果扫描发现唯一索引,可以降级为 RecordLock 锁。
  5. delete ... where 语句:InnoDB会使用 Next-Key Lock 锁进行处理,如果扫描发现唯一索引,可以降级为 RecordLock 锁。
  6. insert 语句:InnoDB会在将要插入的那一行设置一个排他的 RecordLock 锁。
Insert 语句的加锁过程

这里详细说一下 insert 语句具体的加锁过程(摘自官网):

INSERT sets an exclusive lock on the inserted row. This lock is an index-record lock, not a next-key lock (that is, there is no gap lock) and does not prevent other sessions from inserting into the gap before the inserted row.

Prior to inserting the row, a type of gap lock called an insert intention gap lock is set. 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 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.

If a duplicate-key error occurs, a shared lock on the duplicate index record is set. This use of a shared lock can result in deadlock should there be multiple sessions trying to insert the same row if another session already has an exclusive lock. This can occur if another session deletes the row.

  1. Insert 语句会对插入的行设置排他锁。此锁是 Record Lock,而不是 Next-Key 锁(也就是说,没有间隙锁),并且不会阻止其他会话插入插入行之前的间隙中。
  2. 在插入记录前,会向插入记录所在位置申请意向插入 Gap 锁(Insertion Intention Gap Lock),相同区间的意向插入 Gap 锁不会冲突。假设存在值为 4 和 7 的索引记录。尝试插入值为 5 和 6 的单独事务,在获得插入行的独占锁之前,每个事务都使用插入意图锁锁定 4 和 7 之间的间隙,但不会相互阻止,因为这些行不冲突。
  3. 对于唯一索引,如果插入记录时表中已存在相同键值记录(被其他事务修改且未提交),即存在唯一键冲突,会尝试在已有记录上加读锁。

也就是说,在给记录添加 Record 锁前,会先加入特殊的 Gap 锁,即 插入意向锁。

举个例子

现在有表结构如下,其中 c 是唯一索引,d 是普通索引。添加了几条记录。

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  `e` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_c` (`c`),
  KEY `idx_d` (`d`)
) ENGINE=InnoDB

insert into t values(0,0,0,0),(5,5,5,5),(10,10,10,10),(15,15,15,15),(20,20,20,20);
锁兼容的情况
  1. session1 开启一个事务,执行语句
mysql>begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t where c=10 for update;
+----+------+------+------+
| id | c    | d    | e    |
+----+------+------+------+
| 10 |   10 |   10 |   10 |
+----+------+------+------+
1 row in set (0.00 sec)

该语句根据唯一索引 c 找到对应记录,根据前面加锁机制描述,会加一个 Next-key lock,因为是唯一索引,退化成 Recode lock 记录锁。

  1. session2 开启一个事务,根据上面的兼容表格,在 record 锁上可以加 II GAP 锁,所以添加 c=11 这条记录不会被阻塞。
mysql>begin;
Query OK, 0 rows affected (0.00 sec)

mysql>insert into t values(100,11,100,100);
Query OK, 1 row affected (0.00 sec)
锁不兼容的情况
  1. session1 开启一个事务,根据普通索引 d=10 查询记录,在 d=10 附近会有 Next-key lock,也就是在 10 旁边的间隙会加锁, (5,10] 和 (10,15)。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t where d=10 for update;
+----+------+------+------+
| id | c    | d    | e    |
+----+------+------+------+
| 10 |   10 |   10 |   10 |
+----+------+------+------+
1 row in set (0.00 sec)
  1. session2 开启一个事务,在上面列 d 的间隙中添加记录。发现会被卡住,因为根据上面的兼容表格,在已有 Next-key lock 上添加 insert 语句特有的 II Cap 是不兼容的。
mysql> insert into t values(100,100,9,100);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql>

mysql> insert into t values(100,100,11,100);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql>

也就是:插入意向锁只会和 Gap lock 和 Next-key lock 冲突

参考资料

  1. MySQL 官方文档加锁机制: dev.mysql.com/doc/refman/…
  2. 《MySQL 45 讲》- 21 | 为什么我只改一行的语句,锁这么多?
  3. aneasystone's blog - 了解常见的锁类型