提示:公众号展示代码会自动折行,建议横屏阅读
「引言」
本文的目的是对 InnoDB 的锁模块做个简单的介绍,使读者对这块有初步的认识。
此外,我们在对MySQL 5.7做性能分析的时候发现lock_sys mutex成为热点瓶颈,官方在MySQL 8.0上对lock_sys锁也做了很多优化,本文针对一些重大的性能优化做一些介绍。
MySQL lock 与 latch区别(本文主要介绍lock)
「第一部分 简介」
1.1 lock相关数据结构
struct lock_sys_t{ LockMutex mutex; hash_table_t* rec_hash; hash_table_t* prdt_hash; hash_table_t* prdt_page_hash; LockMutex wait_mutex; srv_slot_t* waiting_threads; srv_slot_t* last_slot; int n_waiting;};
struct lock_t { trx_t* trx; UT_LIST_NODE_T(lock_t) trx_locks; dict_index_t* index; lock_t* hash;
union { lock_table_t tab_lock; lock_rec_t rec_lock; };
uint32_t type_mode;}
struct lock_rec_t { ib_uint32_t space; ib_uint32_t page_no; ib_uint32_t n_bits;}
struct lock_table_t { dict_table_t* table; UT_LIST_NODE_T(lock_t) locks;}
lock_t::type_mode:可以区分锁类型表锁、行锁(gap锁,非gap锁等);锁是否处于lock_wait状态;共享锁,排他锁,自增锁等锁模式。
lock_sys_t,lock_t,lock_rec_t,lock_table_t关系如下图所示:
- 表锁和行锁共用数据结构lock_t;
- 行锁以page为单位进行管理,lock_t-->lock_rec_t-->n_bits,到bitmap位图查询某行是否有行锁;
- 通过设置位图表示该行有锁,同一page上的行锁可以共用一个lock_t。
1.2 锁兼容性矩阵和锁强度矩阵
/*用于判断不同事务的锁之间是否可以兼容*/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}};
/*用于判断当前事务上是否已经存在满足强度的锁,如果存在,则不需要继续加锁*/static const byte lock_strength_matrix[5][5] = { /** IS IX S X AI */ /* IS */ {TRUE, FALSE, FALSE, FALSE, FALSE}, /* IX */ {TRUE, TRUE, FALSE, FALSE, FALSE}, /* S */ {TRUE, FALSE, TRUE, FALSE, FALSE}, /* X */ {TRUE, TRUE, TRUE, TRUE, TRUE}, /* AI */ {FALSE, FALSE, FALSE, FALSE, TRUE}};
1.3 加锁/解锁流程
以update为例解析加锁/解锁流程:
其中lock_table为表锁加锁,lock_rec_lock为行锁加锁,下面会详细解释表锁和行锁的加锁过程。
1.3.1 表锁加锁流程(lock_table)
表锁加锁逻辑相对简单,步骤如下图所示:
1.3.2 行锁加锁流程(lock_rec_lock)
如下图所示:
- lock_rec_other_has_conflicting->lock_rec_has_to_wait表示行锁加锁前的兼容性检查,兼容则加锁,不兼容则进入锁等待;
- lock_rec_lock 首先走 lock_rec_lock_fast 逻辑,判断能否快速完成加锁; 若lock_rec_lock_fast失败,则通过lock_rec_lock_slow进行加锁;
- lock_rec_lock_fast表示满足一定条件可以共用之前已经创建的行锁(设置一下bitmap即可),条件:
以下条件满足一个则快速加锁失败:if (lock_rec_get_next_on_page(lock) //page上有多个行锁 || lock->trx != trx //已经拥有该锁的事务不是当前事务 || lock->type_mode != (mode | LOCK_REC) //已有的锁和要加的锁锁模式是否一致 || lock_rec_get_n_bits(lock) <= heap_no) { //n_bits是否足够描述大小为 heap_no 的行 status = LOCK_REC_FAIL;}
- lock_rec_lock_slow 首先调用lock_rec_has_expl判断该行是否有相同或者更强的锁,然后通过兼容性矩阵判断该行是否有不兼容的锁,若不兼容需要将锁设置为等待状态并进行死锁检测(RecLock::deadlock_check),死锁检测流程下文将详细介绍。
1.3.3 锁唤醒场景
加锁时若出现锁冲突,我们将该锁设置为lock_wait模式,并等待被唤醒重新加锁,以下为5种锁唤醒的场景。
1.3.4 解锁流程
事务提交时该事务将添加的所有行锁和表锁进行解锁,释放锁资源,lock_trx_release_locks,具体流程如下图所示:
1.4 行锁锁分裂、锁迁移、锁继承
行锁在一些特定场景中会发生变化,涉及锁分裂,迁移,继承等。
行锁类型:
LOCK_S、LOCK_X
GAP类型:
LOCK_GAP:只锁间隙
LOCK_REC_NO_GAP:只锁记录
LOCK_ORDINARY:锁记录和记录之前的间隙
LOCK_INSERT_INTENTION:插入意向锁,用于insert时检查锁冲突
每个行锁由锁类型和GAP类型组成,例如:LOCK_X|LOCK_ORDINARY 表示对记录和记录之前的间隙加排他锁,锁类型和GAP类型由type_mode控制。
1.4.1 锁分裂
插入的记录的间隙存在GAP锁,此时此GAP需分裂为两个GAP。
例:隔离级别RR
create table t1(c1 int primary key, c1 int unique)engine=innodb;
insert into t1 values(1,1)(2,2);
begin;# supremum 记录上加 LOCK_X|LOCK_GAP 锁住(1~)select * from t1 where c2=2 for update;# 发现插入(3,3)的间隙存在GAP锁,因此给(3,3)加LOCK_X | LOCK_GAP锁。这样依然锁住了(1~)insert into t1 values(3,3);
1.4.2 锁继承
删除的记录前存在GAP锁,此GAP锁会继承到要删除记录的下一条记录上。
例:隔离级别RR
create table t1(c1 int primary key, c1 int unique)engine=innodb;
insert into t1 values(1,1)(2,2);
1.4.3 锁迁移
B树节点发生分裂,合并,删除都会引发锁的变化。锁迁移的原则是,B树结构变化前后,锁住的范围保证不变。
- 节点分裂
假设原节点A(infimum,1,3,supremum) 向右分裂为B(infimum,1,supremum), C(infimum,3,supremum)两个节点。
假设原节点A上记录3锁为LOCK_S|LOCK_ORIDNARY,supremum为LOCK_S|LOCK_GAP,实际锁住了(1~) 锁迁移过程大致为:
1)将3上的gap锁迁移到C节点3上
2)将A上supremum迁移继承到C的supremum上
3)将C上最小记录3的锁迁移继承到B的supremum上
迁移完成后锁的情况如下 B节点:suprmum LOCK_S|LOCK_GAP C节点:3 LOCK_S|LOCK_ORINARY, suprmum LOCK_S|GAP。
迁移后仍然锁住了范围(1~)。
- 节点合并
以上述节点分裂的逆操作来讲述合并过程 B(infimum,1,supremum), C(infimum,3,supremum)两个节点,向左合并为A节点(infimum,1,3,supremum) 其中B,C节点锁情况如下:
B节点:suprmum LOCK_S|LOCK_GAP
C节点:3 LOCK_S|LOCK_ORINARY, suprmum LOCK_S|GAP
迁移流程如下(lock_update_merge_left):
1)将C节点锁记录3迁移到B节点
2)将B节点supremum迁移继承到A的supremum上
迁移后仍然锁住了范围(1~)。
- 节点删除
如果删除节点存在左节点,则将删除节点符合条件的锁,迁移继承到左节点supremum上;否则将删除节点符合条件的锁,迁移继承到右节点最小用户记录上。参考lock_update_discard。
「第二部分 优化一 」
二、避免使用lock_sys mutex
优化场景:发生锁等待时可以通过其它数据结构避免使用lock_sys mutex。
优化方式:
- 使用原子变量std::atomic<int> n_waiting{0}代替int n_waiting;
- 新增trx->lock->wait_lock_type;将lock_get_type_low(trx->lock.wait_lock)缓存到trx->lock.wait_lock_type。读取trx->lock.wait_lock_type只需持有trx->mutex。
「第三部分 优化二 」
三、异步死锁检测
异步死锁优化前,死锁检测由用户线程同步进行;
优化后,用户线程无需死锁检测,lock_wait_timeout_thread线程除了检测锁超时,还需要进行死锁检测。
以行锁为例(表锁类似,将lock_sys->rec_hash换成table->locks)
优化前死锁检测步骤:
- 利用欲加锁事务及新创建的行锁初始化DeadlockChecker::start和DeadlockChecker::m_wait_lock
- 遍历该record1对应的rec_hash bucket1,找到与wait_lock不兼容且处于wait状态的锁入栈
- 从栈中依次取锁并赋值给wait_lock
- 遍历record2哈希桶找到与wait_lock不兼容的锁,检查该锁对应的事务是否为start,若是则死锁,否则回到步骤2
- 如果死锁检测深度太深也认为是死锁,回滚当前事务
检测到死锁,选择当前事务或者m_wait_lock对应的事务进行回滚:
- 优先选择没有更新过非事务表事务回滚,因为非事务表不支持回滚。
- 选择 undo log 数加持有的锁数之和小的事务回滚,代价小。
优化思路:
- 为减少用户线程对lock_sys mutex的竞争,将死锁检测集中在lock_wait_timeout_thread进行处理,几乎可以做到lock_free。
- 新增trx_t::trx_lock_t::std::atomic<trx_t *> blocking_trx; 表示本事务加锁被阻塞,请求的锁被blocking_trx事务持有。
- 新增lock_wait_table_reservations;发生锁等待lock_wait_table_reservations , 并赋值给lock_sys->waiting_threads::slot->reservation_no,用于死锁检测时判断该slot是否已经发生变化。
优化后死锁检测步骤
//死锁检测lock_wait_update_schedule_and_check_for_deadlocks-----lock_wait_snapshot_waiting_threads //遍历lock_sys->waiting_threads,将锁等待任务都放入infos数组, //数组每个元素都包括持锁事务,等锁事务等-----lock_wait_build_wait_for_graph //利用outgoing数组建立锁等待关系, outing数组中存储的是对应infos数组 //锁等待事务所在的数组下标。例如infos[0]={trx1,trx2},infos[1]={trx2,trx5}, //那么outgoing[0]=1;-----lock_wait_find_and_handle_deadlocks //通过infos和outgoing数组我们可以找出是否存在死锁,找到形成死锁检测的环,并进行事务回滚。
「第四部分 优化三 」
四、lock_sys mutex锁拆分
4.1 优化思想
- lock_sys->lock_hash、table->locks等锁结构需要通过锁进行互斥。
- 不同事务可能会操作同一个表或页上的锁,需要通过mutex来互斥。将所有page和table分成一定数量的shards,每个shards分配一个mutex来保护。不同shards可以并发加锁。
- 页分裂、继承、迁移涉及两个page的加锁,可能分布在不同的shards。先加全局共享锁,再按一定顺序对shards加mutex。
- 少量操作会遍历所有shards获取锁信息,直接加全局排他锁,阻止其它线程对shards的访问。
lock_sys_t--->lock_sys::Latches latches 代替 lock_sys_t--->LockMutex mutex;
针对不同的加锁场景,新增了不同的锁类型(全局读写锁和分片mutex锁)。
4.2 锁分区优化具体实现
1.表锁加锁过程优化,lock_sys mutex -> 先全局共享锁,后对table所在分片加mutex。
2.行锁加锁过程优化,lock_sys mutex -> 先全局共享锁,后对page所在分片加mutex。
3.行锁/表锁解锁过程优化,lock_sys mutex -> 先全局共享锁,后对table/page所在分片加mutex。
解锁时,由于需要将事务的所有锁进行释放,简单的做法是先加全局共享锁,然后对事务的每个lock的shards加mutex进行lock释放。但存在一个约束:
为了遵守加锁规则,lock_sys相关的锁需在trx->mutex之前。因此在释放事务的每一个lock时需要先解锁trx->mutex,加锁shard->mutex,再加锁trx->mutex,释放锁资源后解锁shard->mutex。
4.lock_sys mutex -> Global_exclusive_latch_guard(全局排他锁)使用场景。
5.lock_sys mutex -> Global_exclusive_try_latch(全局排他锁)使用场景。
6.lock_sys mutex -> Shard_latches_guard(先加全局共享,再对两个page所在shards加mutex)。
为了避免死锁,对不同page shards加锁时需遵守加锁规则:shards->mutex地址大小顺序加锁。
「第五部分 小结 」
Lock Manager是Innodb中较为复杂的一个模块,本文深入浅出,希望对大家有所帮助。
尤其是在MySQL 5.7及以下版本中遇到lock_sys锁存在热点瓶颈时,可以考虑升级到MySQL 8.0.21及以上版本。
腾讯数据库技术团队对内支持QQ空间、微信红包、腾讯广告、腾讯音乐、腾讯新闻等公司自研业务,对外在腾讯云上依托于CBS CFS的底座,支持TencentDB相关产品,如CynosDB、CDB、CTSDB、MongoDB、CES等。腾讯数据库技术团队专注于持续优化数据库内核和架构能力,提升数据库性能和稳定性,为腾讯自研业务和腾讯云客户提供“省心、放心”的数据库服务。此公众号旨在和广大数据库技术爱好者一起推广和分享数据库领域专业知识,希望对大家有所帮助。