事务锁管理(来自淘宝月报)

背景

承接上篇《InnoDB 事务锁系统简介》 摘自数据库内核月报 - 2016 / 01 http://mysql.taobao.org/monthly/2016/01/01/
link

以下基于MySQL5.7.10 版本

事务锁管理

InnoDB 所有的事务锁对象都是挂在全局对象lock_sys上,同时每个事务对象上也维持了其拥有的事务锁,每个表对象(dict_table_t)上维持了构建在其上的表级锁对象。

如下图所示:
在这里插入图片描述

加表级锁

首先从当前事务的trx_lock_t::table_locks中查找是否已经加了等同或更高级别的表锁,如果已经加锁了,则直接返回成功(lock_table_has);

检查当前是否有和正在申请的锁模式冲突的表级锁对象(lock_table_other_has_incompatible);
直接遍历链表dict_table_t::locks链表

如果存在冲突的锁对象,则需要进入等待队列(lock_table_enqueue_waiting)

创建等待锁对象 (lock_table_create)
检查是否存在死锁(DeadlockChecker::check_and_resolve),当存在死锁时:如果当前会话被选作牺牲者,就移除锁请求(lock_table_remove_low),重置当前事务的wait_lock为空,并返回错误码DB_DEADLOCK;若被选成胜利者,则锁等待解除,可以认为当前会话已经获得了锁,返回成功;

若没有发生死锁,设置事务对象的相关变量后,返回错误码DB_LOCK_WAIT,随后进入锁等待状态

如果不存在冲突的锁,则直接创建锁对象(lock_table_create),加入队列。

lock_table_create: 创建锁对象

递增dict_table_t::n_waiting_or_granted_auto_inc_locks。前面我们已经提到过,当这个值非0时,对于自增列的插入操作就会退化到OLD-STYLE;
锁对象直接引用已经预先创建好的dict_table_t::autoinc_lock,并加入到trx_t::autoinc_locks集合中;

对于非AUTO-INC锁,则从一个pool中分配锁对象
在事务对象trx_t::lock中,维持了两个pool,一个是trx_lock_t::rec_pool,预分配了一组锁对象用于记录锁分配,另外一个是trx_lock_t::table_pool,用于表级锁的锁对象分配。通过预分配内存的方式,可以避免在持有全局大锁时(lock_sys->mutex)进行昂贵的内存分配操作。rec_pool和table_pool预分配的大小都为8个锁对象。(lock_trx_alloc_locks);
如果table_pool已经用满,则走内存分配,创建一个锁对象;

构建好的锁对象分别加入到事务的trx_t::lock.trx_locks链表上以及表对象的dict_table_t::locks链表上;
构建好的锁对象加入到当前事务的trx_t::lock.table_locks集合中。

可以看到锁对象会加入到不同的集合或者链表中,通过挂载到事务对象上,可以快速检查当前事务是否已经持有表锁;通过挂到表对象的锁链表上,可以用于检查该表上的全局冲突情况。

加行级锁

行级锁加锁的入口函数为lock_rec_lock,其中第一个参数impl如果为TRUE,则当当前记录上已有的锁和LOCK_X | LOCK_REC_NOT_GAP不冲突时,就无需创建锁对象。(见上文关于记录锁LOCK_X相关描述部分),为了描述清晰,下文的流程描述,默认impl为FALSE。

lock_rec_lock:

首先尝试fast lock的方式,对于冲突少的场景,这是比较普通的加锁方式(lock_rec_lock_fast), 符合如下情况时,可以走fast lock:
记录所在的page上没有任何记录锁时,直接创建锁对象,加入rec_hash,并返回成功;
记录所在的page上只存在一个记录锁,并且属于当前事务,且这个记录锁预分配的bitmap能够描述当前的heap no(预分配的bit数为创建锁对象时的page上记录数 + 64,参阅函数RecLock::lock_size),则直接设置对应的bit位并返回;

无法走fast lock时,再调用slow lock的逻辑(lock_rec_lock_slow)
判断当前事务是否已经持有了一个优先级更高的锁,如果是的话,直接返回成功(lock_rec_has_expl);
检查是否存在和当前申请锁模式冲突的锁(lock_rec_other_has_conflicting),如果存在的话,就创建一个锁对象(RecLock::RecLock),并加入到等待队列中(RecLock::add_to_waitq),这里会进行死锁检测;
如果没有冲突的锁,则入队列(lock_rec_add_to_queue):已经有在同一个Page上的锁对象且没有别的会话等待相同的heap no时,可以直接设置对应的bitmap(lock_rec_find_similar_on_page);否则需要创建一个新的锁对象;

返回错误码,对于DB_LOCK_WAIT, DB_DEADLOCK等错误码,会在上层进行处理。

等待及死锁判断

当发现有冲突的锁时,调用函数RecLock::add_to_waitq进行判断

如果持有冲突锁的线程是内部的后台线程(例如后台dict_state线程),这个线程不会被一个高优先级的事务取消掉,因为总是优先保证内部线程正常执行;

比较当前会话和持有锁的会话的事务优先级,调用函数trx_arbitrate 返回被选作牺牲者的事务;

当前发起请求的会话是后台线程,但持有锁的会话设置了高优先级时,选择当前线程作为牺牲者;

持有锁的线程为后台线程时,在第一步已经判断了,不会选作牺牲者;

如果两个会话都设置了优先级,低优先级的被选做牺牲者,优先级相同时,请求者被选做牺牲者(thd_tx_arbitrate);

PS: 目前最新版本的5.7还不支持用户端设置线程优先级(但增加一个配置session变量的接口非常容易);

如果当前会话的优先级较低,或者另外一个持有锁的会话为后台线程,这时候若当前会话设置了优先级,直接报错,并返回错误码DB_DEADLOCK;

默认不设置优先级时,请求锁的会话也会被选作victim_trx,但只创建锁等待对象,不会直接返回错误;

当持有锁的会话被选作牺牲者时,说明当前会话肯定设置了高优先级,这时候会走RecLock::enqueue_priority的逻辑;

如果持有锁的会话在等待另外一个不同的锁时,或者持有锁的事务不是readonly的,当前会话会被回滚掉;

开始跳队列,直到当前会话满足加锁条件(RecLock::jump_queue)

请求的锁对象跳过阻塞它的锁对象,直接操作hash链表,将锁对象往前挪;
从当前lock,向前遍历链表,逐个判断是否有别的会话持有了相同记录上的锁(RecLock::is_on_row),并将这些会话标记为回滚(mark_trx_for_rollback),同时将这些事务对象搜集下来,以待后续处理(但直接阻塞当前会话的事务会被立刻回滚掉);

高优先级的会话非常具有杀伤力,其他低优先级会话即使拿到了锁,也会被它所干掉。

不过实际场景中,我们并没有多少机会去设置事务的优先级,这里先抛开这个话题,只考虑默认的场景,即所有的事务优先级都未设置。

摘录者,个人理解,注释:经常是回滚产生undo量少的事务,也就是cost开销少的。

在创建了一个处于WAIT状态的锁对象后,我们需要进行死锁检测(RecLock::deadlock_check),死锁检测采用深度优先遍历的方式,通过事务对象上的trx_t::lock.wait_lock构造事务的wait-for graph进行判断,当最终发现一个锁请求等待闭环时,可以判定发生了死锁。另外一种情况是,如果检测深度过长(即锁等待的会话形成的检测链路非常长),也会认为发生死锁,最大深度默认为LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK,值为200。

摘录者,个人理解,注释:死锁检测采用深度优先遍历的方式,5.6和5.7版本,早期不是

当发生死锁时,需要选择一个牺牲者(DeadlockChecker::select_victim())来解决死锁,通常事务权重低的回滚(trx_weight_ge)。

修改了非事务表的会话具有更高的权重;

如果两个表都修改了、或者都没有修改事务表,那么就根据的事务的undo数量加上持有的事务锁个数来决定权值(TRX_WEIGHT);

低权重的事务被回滚,高权重的获得锁对象。

Tips:对于一个经过精心设计的应用,我们可以从业务上避免死锁,而死锁检测本身是通过持有全局大锁来进行的,代价非常高昂,在阿里内部的应用中,由于有专业的团队来保证业务SQL的质量,我们可以选择性的禁止掉死锁检测来提升性能,尤其是在热点更新场景,带来的性能提升非常明显,极端高并发下,甚至能带来数倍的提升。

当无法立刻获得锁时,会将错误码传到上层进行处理(row_mysql_handle_errors)

DB_LOCK_WAIT

具有高优先级的事务已经搜集了会阻塞它的事务链表,这时候会统一将这些事务回滚掉(trx_kill_blocking);

将当前的线程挂起(lock_wait_suspend_thread),等待超时时间取决于session级别配置(innodb_lock_wait_timeout),默认为50秒;

这个建议改小一点

如果当前会话的状态设置为running,一种是被选做死锁检测的牺牲者,需要回滚当前事务,另外一种是在进入等待前已经获得了事务锁,也无需等待;

获得等待队列的一个空闲slot。(lock_wait_table_reserve_slot)

系统启动时,已经创建好了足够用的slot数组,类型为srv_slot_t,挂在lock_sys->waiting_threads上;

分配slot时,从slot数组的第一个元素开始遍历,直到找到一个空闲的slot。注意这里存在的一个性能问题是,如果挂起的线程非常多,每个新加入挂起等待的线程都需要遍历直到找到一个空闲的slot。 实际上如果每次遍历都从上次分配的位置往后找,到达数组末尾在循环到数组头,这样可以在高并发高锁冲突场景下获得一定的性能提升;

如果会话在innodb层(通常为true),则强制从InnoDB层退出,确保其不占用innodb_thread_concurrency的槽位。然后进入等待状态。被唤醒后,会再次强制进入InnoDB层;

线程并发性参数:
 show variables like 'innodb_thread_concurrency';
+---------------------------+-------+
| Variable_name             | Value |
+---------------------------+-------+
| innodb_thread_concurrency | 0     |
+---------------------------+-------+

被唤醒后,释放slot(lock_wait_table_release_slot);

若被选作死锁的牺牲者了,返回上层回滚事务;若等待超时了,则根据参数innodb_rollback_on_timeout的配置,默认为OFF只回滚当前SQL,设置为ON表示回滚整个事务。

show variables like 'innodb_rollback_on_timeout';
+----------------------------+-------+
| Variable_name              | Value |
+----------------------------+-------+
| innodb_rollback_on_timeout | OFF   |
+----------------------------+-------+

默认为OFF只回滚当前SQL

DB_DEADLOCK: 直接回滚当前事务

释放锁及唤醒

大多数情况下事务锁都是在事务提交时释放,但有两种意外:

AUTO-INC锁在SQL结束时直接释放(innobase_commit --> lock_unlock_table_autoinc);

在RC隔离级别下执行DML语句时,从引擎层返回到Server层的记录,如果不满足where条件,则需要立刻unlock掉(ha_innobase::unlock_row)。

在RC隔离级别下执行DML语句时,从引擎层返回到Server层的记录,如果不满足where条件,则需要立刻unlock掉(ha_innobase::unlock_row)。

除这两种情况外,其他的事务锁都是在事务提交时释放的(lock_trx_release_locks --> lock_release)。事务持有的所有锁都维护在链表trx_t::lock.trx_locks上,依次遍历释放即可。

对于行锁,从全局hash中删除后,还需要判断别的正在等待的会话是否可以被唤醒(lock_rec_dequeue_from_page)。例如如果当前释放的是某个记录的X锁,那么所有的S锁请求的会话都可以被唤醒。

这里的移除锁和检查的逻辑开销比较大,尤其是大量线程在等待少量几个锁时。当某个锁从hash链上移除时,InnoDB实际上通过遍历相同page上的所有等待的锁,并判断这些锁等待是否可以被唤醒。而判断唤醒的逻辑又一次遍历,这是因为当前的链表维护是基于<space, page no>的,并不是基于Heap no构建的。关于这个问题的讨论,可以参阅bug#53825。官方开发Sunny也提到虽然使用<space, page no, heap no>来构建链表,移除Bitmap会浪费更多的内存,但效率更高,而且现在的内存也没有以前那么昂贵。

对于表锁,如果表级锁的类型不为LOCK_IS,且当前事务修改了数据,就将表对象的dict_table_t::query_cache_inv_id设置为当前最大的事务id。在检查是否可以使用该表的Query Cache时会使用该值进行判断(row_search_check_if_query_cache_permitted),如果某个用户会话的事务对象的low_limit_id(即最大可见事务id)比这个值还小,说明它不应该使用当前table cache的内容,也不应该存储到query cache中。

表级锁对象的释放调用函数lock_table_dequeue。

注意在释放锁时,如果该事务持有的锁对象太多,每释放1000(LOCK_RELEASE_INTERVAL)个锁对象,会暂时释放下lock_sys->mutex再重新持有,防止InnoDB hang住。

两个有趣的案例

普通的并发插入导致的死锁

create table t1 (a int primary key); 开启三个会话执行: insert into t1(a) values (2);

session 1 session 2 session 3
BEGIN; INSERT…
INSERT (block),为session1创建X锁,并等待S锁
INSERT (block, 同上等待S锁)
ROLLBACK,释放锁
获得S锁 获得S锁
申请插入意向X锁,等待session3
申请插入意向X锁,等待session2

上述描述了互相等待的场景,因为插入意向X锁和S锁是不相容的。这也是一种典型的锁升级导致的死锁。如果session1执行COMMIT的话,则另外两个线程都会因为duplicate key失败。

这里需要解释下为何要申请插入意向锁,因为ROLLBACK时原记录回滚时是被标记删除的。而我们尝试插入的记录和这个标记删除的记录是相邻的(键值相同),根据插入意向锁的规则,插入位置的下一条记录上如果存在与插入意向X锁冲突的锁时,则需要获取插入意向X锁。

另外一种类似(但产生死锁的原因不同)的场景是在一张同时存在聚集索引和唯一索引的表上,通过replace into的方式插入冲突的唯一键,可能会产生死锁,在3月份的月报,我已经专门描述过这个问题,感兴趣的可以延伸阅读下。

又一个并发插入的死锁现象

两个会话参与。在RR隔离级别下

例表如下:
create table t1 (a int primary key ,b int);
insert into t1 values (1,2),(2,3),(3,4),(11,22);
session 1 session 2
begin;select * from t1 where a = 5 for update;(获取记录(11,22)上的GAP X锁)
begin;select * from t1 where a = 5 for update; (同上,GAP锁之间不冲突
insert into t1 values (4,5); (block,等待session1)
insert into t1 values (4,5);(需要等待session2,死锁)

个人理解,上表中应该是session 1和session 2互相等待(我并没有改变原文内容)

引起这个死锁的原因是非插入意向的GAP X锁和插入意向X锁之间是冲突的。

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

猜你喜欢

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