锁是实现事务隔离性最广泛使用的技术。本文主要分享InnoDB中锁的设计与实现。
锁的定义
下面列举innodb支持的锁。
行级锁
共享锁:S锁,允许事务读一行数据
排他锁:X锁,允许事务删除或更新一行数据
X | S | |
---|---|---|
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
X锁与任何的锁都不兼容,而S锁仅和S锁兼容。
注意:行锁实际上是索引记录锁,对索引记录的锁定。即使表没有建立索引,InnoDB也会创建一个隐藏的聚簇索引,并使用此索引进行记录锁定。
意图锁
意图锁定是表级锁定,标识事务稍后对表中的行做哪种类型的锁定(共享或独占)
意向共享锁(IS):事务想要获得一张表中某几行的共享锁
意向排他锁(IX):事务想要获得一张表中某几行的排他锁
意图锁遵循如下协议:
在事务获取表中某行的共享锁之前,它必须首先在表上获取IS锁或更强的锁。
在事务获取表中某行的独占锁之前,它必须首先在表上获取IX锁。
IS | IX | S | X | |
---|---|---|---|---|
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
X锁与任何的锁都不兼容,S锁与IX锁不兼容,其他情况都是兼容的。
注意:意向锁只会阻塞表级别的锁(如LOCK TABLES请求的表锁),并不会阻塞行级锁(如行级X锁)。
间隙锁(Gap Lock)
行锁的以下三种算法
Record Lock:单个行记录上的锁
Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
Next-Key Lock:锁定记录以及记录前一个间隙
插入意向锁(Insert Intention Lock)
插入意图锁是在行插入之前通过INSERT操作设置的一种特殊间隙锁。
注意:多个事务插入同一个间隙的不同位置,他们并不会冲突。 假设存在索引记录,其值分别为4和7。单独的事务分别尝试插入值5和6,在获得插入行的排他锁之前,每个事务都使用插入意图锁来锁定4和7之间的间隙, 但他们不会互相阻塞。
同样,不同事务请求同一个间隙的Gap锁并不会阻塞,但如果一个事务请求了Gap锁,另一个事务再请求插入意向锁,则会阻塞。
Gap锁和Next-Key锁只存在RR隔离级别下,RC隔离级别下并不使用这些锁。
Gap锁意义是什么?是为了解决幻读问题。
什么是幻读问题?一个事务中同一个SQL多次执行,结果集不同,就是多了一些记录。这违反了事务的隔离性,即当前事务能够看到其他事务的结果。
Gap锁的目的就是解决这个问题。它阻塞插入意向锁,阻止不适当的记录插入,避免幻读问题。
文章下面说到的加Gap锁或Next-Key锁的场景,大家思考一下通过这些锁是否可以解决幻读问题,就知道为什么要加Gap锁和Next-Key锁了。
自增锁
自增锁是事务插入到具有AUTO_INCREMENT列的表时的一种特殊表级锁。当一个事务将值插入表时,必须获取自增锁,以便获取自增列的值。
innodb_autoinc_lock_mode参数可以控制 auto-increment 锁定的算法。有兴趣的同学可以深入了解。
表锁
innodb还支持表锁,LOCK TABLES ... WRITE可以获取指定表的X锁。LOCK TABLES ... READ可以获取指定表的S锁。
锁的实现
行锁在InnoDB中的数据结构如下
typedef struct lock_rec_struct lock_rec_t
struct lock_rec_struct{
ulint space; /*space id*/
ulint page_no; /*page number*/
unint n_bits; /*number of bits in the lock bitmap*/
}
InnoDB中根据页的组织形式进行锁管理,并使用位图记录锁信息。
n_bits变量表示位图占用的字节数,它后面紧跟着一个bitmap,bitmap占用的字节为:1 + (nbits-1)/8,bitmap中的每一位标识对应的行记录是否加锁。
因此,lock_rec_struct占用的实际存储空间为:sizeof(lock_rec_struct) + 1 + (nbits-1)/8。
思考:如何锁定一个间隙呢?
InnoDB通过在间隙的下一个记录添加Gap锁实现锁定一个间隙
表级锁的数据结构(用于表的意向锁和自增锁)
typedef struct lock_table_struct lock_table_t;
struct lock_table_struct {
dict_table_t* table; /*database table in dictionary cache*/
UT_LIST_NODE_T(lock_t) locks; /*list of locks on the same table*/
}
而事务中关联如下锁结构
typedef struct lock_struct lock_t;
struct lock_struct{
trx_t* trx; /* transaction owning the lock */
UT_LIST_NODE_T(lock_t) trx_locks; /* list of the locks of the transaction */
ulint type_mode; /* lock type, mode, gap flag, and wait flag, ORed */
hash_node_t hash; /* hash chain node for a record lock */
dict_index_t* index; /* index for a record lock */
union {
lock_table_t tab_lock; /* table lock */
lock_rec_t rec_lock; /* record lock */
} un_member;
};
index变量指向一个索引,行锁本质是索引记录锁。
lock_struct是根据一个事务的每个页(或每个表)进行定义的。但一个事务可能在不同页上有多个行锁,trx_locks变量将一个事务所有的锁信息进行链接,这样就可以快速查询一个事务所有锁信息。
UT_LIST_NODE_T定义如下,典型的链表结构
#define UT_LIST_NODE_T(TYPE)
struct {
TYPE * prev; /* pointer to the previous node,NULL if start of list */
TYPE * next; /* pointer to next node, NULL if end of list */
}
lock_struct中type_mode变量是一个无符号的32位整型,从低位排列,第1字节为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 */
LOCK_NONE, /* this is used elsewhere to note consistent read */
LOCK_NUM = LOCK_NONE, /* number of lock modes */
LOCK_NONE_UNSET = 255
};
第2字节为lock_type,目前只用前两位,大小为 16 和 32 ,表示 LOCK_TABLE 和 LOCK_REC,
#define LOCK_TABLE 16
#define LOCK_REC 32
剩下的高位 bit 表示行锁的类型record_lock_type
#define LOCK_WAIT 256 /* 表示正在等待锁 */
#define LOCK_ORDINARY 0 /* 表示 Next-Key Lock ,锁住记录本身和记录之前的 Gap*/
#define LOCK_GAP 512 /* 表示锁住记录之前 Gap(不锁记录本身) */
#define LOCK_REC_NOT_GAP 1024 /* 表示锁住记录本身,不锁记录前面的 gap */
#define LOCK_INSERT_INTENTION 2048 /* 插入意向锁 */
#define LOCK_CONV_BY_OTHER 4096 /* 表示锁是由其它事务创建的(比如隐式锁转换) */
另外,除了查询某个事务所有锁信息,系统还需要查询某个具体记录的锁信息。如记录id=3是否有锁?
而InnoDB使用哈希表映射行数据和锁信息
struct lock_sys_struct{
hash_table_t* rec_hash;
}
每次新建一个锁对象,都要插入到lock_sys->rec_hash中。lock_sys_struct中的key通过页的space和page_no计算得到,而value则是锁对象lock_rec_struct。
因此若需查询某一行记录是否有锁,首先根据行所在页进行哈希查询,然后根据查询得到的lock_rec_struct,查找lock bitmap,最终得到该行记录是否有锁。
可以看出,根据页进行对行锁的查询并不是高效设计,但这种方式的资源开销非常小。某一事务对一个页任意行加锁开销都是一样的(不管锁住多少行)。因此也不需要支持锁升级的功能。
如果根据每一行记录进行锁信息管理,所需的开销会非常巨大。当一个事务占用太多的锁资源时,需要进行锁升级,将行锁升级为更粗粒度的锁,如页锁或表锁。
而现在InnoDB设计的方案并不需要锁升级。
加锁操作
下面列举几种常见场景下的加锁操作
插入
- 首先对表加上IX锁
- 唯一索引冲突检查:如果唯一索引上存在相同项,进行S锁当前读,读到数据则唯一索引冲突,返回异常,否则检查通过。
- 判断插入位置是否存在Gap锁或Next-Key锁,没有的话直接插入,有的话等待锁释放,并产生插入意向锁。
- 对插入记录的所有索引项加X锁
为了降低锁的开销,innodb采用了延迟加锁机制,即隐式锁(implicit lock)。
当有事务对某条记录进行修改时,需要先判断该行记录是否有隐式锁(原记录的事务id是否是活动的事务),如果有则为其真正创建锁并等待(隐式锁转换为显示锁),否则直接更新数据并写入自己的事务id(可以理解为加了隐式锁)。
二级索引虽然存储上没有记录事务id,但同样可以存在隐式锁,只不过判断逻辑复杂一些。有兴趣的同学可以深入了解。
插入操作第3步添加的插入意向锁和第4步添加的X锁都是先添加隐式锁(就是没有加锁),当发生锁冲突时,再转化为显示锁。
一致性锁定读,修改
一致性非锁定读:如果读取的行正在执行DELETE或UPDATE操作,读取操作不等待行上锁的释放,而去读行的一个快照数据。在之前事务篇已经分享过相关内容。
这里看一下一致性锁定读(就是当前读)和修改操作的加锁逻辑
(1) 查询命中结果
- SELECT ... FROM ... LOCK IN SHARE MODE(S锁),SELECT ... FROM ... FOR UPDATE(X锁),UPDATE ... WHERE ... (X锁)语句在扫描命中的索引记录上加上next key锁。如果是唯一索引,只需要在相应记录上加index record lock。
- 在辅助索引记录上加锁的语句,首先对辅助索引记录加next key锁,然后还要对聚集索引记录进行加锁record lock
- 在辅助索引记录上加锁的语句,可能还需要对下一个记录进行加Gap锁,解决幻读问题。
(2) 查询未命中结果
如果sql查询没有命中结果,则对命中的间隙加Gap锁。
(3) 查询未使用索引
如果sql没有使用索引,只能走聚簇索引,对表中的记录进行全表扫描。
在RC隔离级别下会给所有记录加Record锁,在RR隔离级别下,对所有记录加Next-Key锁。
删除
删除操作需要和更新操作一样加锁,并且当purge真正删除记录操作完成后,如果删除记录上有Gap锁,则由下一个记录继承该锁,同时释放并重置删除记录上等待锁的信息。
死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种相互等待的现象。若无外力作用,他们都将无法推进下去。
解决死锁常用的两个方案:
超时机制,即两个事务互相等待时,当一个等待时间超过设置的某一阀值时,其中一个事务回滚,另一个事务继续执行。
MySQL4.0版本开始,提供innodb_lock_wait_time用于设置等待超时时间。等待图(wait-for graph)
InnoDB通过锁的信息链表和事务等待链表,判断是否存在等待回路。如有,则存在死锁。
每次加锁操作需要等待时都判断是否产生死锁,若有则回滚事务。
实例分析
MySQL 8提供了performance_schema.data_locks可以很清晰地看到锁信息。
下面的data_locks信息都是通过如下sql查询
select ENGINE_TRANSACTION_ID,OBJECT_NAME,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_STATUS,LOCK_DATA from performance_schema.data_locks;
创建测试表和测试数据
CREATE TABLE `lock_test` (
`a` int(10) unsigned NOT NULL,
`b` int(1) unsigned NOT NULL ,
`c` int(1) unsigned NOT NULL ,
PRIMARY KEY (`a`),
KEY `key2` (b) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into lock_test values(10, 10, 10);
insert into lock_test values(20, 20, 20);
insert into lock_test values(30, 30, 30);
insert into lock_test values(40, 40, 40);
insert into lock_test values(50, 50, 50);
测试场景,通过非唯一索引更新数据
begin;
update lock_test set b = 0 where b = 30;
data_locks信息如下:
+-----------------------+---------------+-------------+------------+-----------+---------------+-------------+-----------+
| ENGINE_TRANSACTION_ID | OBJECT_SCHEMA | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------------------+---------------+-------------+------------+-----------+---------------+-------------+-----------+
| 5242 | ttt | lock_test | NULL | TABLE | IX | GRANTED | NULL |
| 5242 | ttt | lock_test | key2 | RECORD | X | GRANTED | 30, 30 |
| 5242 | ttt | lock_test | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 30 |
| 5242 | ttt | lock_test | key2 | RECORD | X,GAP | GRANTED | 40, 40 |
+-----------------------+---------------+-------------+------------+-----------+---------------+-------------+-----------+
事务在key2索引的b=30记录添加了Next-Key锁。(LOCK_MODE为X,代表Next-Key锁)
PRIMARY索引的a=30记录也加了Record锁。
事务还在key2索引的(30,40)区间加了Gap锁,所以在(30,40)之间插入数据会被阻塞。
这是如果删除a=40记录,data_locks信息如下
+-----------------------+---------------+-------------+------------+-----------+---------------+-------------+-----------+
| ENGINE_TRANSACTION_ID | OBJECT_SCHEMA | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------------------+---------------+-------------+------------+-----------+---------------+-------------+-----------+
| 5249 | ttt | lock_test | NULL | TABLE | IX | GRANTED | NULL |
| 5249 | ttt | lock_test | key2 | RECORD | X | GRANTED | 40, 40 |
| 5249 | ttt | lock_test | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 40 |
| 5249 | ttt | lock_test | key2 | RECORD | X,GAP | GRANTED | 50, 50 |
| 5242 | ttt | lock_test | NULL | TABLE | IX | GRANTED | NULL |
| 5242 | ttt | lock_test | key2 | RECORD | X | GRANTED | 30, 30 |
| 5242 | ttt | lock_test | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 30 |
| 5242 | ttt | lock_test | key2 | RECORD | X,GAP | GRANTED | 40, 40 |
+-----------------------+---------------+-------------+------------+-----------+---------------+-------------+-----------+
可以到a=40记录被删除后,添加了(30,50)Gap锁,替换原来的(30,40)的Gap锁
再看一下查询未命中结果的场景
update lock_test set a = 0 where a = 15
data_locks信息如下:
+-----------------------+-------------+------------+-----------+-----------+-------------+-----------+
| ENGINE_TRANSACTION_ID | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------------------+-------------+------------+-----------+-----------+-------------+-----------+
| 5644 | lock_test | NULL | TABLE | IX | GRANTED | NULL |
| 5644 | lock_test | PRIMARY | RECORD | X,GAP | GRANTED | 20 |
+-----------------------+-------------+------------+-----------+-----------+-------------+-----------+
可以看到在命中的间隙上加了Gap锁。
注意:update操作也会产生INSERT_INTENTION锁
+-------------------------------------------------+-------------------------------------------------+
| T1 | T2 |
+-------------------------------------------------+-------------------------------------------------+
| begin; | |
| select * from lock_test where b= 20 for update; | |
+-------------------------------------------------+-------------------------------------------------+
| | begin; |
| | update lock_test set a = 60 where b = 30;(阻塞) |
+-------------------------------------------------+-------------------------------------------------+
data_locks信息如下:
+-----------------------+-------------+------------+-----------+------------------------+-------------+-----------+
| ENGINE_TRANSACTION_ID | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------------------+-------------+------------+-----------+------------------------+-------------+-----------+
| 5684 | lock_test | NULL | TABLE | IX | GRANTED | NULL |
| 5684 | lock_test | key2 | RECORD | X | GRANTED | 30, 30 |
| 5684 | lock_test | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 30 |
| 5684 | lock_test | key2 | RECORD | X,GAP | GRANTED | 40, 40 |
| 5684 | lock_test | key2 | RECORD | X,GAP,INSERT_INTENTION | WAITING | 30, 30 |
| 5673 | lock_test | NULL | TABLE | IX | GRANTED | NULL |
| 5673 | lock_test | key2 | RECORD | X | GRANTED | 20, 20 |
| 5673 | lock_test | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 20 |
| 5673 | lock_test | key2 | RECORD | X,GAP | GRANTED | 30, 30 |
+-----------------------+-------------+------------+-----------+------------------------+-------------+-----------+
事务2的update操作产生了X,GAP,INSERT_INTENTION
锁,并且被阻塞。
下面看一些死锁场景。
(1) Duplicate key导致死锁
+-----+------------------------+-----------------------------------------+-----------------------+
| | T1 | T2 | T3 |
+-----+------------------------+-----------------------------------------+-----------------------+
| (1) | begin; | | |
| | insert into lock_test | | |
| | values(25, 25, 25); | | |
+-----+------------------------+-----------------------------------------+-----------------------+
| (2) | | begin; | |
| | | insert into lock_test | |
| | | values(25, 25, 25); | |
+-----+------------------------+-----------------------------------------+-----------------------+
| | | | begin; |
| | | | insert into lock_test |
| | | | values(25, 25, 25); |
+-----+------------------------+-----------------------------------------+-----------------------+
| (3) | rollback; | Deadlock found when trying to get lock; | Query OK |
+-----+------------------------+-----------------------------------------+-----------------------+
(1) 事务T1插入a=25记录
(2) 事务T2、T3也开始插入a=25记录,由于发生唯一键冲突,T2,T3需要执行S锁当前读(LOCK_S | LOCK_REC_NOT_GAP)
这时T1隐式锁转化为显示锁 (LOCK_X | LOCK_REC_NOT_GAP),导致T2,T3阻塞
(3) T1回退
这时,T2和T3都要请求索引id=25上的排他记录锁(LOCK_X | LOCK_REC_NOT_GAP)。
由于X锁与S锁互斥,T2和T3都等待对方释放S锁。
于是,死锁便产生了。
(2) GAP与Insert Intention冲突导致死锁
+-----+--------------------------------------------------+--------------------------------------------------+
| | T1 | T2 |
+-----+--------------------------------------------------+--------------------------------------------------+
| (1) | begin; | |
| | select * from lock_test where b = 20 for update; | |
+-----+--------------------------------------------------+--------------------------------------------------+
| | | begin; |
| | | select * from lock_test where b = 30 for update; |
+-----+--------------------------------------------------+--------------------------------------------------+
| (2) | insert into lock_test values(25, 25, 25); | |
+-----+--------------------------------------------------+--------------------------------------------------+
| | | insert into lock_test values(26, 26, 26); |
+-----+--------------------------------------------------+--------------------------------------------------+
| | | Deadlock found when trying to get lock; |
+-----+--------------------------------------------------+--------------------------------------------------+
(1)
T1事务GAP锁锁住区间(20,30)
T2事务Next-Key锁锁住区间(20,30]
(2)
T1事务插入操作,需要在间隙(20,30)添加插入意向锁,这时等待T2事务Next-Key锁释放
T2事务插入操作,需要在间隙(20,30)添加插入意向锁,这时等待T1事务GAP锁释放
这时死锁产生了。
另外,show engine innodb status可以获取最近一次的死锁日志。
MySQL8之前,可以通过INFORMATION_SCHEMA下INNODB_TRX,INNODB_LOCKS,INNODB_LOCK_WAITS查看事务和锁信息。
INNODB_TRX在MySQL8依然保留。
参考文档:
常见 SQL 语句的加锁分析
Innodb 锁子系统浅析
mysql insert锁机制
如果您觉得本文不错,欢迎关注我的微信公众号,您的关注是我坚持的动力!