深入理解InnoDB -- 锁篇
锁是实现事务隔离性最广泛使用的技术。本文主要分享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_tstruct 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 = 15data_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 |+-----------------------+-------------+------------+-----------+-----------+-------------+-----------+
可以看到在命中的(10,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依然保留。