这篇文章将为大家详细讲解有关如何理解MySQL升级WRITE_SET后死锁的产生,文章内容质量较高,因此小编分享给大家做个参考,希望大家阅读完这篇文章后对相关知识有一定的了解。

背景

MySQL在推出MGR的时候使用了WRITE_SET, 借用这个思想, MySQL在5.7.22版本引入了基于WRITE_SET的并行复制方案[1]。在原先的主从复制技术中,同一批次的事物能进入事物的prepare阶段说明那批事物没有冲突,所以可以并发执行。我们都知道innodb是基于行锁的数据库,所以如果能够按照行级别的粒度来并发的回放数据会对性能有很大的提高。采用这套方案的性能优点就有很多方面了,其中一个可以简单看到的好处就是:我们在回放的时候就不用依赖于主上事物提交的情况了,正所谓less is more。减少了依赖,并行从宏观上也能按照逻辑行这样的来回放,所以性能肯定有很大的提升[2]. 故而,我们数据库这边在一些实例上启用了这个并行回放特性。

导致我们死锁的现象是: 我们发现开启了write_set并行回放的实例从库上死锁的概率比以前高了不少, 并且发生死锁的实例都是在进行xtrabackup备份。

场景

我们知道MySQL事物会设计到很多的锁,比如MDL锁,innodb的行锁,意向锁,latch 锁等等。不同的隔离级别锁的行为也有很多的差异。从死锁理论的角度:死锁就是有向图中存在环,从而造成相互等待。要解决死锁只要简单的破坏任何一条边,来打破环行等待。当然实际的实现可能会因各个环节点的权重不同而有所优化,选择代价最小的。但之前的重点肯定是找出这个“环”。而这些锁有些是运维的时候可以看到有些是看不到的。比如latch锁一般对用户看不到。因为性能原因,我们的MDL锁和INNODB锁的详细信息并未收集。如果开启了,就可以通过performance_schema.metadata_lock这个表来查询MDL锁的相关信息,通过show engine innodb status来查看详细innodb的加锁信息。

通过简单的分析,我们锁定是MDL死锁。所以在这样的场景下,我们只能通过show full processlist来查看到当时的状态,如下图:

case1:图1

case2:图2-1

图2-2

===

为了方便大家理解, 我画了一个示意图[图3]来解释这两个case的死锁情况:图3

case1 死锁分析:

可以看到在work线程组中,有一个work处理的事物先到达了事物的提交状态, 但是事物在提交前需要进行 order_commit判断,因为我们设置了slave_preserve_commit_order ,要保证事物是按照主库上的提交顺序来提交的。所以这个时候必须等待之前的事物要提交才可以进行。所以看到这个线程的状态是: "Waiting for preceding transaction to commit"。当那个"靠前"的事物准备提交的时候要去拿mdl::commit_lock这把锁,发现要不到。形成如上的“环等待”。

通过分析可以知道,这个时候同时执行了 FTWRL (flush table with read lock), 而这个操作会获取到MDL的一个共享锁。但是同样没有版本获取mdl::commit_lock 而等待。这个等待会造成新来的更新请求被阻塞,因为更新的语句是排他类型的锁。由于篇幅的原因,不细说MDL锁兼容细节。这里只给出结论,会阻塞部分更新的语句,进而会影响到业务。

===图4

case2 死锁分析:

顺便提一句: 同样可以看到,这种情况下新的请求被阻塞主。注意,这也正是备份的核心思想。阻塞新来的请求,阻塞同批次的提交。保证在备份的时候没有新的数据插入

一开始一个比较"靠后"的事物获取了mdl::commit_lock,在准备提交的时候,发现系统配置了slave_preserve_commit_order,同时该事物的前面还有事物未提交,需要等待前面的事物先执行完成后才能继续。然后FTWRL先获取了mdl::global_read_lock锁,但是没有办法获取mdl::commit_lock锁。

这个时候如果这个“前面的事物”是更新操作,那么就跟mdl::global_read_lock锁互斥,故而形成上面的死锁。

验证

由于这样的死锁,是概率出现的。为了高效的复现问题,我们打算使用mysql的测试框架来验证. 第一个步骤是:通过上面的分析,修改内核源码加大死锁的概率。证明我们的猜想确实能够出现死锁。但是这个出现的死锁并不一定就是线上真是环境的死锁。故而需要我们把修改的源码在实际场景下面验证。当然我们没有办法在生产环境来验证。我们可以通过第一步修改的源码,然后使用备份的数据来模拟。如果使用备份的数据 + 我们修改的源码数据库实例复现了,才能客观的判断我们的死锁研判。当然读者可能说我们修改源码破坏了之前的环境,这里当然是有前提的。这个前提就是:只修改并行回放线程组中的某一个线程,不改变原有逻辑,只是单纯的让它支持慢一点来提高死锁的概率,作证我们的死锁研判。

首先我们的第一步就是要:在主库上产生两个事物(当然我们也可以使用蛮力,循环,不过可能效果差,甚至可能无法复现),使用MySQL的测试框架,祥见如下的代码:

57#===========================58#在master上创建两个链接master和master159--sourceinclude/rpl_connection_master.inc60sendSETDEBUG_SYNC='waiting_in_the_middle_of_flush_stageSIGNALwWAIT_FORb';6162--sourceinclude/rpl_connection_master1.inc63sendSETDEBUG_SYNC='nowWAIT_FORw';6465--sourceinclude/rpl_connection_master.inc66--reap67showmasterstatus;68sendinsertintotest.t1values(1);6970--sourceinclude/rpl_connection_master1.inc71--reap72SETDEBUG_SYNC='bgc_after_enrolling_for_flush_stageSIGNALb';73insertintotest.t1values(1000);

如何验证我们的主库上这两个事物属于同一个批次呢?当然是binlog啦。结果如下:

showmasterstatus;FilePositionBinlog_Do_DBBinlog_Ignore_DBExecuted_Gtid_Setmaster-bin.000001849#2001079:26:14serverid1end_log_pos219CRC320x059fa77aAnonymous_GTIDlast_committed=0sequence_number=1rbr_only=no#2001079:26:24serverid1end_log_pos408CRC320xa1a6ea99Anonymous_GTIDlast_committed=1sequence_number=2rbr_only=yes#2001079:26:24serverid1end_log_pos661CRC320x2b0fc8a5Anonymous_GTIDlast_committed=1sequence_number=3rbr_only=yes

可以看到last_commit这个字段我们一共产生了两组binlog, 一个是0 这里是create table 语句。另外一个是1, 就是我们上面的两条insert 语句。

接下来就是就是要修改MySQL的源代码了,这里主要是要考虑到MTS的并行复制逻辑。因为我们在主库上通过DEBUG_SYNC让大的事物先执行,所以比如是大的事物先分配到woker线程组中的第一个。所以我们在binlog回放的关键路径上: Xid_apply_log_event::do_apply_event_worker 这个函数中让第一个worker sleep足够多的时间让我们执行FTWRL。

直接修改源代码编译需要来回的编译,我们这边使用systemstap 这个工具,JIT在运行时注入一段代码来改变某些worker的行为。在执行注入前先执行脚本验证下能否注入:

41--execsudostap-L'process("$MYSQLD").function("pop_jobs_item")'42--execsudostap-L'process("$MYSQLD").function("*Xid_apply_log_event::do_apply_event_worker")'

需要注意的是,因为stap的架构原理的原因,详细可参考下面的链接[3],需要root权限。下面是注入的代码:

stap-v-g-d$MYSQLD--ldd-e'probeprocess($server_pid).function("Xid_apply_log_event::Xid_apply_log_event"){printf("hitindo_apply_log_event\n")if($w->id==0){mdelay(30000)}}'stap-v-g-d$MYSQLD--ldd-e'probeprocess($server_pid).function("pop_jobs_item"){printf("hitinpop_jobs_item")if($worker->id==0){mdelay(3000)}}'

大致的意思就是: 让复制线程组的第一个线程sleep 3s。这样有足够的时间来运行FTWRL。最终的执行结果:

showfullprocesslist;IdUserHostdbCommandTimeStateInfo3rootlocalhost:10868testSleep83NULL4rootlocalhost:10870testSleep84NULL7rootlocalhost:10922testQuery61Waitingforcommitlockflushtablewithreadlock8rootlocalhost:10926testQuery0startingshowfullprocesslist9systemuserNULLConnect82WaitingformastertosendeventNULL10systemuserNULLConnect61Slavehasreadallrelaylog;waitingformoreupdatesNULL11systemuserNULLConnect71WaitingforglobalreadlockNULL12systemuserNULLConnect71WaitingforprecedingtransactiontocommitNULL13systemuserNULLConnect82WaitingforaneventfromCoordinatorNULL14systemuserNULLConnect81WaitingforaneventfromCoordinatorNULL

可以看到,我们的猜想完整的复现了死锁。大致解释下:

我们在构造这个死锁的时候,因为我们控制 的worker会sleep 3s。故而我们可以查询worker的状态,当worker处于 Waiting for preceding transaction to commit 这个状态的时候,立马执行FTWRL。然后可以看到FTWRL会block在commit_lock。然后另外一个更新自然是要等待: global read lock, 而形成死锁。

首先对于不太理解备份原理的同学,应该可以从这两个死锁等待图中清楚的看到FTWRL的作用。它是通过两把GLOBAL READ LOCK 和COMMIT_LOCK锁来控制备份的一致性。这里不详细讨论。 解决死锁问题,通过死锁理论,肯定是要打破有向图中的环。

在我们的这个死锁case中通过分析可以知道可以操作的两条边只有:

1. slave_preserve_commit_order
2. FTWRL 显然:对于那些可以接受在从库上事物的提交可以“乱序”的,我们只要关闭这个配置选项就可以解除死锁

而如果是要强制要求有序的,那么我们只能关闭备份的线程(图中的节点,及相关的边) 同样可以破解死锁。在死锁出现的时候,个人觉得关闭备份线程代码是更小的。如果关闭worker线程的话,从库复制会出错误。

关于如何理解MySQL升级WRITE_SET后死锁的产生就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。