MySQL升级WRITE_SET后的一次死锁分析

database

导致我们死锁的现象是: 我们发现开启了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和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

") 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线程的话,从库复制会出错误。

参考

  1. https://dev.mysql.com/doc/relnotes/mysql/5.7/en/news-5-7-22.html
  2. http://mysqlhighavailability.com/improving-the-parallel-applier-with-writeset-based-dependency-tracking/
  3. https://sourceware.org/systemtap/langref/

作者:龙利剑

  • 现在注册滴滴云,有机会可得30元无门槛滴滴出行券
  • 新购云服务1月5折 3月4.5折 6月低至4折
  • 滴滴云使者招募,推荐最高返佣50%

以上是 MySQL升级WRITE_SET后的一次死锁分析 的全部内容, 来源链接: utcz.com/z/532228.html

回到顶部