1. 背景
在日常业务开发中,经常可能需要对MySQL中数据进行导出、导入,最常使用的工具便是mysqldump。mysqldump功能很丰富,可以支持导出表结构、导出整个库数据、导出指定表数据、甚至导出满足指定条件的数据到本地文件以及从一个库中将数据导入另一个库。本文不打算介绍mysqldump具体用法,网上也有很多相关介绍文档可以参考,本文中主要想介绍下mysqldump跨库导数据时存在的一个“坑”——“假死锁”现象,以及分析下该现象产生原因及解决办法,帮助大家在今后的使用过程中掉进"坑"。这里“假死锁”是我自己取得名字^-^!,之所以称之为"假"死锁,是因为其表现结果和死锁一样,两个进程都互相阻塞,但其本质并非是两个进程互相锁死了对方导致的。
2. 问题复现
mysqldump跨库导数据基本命令格式如下:
mysqldump --host=h1 -uroot -proot --databases db1 | mysql --host=h2 -uroot -proot db2
复制代码
上述命令在在跨服务器的数据库之间(h1、h2不同)导数据时没有任何问题,但是在同一个服务器的不同库之间(h1、h2相同, db1、db2不同)导数据时可能会发生死锁现象。下面先把问题重现一下,首先在本地MySQL Server中准备两个库(注意: 是同一个MySQL Server中两个不同的库)test库和test1库,其中test库中部分表信息如图1所示,test1库是个空库,不包含任何表结构和数据, 另外再有一台公有云MySQL Server中的test库,也是一个空库。
图 1: test库中表信息
首先尝试通过mysqldump将本地MySQL test库的数据导入到远程MySQL的test库中:
mysqldump -h127.0.0.1 -P3306 -uroot --databases test | mysql --host=cd-cdb-1ygx58w2.sql.tencentcdb.com -P3306 -uxxxx -p'test123!' test
复制代码
执行结果:
命令执行正常
切换至远程MySQL Server,可以看到test库中已经有相关表及数据
图 2: 跨服务器整库数据导出
然后尝试通过mysqldump将本地MySQL Server 中test库中的表及数据导入到同一个MySQL Server下test1库中,命令如下,结果将出现图3所示现在:
mysqldump -h127.0.0.1 -P3306 -uroot --databases test | mysql --host=127.0.0.1 -P3306 -uroot test1;
复制代码
执行结果:
命令卡死在某处一直无法返回
Ctrl+C强制终止命令后,检查test1库会发现表结构及相关数据都没迁移过来。
图 3: 同一个MySQL Server夸库导整库命令卡死
将上述命令稍微改下(如下),只导某张表数据(activity_info)则命令可以瞬间执行完成,然后切换到test1数据库下也可以看到activity_info表及其数据都成功从test数据库导入过来了,最终导数据结果如图4所示:
mysqldump -h127.0.0.1 -P3306 -uroot --databases test --tables activity_info | mysql --host=127.0.0.1 -P3306 -uroot test1;
复制代码
图 4: 同一个MySQL Server下跨库导单表命令执行正常
3. 原因分析
图 5: mysqldump输出(也即mysql输入)
针对图3中“卡死”现象,首先我们可以分析下mysqldump | mysql这个命令的执行方式。
这个命令本质上会产生两个进程,一个是mysqldump进程,它负责从数据库将表及数据导出,并将输出写入到管道中(写入到管道的数据即mysqldump导出到普通文本的数据,内容及格式如图5所示),对应数据导出的过程;另一个是mysql进程,它的作用则是从管道读取(命令)数据,并发送到MySQL Server执行,对应数据导入过程,并且这两个进程是并发执行的,通过ps命令很容易查看到这两个进程的存在。
图 6: mysqldump | mysql 命令产生的两个进程信息
既然如此,那我们应该可以大胆的猜测图3中“卡死”现象是由于发生了死锁。为了验证猜想,可以在mysql中通过show processlist命令查看当前连入服务器的进程执行情况如下:
图 6: MySQL Server中进程执行情况
果不其然,此时总共有三个进程在执行,其中一个(id为12301)的进程为当前执行show processlist命令的进程,可以忽略。 另外两个进程,id为12407的进程为mysqldump数据导出进程,可以看到改进出目前处理Sleep状态,并未正在执行数据导出操作;而另一个id为12408的进程,该进程为mysql进程,并且该进程也处于一个等待锁的状态(waiting for table metadata lock), 并且可以看到该进程当前阻塞的命令为: DROP TABLE IF EXISTS `account_map`,注意这条sql正好是mysqldump输出的数据的第3行。下面我们就分别分析下这两个进程都阻塞的原因。
3.1 mysql进程为何阻塞?
首先,分析下mysql进程阻塞的原因。图6已经展示的很清楚,mysql想要尝试删除掉account_map这个表,要执行删除操作需要先获取一个table metadata lock(查了下,mysql对表执行DDL操作时都需要先获取表级元数据锁——metadata lock,DROP正好就是DDL操作),不巧,这个锁正好被其它进程占用了,因此它只能阻塞等待锁;那么锁究竟是被那个进程占有了呢? 当然是mysqldump(id=12407)这个进程了啊!!! (别问我咋知道的,show processlist显示,当前就mysql和mysqldump这两个进程,总不能mysql自己把自己锁死了吧, 哈哈哈哈!) 。这一点应该很好理解,mysqldump导出数据时本身就会对表加上读锁,防止导出过程中数据被篡改,最终导致数据不一致的问题(不了解的同学查询下mysqldump相关介绍资料就知道了,一般都会介绍到这点)。但问题是: mysqldump进程操作的是test库,而mysql进程写入的是test1库,这两个进程都没有操作同一张表甚至是同一个库,怎么会竞争同一个表的元数据锁呢?为何?
原因请看图5,mysqldump的输出结果第二行,啊! 恍然大悟,mysqldump --database这个命令生成的sql里面开头处会有"USE 'test'"这条, 这样虽然mysql进程指定了往test1库写数据,然而在执行了USE 'test'这条语句后mysql进程当前执行环境实际上是切换到了test库(此时你还浑然不知^-^!!!)。因此在这条命令后mysql进程和mysqldump进程都在同时操作test里的表,而mysqldump开始导出数据前已经对表加了锁,所以会导致mysql进程在执行DROP操作时无法获取锁而被阻塞!
3.2 mysqldump进程为何阻塞?
从图6可以看到mysqldump进程(id=12407)处于Sleep阻塞状态,并且从State和Info可以看出它阻塞的原因和前面mysql进程阻塞的原因是不一样的,mysqldump并不是在等待获取什么锁而进入阻塞状态。那我们想想,除了获取锁失败以外还有哪些原因可以导致一个进程阻塞的呢?不妨我们看看下面操作系统进程状态转换图。
图 7: 操作系统进程状态转换
原因是不是很清晰了,原因是什么? I/O,当进程IO阻塞的时候就会导致该进程进入Sleep阻塞状态。那我们来看看mysqldump此时在做哪些IO操作,无非是从数据库读数据以及往管道写数据。那么这两步究竟是哪一步发生了IO阻塞呢?刚才已经知道mysqldump已经获取了数据库中表的读锁,按理从数据库读数据应该不会发生阻塞的情况才对了,因此更大的可能就是往管道写数据时阻塞住了。
我们都知道shell的管道缓冲区(pipe buffer)容量是有限的, 如果缓冲区满了, 那么mysqldump就不能再继续往里面写数据,因此只能阻塞。再结合前面mysql进程因为无法获取锁,所以也就无法及时从管道把数据读取走,而mysqldump一直往管道写数据,直到某一刻管道满了,mysqldump无法继续写入而阻塞,而此时它还一直占着数据库锁。这样就导致了mysql和mysqldump互相“死锁”的问题。但之所以称之为"假死锁",是因为mysql进程和mysqldump进程并不是因为互相占有了对方需要的锁导致的,mysql进程确实是因为需要的锁被mysqldump进程占用了,但mysqldump阻塞是因为IO的原因而不是因为需要的锁被mysql占有了。
3.3 只导单个表时为何可以成功?
图4中在同一个MySQL Server下不同库建导单表数据时,没有发生导整库时的"死锁"现象。原因是一下命令的输入里面不会带有USE命令,因此不会出现mysql和mysqldump同时操作同一个库的问题;其次,单表的数据量会小很多,导致管道容量溢出的可能性也更小。
mysqldump -h127.0.0.1 -P3306 -uroot --databases test --tables activity_info
复制代码
3.4 小结
mysql进程阻塞是因为mysqldump -h127.0.0.1 -P3306 -uroot --databases生成的sql开头处带有USE命令将当前sql执行环境悄悄切换了源库上,导致mysql进程和mysqldump进程同时操作相同的表,而mysql进程在执行DDL操作时尝试去获取已经被mysqldump占有的表元数据锁时失败,从而阻塞在获取锁操作上。
mysqldump阻塞是因为mysql因为获取表锁失败无法从管道读取数据,而mysqldump还在一直往管道写数据,当管道缓冲区满了以后,mysqldump也无法再继续往管道写数据,从而阻塞在IO上。
最终根本原因还是由于mysql进程获取锁失败导致的,如果mysql进程可以往下执行,则不会出现管道缓冲区一直处于满的情况,mysqldump进程也不会一直处于IO阻塞状态。
4. 解决方案
从上面的分析中,我们已经知道mysqldump在跨库导整库数据时,如果源库和目的库都在在同一个MySQL Server实例下,会存在"假死锁"问题。因此在同一个数据库实例的不同库间导数据时,要注意和跨数据库实例导数据的区别。 为避免上述问题,可以采用以下几种替代方案:
mysqldump不加--databases或-B选项
mysqldump -h127.0.0.1 -P3306 -uroot test | mysql --host=127.0.0.1 -P3306 -uroot test1;
复制代码
这条命令和之前出问题的命令相比少了个--databases(简写-B)参数,其区别就在于生成的sql不会带USE命令,从而不会出现mysqldump和mysql操作同一个库表而竞争表锁的问题。
先导表结构:
mysqldump -h127.0.0.1 -P3306 -uroot -d test | mysql --host=127.0.0.1 -P3306 -uroot test1
复制代码
上述命令会将test中的表结构导入到test1中,只导表结构,不含表数据,并且mysqldump输出sql不含USE,不会出现mysqldump和mysql操作同一个库表而竞争表锁的问题。
再导表数据:
mysqldump -h127.0.0.1 -P3306 -uroot -t test | mysql --host=127.0.0.1 -P3306 -uroot test1
复制代码
上述命令只会执行insert操作将test中表数据导入到test1对应表中,mysqldump输出也不含USE。
先将mysqldump数据写入本地文件,mysql在从文件读数据
mysqldump -h127.0.0.1 -P3306 -uroot --result-file=data.sql test && mysql --host=127.0.0.1 -P3306 -uroot test1 < data.sql;
复制代码
这条命令本质和1一样,注意也不能加--databases或-B选项