MySQL在推出MGR的时候使用了WRITE_SET, 借用这个思想, MySQL在5.7.22版本引入了基于WRITE_SET的并行复制方案[1]。在原先的主从复制技术中,同一批次的事物能进入事物的prepare阶段说明那批事物没有冲突,因此能够并发执行。咱们都知道innodb是基于行锁的数据库,因此若是可以按照行级别的粒度来并发的回放数据会对性能有很大的提升。采用这套方案的性能优势就有不少方面了,其中一个能够简单看到的好处就是:咱们在回放的时候就不用依赖于主上事物提交的状况了,正所谓less is more。减小了依赖,并行从宏观上也能按照逻辑行这样的来回放,因此性能确定有很大的提高[2]. 故而,咱们数据库这边在一些实例上启用了这个并行回放特性。html
致使咱们死锁的现象是: 咱们发现开启了write_set并行回放的实例从库上死锁的几率比之前高了很多, 而且发生死锁的实例都是在进行xtrabackup备份。本文主要分析这些数据库实例上发生死锁的缘由。mysql
咱们知道MySQL事物会设计到不少的锁,好比MDL锁,innodb的行锁,意向锁,latch
锁等等。不一样的隔离级别锁的行为也有不少的差别。从死锁理论的角度:死锁就是有向图中存在环,从而形成相互等待。要解决死锁只要简单的破坏任何一条边,来打破环行等待。固然实际的实现可能会因各个环节点的权重不一样而有所优化,选择代价最小的。但以前的重点确定是找出这个“环”。而这些锁有些是运维的时候能够看到有些是看不到的。好比latch锁通常对用户看不到。由于性能缘由,咱们的MDL锁和INNODB锁的详细信息并未收集。若是开启了,就能够经过performance_schema.metadata_lock这个表来查询MDL锁的相关信息,经过show engine innodb status来查看详细innodb的加锁信息。sql
经过简单的分析,咱们锁定是MDL死锁。因此在这样的场景下,咱们只能经过show full processlist来查看到当时的状态,以下图:数据库
case1:bash
图1架构
case2:并发
图2-1app
===框架
为了方便你们理解, 我画了一个示意图[图3]来解释这两个case的死锁状况:less
图3
能够看到在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
顺便提一句: 一样能够看到,这种状况下新的请求被阻塞主。注意,这也正是备份的核心思想。阻塞新来的请求,阻塞同批次的提交。保证在备份的时候没有新的数据插入
一开始一个比较"靠后"的事物获取了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和master1
59 --source include/rpl_connection_master.inc
60 send SET DEBUG_SYNC='waiting_in_the_middle_of_flush_stage SIGNAL w WAIT_FOR b';
61
62 --source include/rpl_connection_master1.inc
63 send SET DEBUG_SYNC= 'now WAIT_FOR w';
64
65 --source include/rpl_connection_master.inc
66 --reap
67 show master status;
68 send insert into test.t1 values(1);
69
70 --source include/rpl_connection_master1.inc
71 --reap
72 SET DEBUG_SYNC= 'bgc_after_enrolling_for_flush_stage SIGNAL b';
73 insert into test.t1 values(1000);
复制代码
如何验证咱们的主库上这两个事物属于同一个批次呢?固然是binlog啦。结果以下:
show master status;
File Position Binlog_Do_DB Binlog_Ignore_DB Executed_Gtid_Set
master-bin.000001 849
#200107 9:26:14 server id 1 end_log_pos 219 CRC32 0x059fa77a Anonymous_GTID last_committed=0 sequence_number=1 rbr_only=no
#200107 9:26:24 server id 1 end_log_pos 408 CRC32 0xa1a6ea99 Anonymous_GTID last_committed=1 sequence_number=2
rbr_only=yes
#200107 9:26:24 server id 1 end_log_pos 661 CRC32 0x2b0fc8a5 Anonymous_GTID last_committed=1 sequence_number=3 rbr_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 --exec sudo stap -L 'process("$MYSQLD").function("pop_jobs_item")'
42 --exec sudo stap -L 'process("$MYSQLD").function("*Xid_apply_log_event::do_apply_event_worker")'
复制代码
须要注意的是,由于stap的架构原理的缘由,详细可参考下面的连接[3],须要root权限。下面是注入的代码:
stap -v -g -d $MYSQLD --ldd -e 'probe process($server_pid).function("Xid_apply_log_event ::Xid_apply_log_event ") {printf("hit in do_apply_log_event\n") if ($w->id ==0) { mdelay(30000)} }'
stap -v -g -d $MYSQLD --ldd -e 'probe process($server_pid).function("pop_jobs_item") { printf("hit in pop_jobs_item") if ($worker->id == 0) { mdelay(3000)} }'
复制代码
大体的意思就是: 让复制线程组的第一个线程sleep 3s。这样有足够的时间来运行FTWRL。最终的执行结果:
show full processlist;
Id User Host db Command Time State Info
3 root localhost:10868 test Sleep 83 NULL
4 root localhost:10870 test Sleep 84 NULL
7 root localhost:10922 test Query 61 Waiting for commit lock flush table with read lock
8 root localhost:10926 test Query 0 starting show full processlist
9 system user NULL Connect 82 Waiting for master to send event NULL
10 system user NULL Connect 61 Slave has read all relay log; waiting for more updates NULL
11 system user NULL Connect 71 Waiting for global read lock NULL
12 system user NULL Connect 71 Waiting for preceding transaction to commit NULL
13 system user NULL Connect 82 Waiting for an event from Coordinator NULL
14 system user NULL Connect 81 Waiting for an event from Coordinator NULL
复制代码
能够看到,咱们的猜测完整的复现了死锁。大体解释下:
咱们在构造这个死锁的时候,由于咱们控制 的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线程的话,从库复制会出错误。