当 MySQL 连接池遇上事务(二):消失的记录

1. 往事回顾

MySQL连接池是一个很好的设计,通过将大量短连接转化为少量的长连接,从而提高整个系统的吞吐率。但是当跟事务一起使用时,如果使用方式不恰当时,就会发生一些奇怪的事。之前写过一篇文章专门讲述了遇到的一件奇怪的事情,详见《当MySQL连接池遇上事务(一):神秘的幽灵锁》

简单地说,《神秘的幽灵锁》一文,问题出在上层业务使用MySQL公共库时没意识到底层的连接池,导致使用方式不当。在上层看来是:

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

而实际底层实现是:

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

这个过程无法保证每次拿到的都是同一个连接,也就存在了很大的隐患。当业务接口异常退出时,由于没有执行commit或rollback的连接已经被放回连接池,导致该带状态的连接没有被释放,并且进一步影响到该连接后续操作过的表。

解决方案是修改所有使用事务的接口,在事务结束之前不能将连接放回连接池。但是由于改动量较大,在全部接口修改完成之前,先对可能导致接口退出的异常进行处理,避免异常情况的发生。这样也正常稳定地运行了一段时间,没有再发生之前的问题。

直到……

2. 非阻塞HTTP也来搞怪

2.1 非阻塞HTTP的背景

之前说过,项目组使用OpenResty作为API Server,当需要执行HTTP调用时,早期很自然地选择了成熟的luasocket库。luasocket库是lua的一个开源库,对于常用的HTTP功能都能很好的满足,包括直接调用、代理转发、超时时间设置等。但是lua的库大多数阻塞调用的,对于OpenResty这样一个100%异步非阻塞的高性能服务器来说,阻塞的HTTP调用对对整体性能造成致命的伤害。

因此,近期正在使用非阻塞的resty.http库来替换luasocket库。resty.http是OpenResty的一个第三方开源HTTP调用的实现,采用了与OpenResty一致的风格,支持直接调用、反向代理、超时时间设置等特性,最重要的,它是非阻塞的。

2.2 HTTP调用方式居然影响到MySQL

按理说,替换HTTP库跟MySQL不应该有任何关系。阻塞与非阻塞强调的是调用方,只要保证替换HTTP库前后,对于同一个HTTP调用,被调用方收到的请求参数和请求方法完全一致即可。被调接口不应该也不能感知调用方使用的是阻塞还是非阻塞调用。

但是,奇怪的事情又双叒叕发生了……

替换luasocket库为非阻塞的resty.http之后,在页面配置时必现错误(后续定位是MySQL引起的)。奇怪的是,使用resty.http时错误必现,而恢复luasocket后则不会发生!!

2.3 消失的记录

为了定位,在平台接口内加了很多日志。定位的结果居然是,平台接口往异步任务表X插入一条记录,插入成功并且获取到一个自增长的任务ID N,但是当sleep之后再次查询该任务状态时,发现任务ID为N的记录并不存在。并且,之后再往表X插入记录,自增长的任务ID居然跳过了N,直接是N+1。

接口的日志和N+1的任务ID,都证明了任务ID为N的记录曾经存在过,但是从数据库中却找不到这条记录的任何踪迹。我把这叫做“消失的记录”。

3. 事物是普遍联系滴

奇怪的事情屡次发生,我又开始了艰辛的探索之路。这一次,我需要把两个看起来不相关的东西(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等锁而无法处理任务,任务最终还是会超时失败。

4. 那些年,我们那一如既往的幸运

“消失的记录”问题总算搞清楚了,现在再回顾一下,在《神秘的幽灵锁》一文,我说过我们使用“连接池+事务”的方式一直是错误的,但是却很幸运地没发生过问题,其根本原因就在于我们使用了阻塞的HTTP请求库。阻塞的方式导致我们的连接池同一时刻只有一个请求在使用,也就避免了接口间相互影响的可能。而如今,非阻塞的resty.http,把我们的运气用完了,所以到了需要处理这个问题的时候了。

问题的处理方式之前已经说过,就是修改事务接口用连接池的方式,在事务结束之前不能将连接放回连接池。但这个改动量较大,在全部修改完成之前,resty.http只怕是不能上线了。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏代码GG之家

ANR 原理与实战技巧

? 00 手机用用,就卡卡卡。莫名其妙的出现一堆程序无响应,欲哭无泪。这是为什么呢?因为你用的android手机。 android手机,为了...

3367
来自专栏Albert陈凯

3.4 Spark通信机制

3.4 Spark通信机制 前面介绍过,Spark的部署模式可以分为local、standalone、Mesos、YARN等。 本节以Spark部署在stan...

3315
来自专栏F-Stack的专栏

F-Stack Q&A 第三期

Q1:如果在一个阻塞型的socket上执行recv,会不会把相应的线程卡死,调用recv时该socket中没有数据包,导致sleep,sleep导致该线程没办法...

4559
来自专栏我是攻城师

从App的角度看进程和线程

在现在人人都有一部手机或电脑的年代,我们几乎天天都在使用各种app,如微信,QQ,抖音,优酷等等软件,表面上我们是与各种app交互,但如果站在操作系统的角度来看...

422
来自专栏扎心了老铁

使用beanstalkd实现定制化持续集成过程中pipeline

持续集成是一种项目管理和流程模型,依赖于团队中各个角色的配合。各个角色的意识和配合不是一朝一夕能练就的,我们的工作只是提供一种方案和能力,这就是持续集成能力的服...

3057
来自专栏前沿技墅

再聊服务化基石:也有IO的事儿

1335
来自专栏蛋未明的专栏

Node.Js执行原理图引发的思考

1343
来自专栏微服务生态

论代码级性能优化变迁之路(二)

在上一篇我们主要介绍了所遇到问题的五点,那么今天接下来讨论剩下的问题,我们先再回顾一下之前讨论的问题:

632
来自专栏杨建荣的学习笔记

关于分区表的move操作(r2笔记90天)

关于分区表的move操作还是很值得深究的一个问题。如果分区表中含有lob字段,难度还会加大。 对于普通的表而言,做move操作室理所当然,oracle提供的方...

3185
来自专栏云计算

重新审视分布式(微服务)体系结构中的全局数据一致性

早在2015年的时候,我写了几篇文章,介绍如何通过搭载标准Java EE事务管理器以获得跨分布式服务的数据一致性(查看原文请点击这里,基于Spri...

1372

扫码关注云+社区