深入理解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设计的方案并不需要锁升级。

加锁操作

下面列举几种常见场景下的加锁逻辑

插入

  1. 首先对表加上IX锁

  2. 唯一索引冲突检查:如果唯一索引上存在相同项,进行S锁当前读,读到数据则唯一索引冲突,返回异常,否则检查通过。

  3. 判断插入位置是否存在Gap锁或Next-Key锁,没有的话直接插入,有的话等待锁释放,并产生插入意向锁。

  4. 对插入记录的所有索引项加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锁,则由下一个记录继承该锁,同时释放并重置删除记录上等待锁的信息。

死锁

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种相互等待的现象。若无外力作用,他们都将无法推进下去。

解决死锁常用的两个方案:

  1. 超时机制,即两个事务互相等待时,当一个等待时间超过设置的某一阀值时,其中一个事务回滚,另一个事务继续执行。
    MySQL4.0版本开始,提供innodb_lock_wait_time用于设置等待超时时间。

  2. 等待图(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依然保留。

转载自:

深入理解InnoDB -- 锁篇

posted @ 2021-04-26 10:10  晓码君  阅读(221)  评论(0编辑  收藏  举报