锁是实现事务隔离性最广泛使用的技术。本文主要分享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通过锁的信息链表和事务等待链表,判断是否存在等待回路。如有,则存在死锁。
每次加锁操作需要等待时都判断是否产生死锁,若有则回滚事务。