MySQL连接池是一个很好的设计,通过将大量短连接转化为少量的长连接,从而提高整个系统的吞吐率。但是当跟事务一起使用时,如果使用方式不恰当时,就会发生一些奇怪的事。之前写过一篇文章专门讲述了遇到的一件奇怪的事情,详见《当MySQL连接池遇上事务(一):神秘的幽灵锁》。
简单地说,《神秘的幽灵锁》一文,问题出在上层业务使用MySQL公共库时没意识到底层的连接池,导致使用方式不当。在上层看来是:
开启事务->执行SQL->commit
而实际底层实现是:
获取一个连接->开启事务->扔回连接池->获取一个连接->执行SQL->扔回连接池->获取一个连接->commit->扔回连接池。
这个过程无法保证每次拿到的都是同一个连接,也就存在了很大的隐患。当业务接口异常退出时,由于没有执行commit或rollback的连接已经被放回连接池,导致该带状态的连接没有被释放,并且进一步影响到该连接后续操作过的表。
解决方案是修改所有使用事务的接口,在事务结束之前不能将连接放回连接池。但是由于改动量较大,在全部接口修改完成之前,先对可能导致接口退出的异常进行处理,避免异常情况的发生。这样也正常稳定地运行了一段时间,没有再发生之前的问题。
直到……
之前说过,项目组使用OpenResty作为API Server,当需要执行HTTP调用时,早期很自然地选择了成熟的luasocket库。luasocket库是lua的一个开源库,对于常用的HTTP功能都能很好的满足,包括直接调用、代理转发、超时时间设置等。但是lua的库大多数阻塞调用的,对于OpenResty这样一个100%异步非阻塞的高性能服务器来说,阻塞的HTTP调用对对整体性能造成致命的伤害。
因此,近期正在使用非阻塞的resty.http库来替换luasocket库。resty.http是OpenResty的一个第三方开源HTTP调用的实现,采用了与OpenResty一致的风格,支持直接调用、反向代理、超时时间设置等特性,最重要的,它是非阻塞的。
按理说,替换HTTP库跟MySQL不应该有任何关系。阻塞与非阻塞强调的是调用方,只要保证替换HTTP库前后,对于同一个HTTP调用,被调用方收到的请求参数和请求方法完全一致即可。被调接口不应该也不能感知调用方使用的是阻塞还是非阻塞调用。
但是,奇怪的事情又双叒叕发生了……
替换luasocket库为非阻塞的resty.http之后,在页面配置时必现错误(后续定位是MySQL引起的)。奇怪的是,使用resty.http时错误必现,而恢复luasocket后则不会发生!!
为了定位,在平台接口内加了很多日志。定位的结果居然是,平台接口往异步任务表X插入一条记录,插入成功并且获取到一个自增长的任务ID N,但是当sleep之后再次查询该任务状态时,发现任务ID为N的记录并不存在。并且,之后再往表X插入记录,自增长的任务ID居然跳过了N,直接是N+1。
接口的日志和N+1的任务ID,都证明了任务ID为N的记录曾经存在过,但是从数据库中却找不到这条记录的任何踪迹。我把这叫做“消失的记录”。
奇怪的事情屡次发生,我又开始了艰辛的探索之路。这一次,我需要把两个看起来不相关的东西(HTTP调用方式和MySQL)联系起来。这很艰难,我还是根据现有的线索一步步往前推,看看究竟能走到哪。接下来还是以“提问-解答”的方式进行。
1) 记录会不会是被删除了?
遇到消失的记录,首先的怀疑是,记录会不会被删除了?
于是对该接口代码进行审阅(该接口是其他同事开发的)。审阅的结果令人失望,所有代码都是那么的正常,连让人怀疑的地方都没有。于是又把所有代码都搜索了一遍,居然没找到有删除任务表X的地方。至于第三方脚本删除,从时间上和删除记录的选择性上看,应该是不可能的。
当然,为了验证我的判断,解析了binlog,发现任务ID为N的记录压根就没有插入过,更没有被删除过。
记录被删除的可能,排除!
2) 记录是不是插入失败了?
既然从binlog看,记录没有被插入过,那么接口日志为什么显示获得了自增长的任务ID N呢?一个合理的怀疑是,在获得自增长ID之后,因为某个未知的原因导致插入失败了。查看MySQL文档,确实在插入失败的情况下,仍然可能会占用一个自增长ID。
那么是否是插入失败了呢?因为接口日志显示的是插入成功并且没有发生任何错误,怀疑插入失败就是怀疑resty.mysql库有问题。。没事,咱有怀疑精神,确认就是了。于是又开始阅读resty.mysql库的源码了。源码并不复杂,确认了只有MySQL APi返回正常时,resty.mysql库才会返回正常。MySQL API我还是信得过的,嘿嘿嘿。
也就是说,记录确实是插入成功了!
3) 插入成功的记录为什么没有binlog?
有了上一次《神秘的幽灵锁》的经验,这一次我很快意识到可能是因为事务!在事务内,接口认为插入成功了,但是后面事务rollback了,所以导致没有写入binlog。那么,这一切就解释的通了。
因为平台接口没有使用事务,只有业务接口使用了,所以只能是跨接口影响。于是,我赶紧搜索OpenResty的错误日志,希望找到上次一样的“lua entry thread abort”异常。但是很遗憾,这次所有接口都没有异常退出。
这条路到这里走到了尽头。。
4) HTTP调用方式为什么会跟事务扯上关系?
既然从MySQL本身出发的路走不下去,那就从HTTP调用方式思考。
替换luasocket为resty.http,从HTTP请求的功能上看是完全等价的,唯一的不同在于调用方式从阻塞变成了非阻塞。也就是说,非阻塞调用导致了MySQL连接的混用,平台接口拿到了业务接口开启了事务的连接。
为了验证这个猜想,我再次查看resty.mysql的文档,找到了一个函数get_reused_times(),该函数返回MySQL连接被使用的次数。通过在业务接口和平台接口加上日志打印get_reused_times()的结果,确认了我的猜想:业务接口调用了平台接口,当使用luasocket时,平台接口第一次get_reused_times()的结果是0,说明是新创建的连接;而使用resty.http时,平台接口第一次get_reused_times()的结果是业务接口调用平台接口前get_reused_times()的结果加1,说明平台接口拿到了业务接口的同一个连接。
那么,非阻塞在这个过程中究竟起到什么作用呢?我百思不得其解,直到我看到了这么一句话:
You can specify the max idle timeout (in ms) when the connection is in the pool and the maximal size of the pool every nginx worker process.
重点是“every nginx worker process”!也就是说,resty.mysql的连接池是worker级别的!!
使用luasocket时,因为阻塞,所以新的请求不会被分配到业务请求相同的worker上,也就是说,开启了事务的连接,不会被其他请求使用,因为根本就没有其他请求会使用这个worker的连接池!
但是换成非阻塞的resty.http之后,业务接口发起HTTP请求后,该worker仍然可以接受新的请求,并且非阻塞内部接口调用类似于子查询,在OpenResty看来就是同一个请求,所以必然分配到同一个worker。被调用的平台接口很自然的拿到了开启事务的连接,并往任务表X成功插入了一条记录(任务ID为N)。而在平台接口sleep之后,因为该连接超过了keepalive时间已经被释放,事务没有被提交,再次获取连接查询时,就查不到刚才插入的记录了,从而造成“消失的记录”。
进一步推想,如果sleep时间没超过keepalive时间,那么也是会有问题的。这次不会出现消失的记录,异步任务记录插入成功,但是由于该连接已开启了事务,会导致任务表被加上行锁和间隙锁,从而导致任务处理svr等锁而无法处理任务,任务最终还是会超时失败。
“消失的记录”问题总算搞清楚了,现在再回顾一下,在《神秘的幽灵锁》一文,我说过我们使用“连接池+事务”的方式一直是错误的,但是却很幸运地没发生过问题,其根本原因就在于我们使用了阻塞的HTTP请求库。阻塞的方式导致我们的连接池同一时刻只有一个请求在使用,也就避免了接口间相互影响的可能。而如今,非阻塞的resty.http,把我们的运气用完了,所以到了需要处理这个问题的时候了。
问题的处理方式之前已经说过,就是修改事务接口用连接池的方式,在事务结束之前不能将连接放回连接池。但这个改动量较大,在全部修改完成之前,resty.http只怕是不能上线了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。