InnoDB 事务锁系统简介

背景

摘自数据库内核月报 - 2016 / 01 http://mysql.taobao.org/monthly/2016/01/01/
link

以下基于MySQL5.7.10 版本

行级锁

InnoDB 支持到行级别粒度的并发控制

LOCK_REC_NOT_GAP

锁带上这个 FLAG 时,表示这个锁对象只是单纯的锁在记录上,不会锁记录之前的 GAP。在 RC 隔离级别下一般加的都是该类型的记录锁

但唯一二级索引上的 duplicate key 检查除外,总是加 LOCK_ORDINARY (Next-Key Lock)类型的锁(个人理解,唯一索引,可能 造成死锁)。

LOCK_GAP

表示只锁住一段范围,不锁记录本身,通常表示两个索引记录之间,或者索引上的第一条记录之前,或者最后一条记录之后的锁。可以理解为一种区间锁,一般在RR隔离级别下会使用到GAP锁。

你可以通过切换到RC隔离级别,或者开启选项innodb_locks_unsafe_for_binlog来避免GAP锁。这时候只有在检查外键约束或者duplicate key检查时才会使用到GAP LOCK。 (innodb_locks_unsafe_for_binlog默认是关闭的5.7.26,除非明确业务需要这样设置,一般不建议更改默认的参数)

RC只有在检查外键约束或者duplicate key检查时才会使用到GAP LOCK。

LOCK_ORDINARY(Next-Key Lock)

也就是所谓的 NEXT-KEY 锁,包含记录本身及记录之前的GAP。当前 MySQL 默认情况下使用RR的隔离级别,而NEXT-KEY LOCK正是为了解决RR隔离级别下的幻读问题。所谓幻读就是一个事务内执行相同的查询,会看到不同的行记录。在RR隔离级别下这是不允许的。

假设索引上有记录1, 4, 5, 812 我们执行类似语句:
SELECTWHERE col > 10 FOR UPDATE。
如果我们不在(8, 12)之间加上Gap锁,
另外一个 Session 就可能向其中插入一条记录,
例如9,再执行一次相同的SELECT FOR UPDATE,就会看到新插入的记录。

这也是为什么插入一条记录时,需要判断下一条记录上是否加锁了。

LOCK_S(共享锁)

共享锁的作用通常用于在事务中读取一条行记录后,不希望它被别的事务锁修改,但所有的读请求产生的LOCK_S锁是不冲突的。在InnoDB里有如下几种情况会请求S锁。

1.(不常用)普通查询在隔离级别为 SERIALIZABLE 会给记录加 LOCK_S 锁。但这也取决于场景:非事务读(auto-commit)在 SERIALIZABLE 隔离级别下,无需加锁(不过在当前最新的5.7.10版本中,SHOW ENGINE INNODB STATUS 的输出中不会打印只读事务的信息,只能从informationschema.innodb_trx表中获取到该只读事务持有的锁个数等信息)2.(常用)类似 SQL SELECTIN SHARE MODE,会给记录加S锁,其他线程可以并发查询,但不能修改。基于不同的隔离级别,行为有所不同:

RC隔离级别: LOCK_REC_NOT_GAP | LOCK_S;
RR隔离级别:如果查询条件为唯一索引且是唯一等值查询时,加的是 LOCK_REC_NOT_GAP | LOCK_S;对于非唯一条件查询,或者查询会扫描到多条记录时,加的是LOCK_ORDINARY | LOCK_S锁,也就是记录本身+记录之前的GAP;

3.(常用)通常INSERT操作是不加锁的,但如果在插入或更新记录时,检查到 duplicate key(或者有一个被标记删除的duplicate key),对于普通的INSERT/UPDATE,会加LOCK_S锁,而对于类似REPLACE INTO或者INSERTON DUPLICATE这样的SQL加的是X锁。而针对不同的索引类型也有所不同:

insert,针对不同的索引类型也有所不同:

对于聚集索引(参阅函数row_ins_duplicate_error_in_clust),隔离级别小于等于RC时,加的是LOCK_REC_NOT_GAP类似的S或者X记录锁。否则加LOCK_ORDINARY类型的记录锁(NEXT-KEY LOCK);

对于二级唯一索引,若检查到重复键,当前版本总是加 LOCK_ORDINARY 类型的记录锁(函数 row_ins_scan_sec_index_for_duplicate)。实际上按照RC的设计理念,不应该加GAP锁(bug#68021),官方也事实上尝试修复过一次,即对于RC隔离级别加上LOCK_REC_NOT_GAP,但却引入了另外一个问题,导致二级索引的唯一约束失效(bug#73170),感兴趣的可以参阅我写的这篇博客,由于这个严重bug,官方很快又把这个fix给revert掉了。

4.外键检查
当我们删除一条父表上的记录时,需要去检查是否有引用约束(row_pd_check_references_constraints),这时候会扫描子表(dict_table_t::referenced_list)上对应的记录,并加上共享锁。按照实际情况又有所不同。
5.INSERTSELECT插入数据时,会对SELECT的表上扫描到的数据加LOCK_S锁

按照实际情况又有所不同。我们举例说明
使用RC隔离级别,两张测试表:

create table t1 (a int, b int, primary key(a));
create table t2 (a int, b int, primary key (a), key(b), foreign key(b) references t1(a));
insert into t1 values (1,2), (2,3), (3,4), (4,5), (5,6), (7,8), (10,11);
insert into t2 values (1,2), (2,2), (4,4);
执行SQLdelete from t1 where a = 10;

在t1表记录10上加 LOCKREC_NOT_GAP|LOCK_X
在t2表的supremum记录(表示最大记录)上加 LOCK_ORDINARY|LOCK_S,即锁住(4, ~)区间
执行SQLdelete from t1 where a = 2;

在t1表记录(2,3)上加 LOCK_REC_NOT_GAP|LOCK_X
在t2表记录(1,2)上加 LOCK_REC_NOT_GAP|LOCK_S锁,这里检查到有引用约束,因此无需继续扫描(2,2)就可以退出检查,判定报错。
执行SQLdelete from t1 where a = 3;

在t1表记录(3,4)上加 LOCK_REC_NOT_GAP|LOCK_X
在t2表记录(4,4)上加 LOCK_GAP|LOCK_S锁
另外从代码里还可以看到,如果扫描到的记录被标记删除时,也会加LOCK_ORDINARY|LOCK_S 锁。具体参阅函数row_ins_check_foreign_constraint

在这里插入图片描述

LOCK_X(排他锁)

排他锁的目的主要是避免对同一条记录的并发修改。通常对于UPDATE或者DELETE操作,或者类似SELECT … FOR UPDATE操作,都会对记录加排他锁。

我们以如下表为例:

create table t1 (a int, b int, c int, primary key(a), key(b));
insert into t1 values (1,2,3), (2,3,4),(3,4,5), (4,5,6),(5,6,7);
执行SQL(通过二级索引查询):
update t1 set c = c +1 where b = 3;

RC隔离级别:1. 锁住二级索引记录,为NOT GAP X锁;2.锁住对应的聚集索引记录,也是NOT GAP X锁。

RR隔离级别下:1.锁住二级索引记录,为LOCK_ORDINARY|LOCK_X锁;2.锁住聚集索引记录,为NOT GAP X锁

执行SQL(通过聚集索引检索,更新二级索引数据):
update t1 set b = b +1 where a = 2;

对聚集索引记录加 LOCK_REC_NOT_GAP | LOCK_X锁

在标记删除二级索引时,检查二级索引记录上的锁(lock_sec_rec_modify_check_and_lock),如果存在和LOCK_X | LOCK_REC_NOT_GAP冲突的锁对象,则创建锁对象并返回等待错误码;否则无需创建锁对象;

当到达这里时,我们已经持有了聚集索引上的排他锁,因此能保证别的线程不会来修改这条记录。(修改记录总是先聚集索引,再二级索引的顺序),即使不对二级索引加锁也没有关系。但如果已经有别的线程已经持有了二级索引上的记录锁,则需要等待。

在标记删除后,需要插入更新后的二级索引记录时,依然要遵循插入意向锁的加锁原则。

我们考虑上述两种 SQL 的混合场景,一个是先锁住二级索引记录,再锁聚集索引;另一个是先锁聚集索引,再检查二级索引冲突,因此在这类并发更新场景下,可能会发生死锁。

不同场景,不同隔离级别下的加锁行为都有所不同,例如在RC隔离级别下,不符合WHERE条件的扫描到的记录,会被立刻释放掉,但RR级别则会持续到事务结束。你可以通过GDB,断点函数lock_rec_lock来查看某条SQL如何执行加锁操作。

例如在RC隔离级别下,不符合WHERE条件的扫描到的记录,会被立刻释放掉,但RR级别则会持续到事务结束。

LOCK_INSERT_INTENTION(插入意向锁)

INSERT INTENTION锁是GAP锁的一种,如果有多个session插入同一个GAP时,他们无需互相等待,例如当前索引上有记录4和8,两个并发session同时插入记录6,7。他们会分别为(4,8)加上GAP锁,但相互之间并不冲突(因为插入的记录不冲突)。

Gap锁之间不冲突,INSERT INTENTION锁是GAP锁的一种(插入意向锁)

当向某个数据页中插入一条记录时,总是会调用函数lock_rec_insert_check_and_lock进行锁检查(构建索引时的数据插入除外),会去检查当前插入位置的下一条记录上是否存在锁对象,这里的下一条记录不是指的物理连续,而是按照逻辑顺序的下一条记录。 如果下一条记录上不存在锁对象:若记录是二级索引上的,先更新二级索引页上的最大事务ID为当前事务的ID;直接返回成功。

如果下一条记录上存在锁对象,就需要判断该锁对象是否锁住了GAP。如果GAP被锁住了,并判定和插入意向GAP锁冲突,当前操作就需要等待,加的锁类型为LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION,并进入等待状态。但是插入意向锁之间并不互斥。这意味着在同一个GAP里可能有多个申请插入意向锁的会话。

锁表更新

我们知道GAP锁是在一个记录上描述的,表示记录及其之前的记录之间的GAP。但如果记录之前发生了插入或者删除操作,之前描述的GAP就会发生变化,InnoDB需要对锁表进行更新。

对于数据插入,假设我们当前在记录[3,9]之间有会话持有锁(不管是否和插入意向锁冲突),现在插入一条新的记录5,需要调用函数lock_update_insert。这里会遍历所有在记录9上的记录锁,如果这些锁不是插入意向锁并且是LOCK_GAP或者NEXT-KEY LOCK(没有设置LOCK_REC_NOT_GAP标记)(lock_rec_inherit_to_gap_if_gap_lock),就会为这些会话的事务增加一个新的锁对象,锁的类型为LOCK_REC | LOCK_GAP,锁住的GAP范围在本例中为(3,5)。所有符合条件的会话都继承了这个新的GAP,避免之前的GAP锁失效。
对于数据删除操作,调用函数lock_update_delete,这里会遍历在被删除记录上的记录锁,当符合如下条件时,需要为这些锁对应的事务增加一个新的GAP锁,锁的Heap No为被删除记录的下一条记录:
lock_rec_inherit_to_gap
        for (lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);
             lock != NULL;
             lock = lock_rec_get_next(heap_no, lock)) {

                if (!lock_rec_get_insert_intention(lock)
                    && !((srv_locks_unsafe_for_binlog
                          || lock->trx->isolation_level
                          <= TRX_ISO_READ_COMMITTED)
                         && lock_get_mode(lock) ==
                         (lock->trx->duplicates ? LOCK_S : LOCK_X))) {
                        lock_rec_add_to_queue(
                                LOCK_REC | LOCK_GAP | lock_get_mode(lock),
                                heir_block, heir_heap_no, lock->index,
                                lock->trx, FALSE);
                }
        }

从上述判断可以看出,即使在RC隔离级别下,也有可能继承LOCK GAP锁,这也是当前版本InnoDB唯一的意外:判断Duplicate key时目前容忍GAP锁。上面这段代码实际上在最近的版本中才做过更新,更早之前的版本可能存在二级索引损坏。

完成GAP锁继承后,会将所有等待该记录的锁对象全部唤醒(lock_rec_reset_and_release_wait)。

LOCK_PREDICATE

从 MySQL5.7 开始MySQL整合了boost.geometry库以更好的支持空间数据类型,并支持在在Spatial数据类型的列上构建索引,在InnoDB内,这个索引和普通的索引有所不同,基于R-TREE的结构,目前支持对2D数据的描述,暂不支持3D.

R-TREE和BTREE不同,它能够描述多维空间,而多维数据并没有明确的数据顺序,因此无法在RR隔离级别下构建NEXT-KEY锁以避免幻读,因此InnoDB使用称为Predicate Lock的锁模式来加锁,会锁住一块查询用到的被称为MBR(minimum boundingrectangle/box)的数据区域。 因此这个锁不是锁到某个具体的记录之上的,可以理解为一种Page级别的锁。

Predicate Lock和普通的记录锁或者表锁(如上所述)存储在不同的lock hash中,其相互之间不会产生冲突。

Predicate Lock相关代码见lock/lock0prdt.cc文件

关于Predicate Lock的设计参阅官方WL#6609。

link

隐式锁

InnoDB 通常对插入操作无需加锁,而是通过一种“隐式锁”的方式来解决冲突。聚集索引记录中存储了事务id,如果另外有个session查询到了这条记录,会去判断该记录对应的事务id是否属于一个活跃的事务,并协助这个事务创建一个记录锁,然后将自己置于等待队列中。该设计的思路是基于大多数情况下新插入的记录不会立刻被别的线程并发修改,而创建锁的开销是比较昂贵的,涉及到全局资源的竞争。

关于隐式锁转换,上一期的月报InnoDB 事务子系统介绍我们已经介绍过了,这里不再赘述。
link

锁的冲突判定

锁模式的兼容性矩阵通过如下数组进行快速判定:

static const byte lock_compatibility_matrix[5][5] = {
/** IS IX S X AI /
/ IS / { TRUE, TRUE, TRUE, FALSE, TRUE},
/ IX / { TRUE, TRUE, FALSE, FALSE, TRUE},
/ S / { TRUE, FALSE, TRUE, FALSE, FALSE},
/ X / { FALSE, FALSE, FALSE, FALSE, FALSE},
/ AI / { TRUE, TRUE, FALSE, FALSE, FALSE}
};
对于记录锁而言,锁模式只有LOCK_S 和LOCK_X,其他的 FLAG 用于锁的描述,如前述 LOCK_GAP、LOCK_REC_NOT_GAP 以及 LOCK_ORDINARY、LOCK_INSERT_INTENTION 四种描述。在比较两个锁是否冲突时,即使不满足兼容性矩阵,在如下几种情况下,依然认为是相容的,无需等待(参考函数lock_rec_has_to_wait)

对于GAP类型(锁对象建立在supremum上或者申请的锁类型为LOCK_GAP)且申请的不是插入意向锁时,无需等待任何锁,这是因为不同Session对于相同GAP可能申请不同类型的锁,而GAP锁本身设计为不互相冲突;
LOCK_ORDINARY 或者LOCK_REC_NOT_GAP类型的锁对象,无需等待LOCK_GAP类型的锁;
LOCK_GAP类型的锁无需等待LOCK_REC_NOT_GAP类型的锁对象;
任何锁请求都无需等待插入意向锁。

表级锁

InnoDB的表级别锁包含五种锁模式:LOCK_IS、LOCK_IX、LOCK_X、LOCK_S以及LOCK_AUTO_INC锁,锁之间的相容性遵循数组lock_compatibility_matrix中的定义。

InnoDB表级锁的目的是为了防止DDL和DML的并发问题。但从5.5版本开始引入MDL锁后,InnoDB层的表级锁的意义就没那么大了,MDL锁本身已经覆盖了其大部分功能。以下我们介绍下几种InnoDB表锁类型。

LOCK_IS/LOCK_IX

也就是所谓的意向锁,这实际上可以理解为一种“暗示”未来需要什么样行级锁,IS表示未来可能需要在这个表的某些记录上加共享锁,IX表示未来可能需要在这个表的某些记录上加排他锁。意向锁是表级别的,IS和IX锁之间相互并不冲突,但与表级S/X锁冲突。

意向锁是表级别的,IS和IX锁之间相互并不冲突,但与表级S/X锁冲突。

在对记录加S锁或者X锁时,必须保证其在相同的表上有对应的意向锁或者锁强度更高的表级锁。

LOCK_X

当加了LOCK_X表级锁时,所有其他的表级锁请求都需要等待。通常有这么几种情况需要加X锁:

DDL操作的最后一个阶段(ha_innobase::commit_inlace_alter_table)对表上加LOCK_X锁,以确保没有别的事务持有表级锁。通常情况下Server层MDL锁已经能保证这一点了,在DDL的commit 阶段是加了排他的MDL锁的。但诸如外键检查或者刚从崩溃恢复的事务正在进行某些操作,这些操作都是直接InnoDB自治的,不走server层,也就无法通过MDL所保护;

当设置会话的autocommit变量为OFF时,执行LOCK TABLE tbname WRITE这样的操作会加表级的LOCK_X锁(ha_innobase::external_lock);

(默认autocommit为ON)

对某个表空间执行discard或者import操作时,需要加LOCK_X锁(ha_innobase::discard_or_import_tablespace)。

LOCK_S

在DDL的第一个阶段,如果当前DDL不能通过ONLINE的方式执行,则对表加LOCK_S锁(prepare_inplace_alter_table_dict);<mysql5.6

设置会话的autocommit为OFF,执行LOCK TABLE tbname READ时,会加LOCK_S锁(ha_innobase::external_lock)。

从上面的描述我们可以看到LOCK_X及LOCK_S锁在实际的大部分负载中都很少会遇到。主要还是互相不冲突的LOCK_IS及LOCK_IX锁。

一个有趣的问题是,每次加表锁时,却总是要扫描表上所有的表级锁对象,检查是否有冲突的锁。很显然,如果我们在同一张表上的更新并发度很高,这个链表就会非常长。

RDS

基于大多数表锁不冲突的事实,我们在RDS MYSQL中对各种表锁对象进行计数,在检查是否有冲突时,例如当前申请的是意向锁,如果此时LOCK_S和LOCK_X的锁计数都是0,就可以认为没有冲突,直接忽略检查。由于检查是在持有全局大锁lock_sys->mutex下进行的。在单表大并发下,这个优化的效果还是非常明显的,可以减少持有全局大锁的时间。

LOCK_AUTO_INC

AUTO_INC锁加在表级别,和AUTO_INC、表级S锁以及X锁不相容。锁的范围为SQL级别,SQL结束后即释放。AUTO_INC的加锁逻辑和InnoDB的锁模式相关,这里在简单介绍一下。

通常对于自增列,我们既可以显式指定该值,也可以直接用NULL,系统将自动递增并填充该列。我们还可以在批量插入时混合使用这两种方式。不同的分配方式,其具体行为受到参数innodb_autoinc_lock_mode的影响。但在基于STATEMENT模式复制时,可能会影响到复制的数据一致性,官方文档 有详细描述,不再赘述,只说明下锁的影响。
(默认innodb_autoinc_lock_mode开启)

link

自增锁模式通过参数innodb_autoinc_lock_mode来控制,加锁选择参阅函数ha_innobase::innobase_lock_autoinc

具体的,有以下几个值:

AUTOINC_OLD_STYLE_LOCKING(0)不开启

也就是所谓的传统加锁模式(在5.1版本引入这个参数之前的策略),在该策略下,会在分配前加上AUTO_INC锁,并在SQL结束时释放掉。该模式保证了在STATEMENT复制模式下,备库执行类似INSERT … SELECT这样的语句时的一致性,因为这样的语句在执行时无法确定到底有多少条记录,只有在执行过程中不允许别的会话分配自增值,才能确保主备一致。

很显然这种锁模式非常影响并发插入的性能,但却保证了一条SQL内自增值分配的连续性。

以上非ROW格式

AUTOINC_NEW_STYLE_LOCKING(1) 开启

这是InnoDB的默认值。在该锁模式下

普通的 INSERT 或 REPLACE 操作会先加一个dict_table_t::autoinc_mutex,然后去判断表上是否有别的线程加了LOCK_AUTO_INC锁,如果有的话,释放autoinc_mutex,并使用OLD STYLE的锁模式。否则,在预留本次插入需要的自增值之后,就快速的将autoinc_mutex释放掉。很显然,对于普通的并发INSERT操作,都是无需加LOCK_AUTO_INC锁的。因此大大提升了吞吐量;

但是对于一些批量插入操作,例如LOAD DATA,INSERT …SELECT 等还是使用OLD STYLE的锁模式,SQL执行期间加LOCK_AUTO_INC锁。

批量插入,SQL执行期间加LOCK_AUTO_INC锁。

和传统模式相比,这种锁模式也能保证STATEMENT模式下的复制安全性,但却无法保证一条插入语句内的自增值的连续性,并且在执行一条混合了显式指定自增值和使用系统分配两种方式的插入语句时,可能存在一定的自增值浪费。

自增值的连续性不能保证,新模式会事先判断然后预留。

例如执行SQL:

INSERT INTO t1 (c1,c2) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,’d’)

假设当前AUTO_INCREMENT值为101,在传统模式下执行完后,下一个自增值为103,而在新模式下,下一个可用的自增值为105,因为在开始执行SQL时,会先预取了[101, 104] 4个自增值,这和插入行的个数相匹配,然后将AUTO_INCREMENT设为105,导致自增值103和104被浪费掉。

注释:AUTO_INCREMENT值为101表示下次插入的ID起始值

AUTOINC_NO_LOCKING(2)

AUTOINC_NO_LOCKING(2)

这种模式下只在分配时加个mutex即可,很快就释放,不会像NEW STYLE那样在某些场景下会退化到传统模式。因此设为2不能保证批量插入的复制安全性。(不推荐,5.7.26已经没有了)

关于自增锁的小BUG

这是Mariadb的Jira上报的一个小bug,在row模式下,由于不走parse的逻辑,我们不知道行记录是通过什么批量导入还是普通INSERT产生的,因此command类型为SQLCOM_END,而在判断是否加自增锁时的逻辑时,是通过COMMAND类型是否为SQLCOM_INSERT或者SQLCOM_REPLACE来判断是否忽略加AUTO_INC锁。这个额外的锁开销,会导致在使用ROW模式时,InnoDB总是加AUTO_INC锁,加AUTO_INC锁又涉及到全局事务资源的开销,从而导致性能下降。

修复的方式也比较简单,将SQLCOM_END这个command类型也纳入考虑。

具体参阅Jira链接。,需登录
link

以上内容为淘宝月报摘录,篇幅有限,下篇继续摘录<事务锁管理>

本文说明,主要技术内容来自互联网技术大佬的分享,还有一些自我的加工(仅仅起到注释说明的作用)。如有相关疑问,请留言,将确认之后,执行侵权必删

猜你喜欢

转载自blog.csdn.net/baidu_34007305/article/details/111357699