首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

当 MySQL 连接池遇上事务一:神秘的幽灵锁

更多腾讯海量技术文章,请关注云加社区:https://cloud.tencent.com/developer

作者:沈玉琛

MySQL连接池是一个很好的设计,通过将大量短连接转化为少量的长连接,从而提高整个系统的吞吐率。一般各个团队都会对连接池进行封装,只提供简洁的接口供上层使用。在上层看来,并不知道底层是否使用了连接池(甚至连访问数据库的IP和Port都不知道),只知道调用了一个接口,执行了指定的SQL语句,并返回执行状态和执行结果。

本来是很好的解耦分层设计,但是当上层使用方式不恰当时,就会发生一些奇怪的事。最近我们项目就遇到了这样奇怪的事情,且听我慢慢道来。

1. 背景

首先交待一些基本背景。我们项目使用了OpenResty作为API Server,并用resty.mysql作为MySQL库。resty.mysql提供了MySQL连接池功能,connect()时会首先从连接池中查找空闲的连接,如果找不到才创建一个新的连接;当使用完毕之后,可以通过set_keepalive()将当前连接放回连接池中,供保活时间内其他请求使用。

这是公共库中封装的执行SQL函数,上层执行SQL都是通过这个函数实现的。

也许有些聪明的同学已经发现了问题了,但是在奇怪的事情发生之前,没有人意识到,而且这个函数也确实稳定可靠的运行了很长时间。

2. 奇怪的事情

前一段时间,发生了几次用户在页面配置时报错,定位的结果是接口超时,而接口超时的原因是DB的表X被锁住了。本来表被锁住了也很正常,找出加锁的地方看看有什么使用不当就行了。

但是搜索了所有的代码,被锁的表X只找到了一处加锁的代码,而日志显示,这处代码的多个线程都在等已有的锁,没有任何一个线程获得了锁。

既然表级锁找不到(行级锁已排除),那么是否是数据库级别的锁呢?查看数据库备份的日志,发现mysqldump的时间点跟锁完全对不上。

这就是那把奇怪的锁,它锁住了我的表,却找不到锁的来源,我把它叫做“神秘的幽灵锁”。

3. 顺藤摸瓜

作为一个唯物主义者,我决定对MySQL的状态进行监控,来捕捉这个幽灵锁。定位的方式很简单:每秒执行几个SQL查询语句,并记录查询的结果,作为问题再现时的定位依据。具体SQL语句如下:

1. show processlist; --查看当前正在执行的SQL语句

2. select * from information_schema.INNODB_TRX; --查看当前已开启的事务

3. select * from information_schema.INNODB_LOCKS; --查看当前事务开启的锁

4. select * from information_schema.INNODB_LOCK_WAITS; --查看当前事务锁的等待关系

至于显式锁表的情况,上述语句不能查询,则通过加日志协助定位(MySQL 5.7以上版本也可以通过SQL查询)。

定位的结果也是相当奇怪:某个地方开启了一个事务,事务锁住了平台的表X和业务的表Y。因为平台的表X被锁,导致接口等待超时页面报错。

这就引出了好几个问题,只要能解答这几个问题,幽灵锁就会现出原型。

1) 什么地方开启的事务?

通过搜索代码发现,平台没有显式使用事务的地方,只有业务侧为了保证操作的原子性,开启了事务,初步怀疑是某个分支没有执行commit或rollback就退出。查看业务逻辑的代码,所有的异常处理分支已都加上了rollback,这就奇了怪了。

既然代码没问题,那就只能检查运行时问题了。查看OpenResty的access.log,竟然惊奇的发现接口报500错误,而在error.log查找该请求的日志,又找到了错误日志“lua entry thread aborted: runtime error”。真是山重水复疑无路,柳暗花明又一村哈。那么问题很明确了,就是这个接口开启了事务,因为某个异常没有处理导致异常退出,没有执行commit或者rollback。

2) 事务为什么会锁表?

首先,事务内并没有显式的加锁,那就只能是数据库本身加的锁了。而数据库会不会加锁,会加什么锁,则跟数据库配置相关。为了验证我的想法,我确认了一下数据库的事务隔离级别:

MySQL > select @@tx_isolation; +-----------------+| @@tx_isolation |+-----------------+| REPEATABLE-READ |+-----------------+1 row in set (0.00 sec)

结果表示当前事务隔离级别是“重复读”级别(MySQL的Innodb默认事务隔离级别),那么也就解释的通了。MySQL的RR级别为了保证不允许脏读、不允许非重复读、不允许幻读(是的,MySQL作为一个成熟的数据库引擎,RR级别已经解决了幻读问题),当执行update等操作时,会对操作影响的记录加上行锁和间隙锁。而业务的SQL语句update条件没有索引,所以就导致了全表被锁了。

3) 事务是基于连接的,在异常退出后,锁为什么没有自动释放?

MySQL连接对象是在lua脚本中创建的,按理说lua entry thread abort,即使句柄没释放,也会被OpenResty的GC机制回收才对,事务不可能持续那么长时间。

有一种可能是,该连接对象是一个全局对象,或者是lua脚本级的local变量。因为OpenResty的module加载机制,lua脚本级的local变量,都只会加载一次,并且在lua脚本退出后生命周期还不会结束,相当于无形中变成了全局变量(关于这个特性也是踩过一个坑,后续再专门讲解)。但是检查MySQL连接对象,确实是函数的局部变量,也就不存在上面这个问题。

最后的最后,突然灵光一闪,我们使用的是连接池,那会不会是因为这个连接没被释放呢?顺藤摸瓜,最后找到了文章开头的那个公共库函数,总算找到罪魁祸首了。因为公共库函数每执行一个SQL后立即将连接放回连接池,而接口异常退出是在开启事务并成功执行update语句之后,在HTTP调用时抛异常,此时连接已经放回了连接池,自然没有被释放了。

4) 该事务只操作了业务的表Y,为什么会导致平台的表X被锁?

这是最后一个问题了,其实从前面几个问题的答案,已经基本可以推出这个问题的答案了。因为业务开启了事务的连接被扔回连接池,然后被平台的接口取出执行了SQL语句,导致平台的表也被加上行锁和间隙锁,从而导致任务超时。

4. 改进方案

幽灵锁已经分析的很清楚了,问题出在上层使用MySQL公共库时没意识到底层的连接池,导致使用方式不当。在上层看来是:

开启事务->执行SQL->commit

而实际底层实现是:

获取一个连接->开启事务->扔回连接池->获取一个连接->执行SQL->扔回连接池->获取一个连接->commit->扔回连接池。

这个过程无法保证每次拿到的都是同一个连接,也就存在了很大的问题。之所以之前一直没发生问题,那纯粹是运气好(至于为什么运气能一直这么好,后续文章再揭晓^v^)。

那么解决方案也就很简单了,修改业务接口使用MySQL库的方式,不用上述封装的函数,直接调用resty.mysql的接口就可以了:

接口入口获取一个MySQL连接对象,使用这个对象执行一系列SQL操作,在接口异常处理或正常结束处将连接对象扔回连接池即可。假如在处理过程中发生了异常导致接口异常退出,连接对象由于不在连接池,其他接口无法获取,并且这个连接对象会被OpenResty的GC机制回收,不会造成影响。

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180116A07X3800?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券