Cobar引起的死锁问题

2017-01-12 10:01:55来源:作者:点我达技术人点击

题外话

使用 Cobar 将近一年了,但对其原理仍旧不是很了解,更没阅读过源码,说起来也是惭愧。趁着最近线上的一次故障,总算说服自己花时间来看看 Cobar 的真面目。

线上故障

故障现场的 Cobar 完全就处于一个近似于僵死的状态,并且在底层 Mysql 层面捕捉到了锁:多个 session 长时间的在等待某个行级锁(row-level locking)直到超时(锁超时时间是50s)。从该SQL我们定位到某个业务接口,并且发现该接口会被并发调用,这个并发调用会更新同一个人的账户余额,简化成 SQL 类似于

update `user` set balance = balance - 5 where id = 1;

当时的并发度大概在30+左右,应用有大量更新这条记录的等待锁超时异常爆出。并且当时的现象是,其余访问 cobar 的线程也被阻塞。

临时方案

线上故障在无法立即找出原因并解决的情况下,我们必须要有紧急预案或者说是临时方案。当时已经定位到业务接口,该接口是由于某个定时任务去并行得对某个用户的账号做扣罚。于是我们将该扣罚金额参数调整成了0,至此, Cobar 恢复正常。

抛出疑问

当然,临时方案只是为了暂时先恢复正常运营。下面的工作就要找出问题真正的原因。 针对上面的故障场景,当时产生了几点疑问:

为什么某条Sql会占用id为1的行锁超过50s甚至更长? 为什么id为1的行锁占用会影响到整个 Cobar 的服务?

我们留着这两点疑问先慢慢往下看。

场景还原

故障之后有同事在重现该场景并寻找原因,根据数据源和事务提交方式的不同,在并发度为500的情况下,分别测试了如下几种场景:

只有在使用 Cobar 并采用手动提交事务的情况下,才会出现 Cobar 僵死的情况。这个测试模拟了一个最简单并且足以说明问题的场景,单表一条主键id为1的记录,对其做 update 操作。更不用说并发500了,30+的情况也完全能把 Cobar 给搞挂。

原理探究

到这里大家可能就开始质疑 Cobar 了,真的是 Cobar 的并发低到只有30+? 带着这样的质疑,我踏上了一条为 Cobar 正名的“不归路”。通过官方文档以及 Cobar 的源代码,梳理出了 Cobar 的大致结构,下面是简化后的结构图:

可以看到, Cobar 实现了 Mysql 协议,伪装成 Mysql 服务端与我们的应用进行通讯,这样我们的应用就可以像直连 Mysql 一样操作 Cobar 了。应用与 Cobar 建立连接,然后通过 Mysql 协议将请求发到 Cobar , Cobar 解析报文然后根据命令的不同执行不同的操作。其中涉及到两个线程池,如上图所示,在 Cobar 中命名这两个线程池为: Handler 和 Executor : Handler 的主要工作是读取数据流,解析报文,处理对应命令,路由计算等。而具体要和底层的 Mysql 打交道的工作就交给 Executor 来完成了。我们来举个简单的例子,就拿前面的 update 语句来简单分析一下(建立连接这块先不说):

update `user` set balance = balance - 5 where id = 1;

假设应用和 Cobar 之间已经建立起了连接,那么 Cobar 就开始从应用读取数据流,一旦读到数据流,那么 Cobar 会简单处理数据包,待获得一个完整的数据包之后将此数据包打包成一个任务丢给 Handler 去执行。该任务的内容包含:

根据 Mysql 协议来解析数据包(枯燥的过程,例如包头4个字节,第五个字节代表具体的命令类型,诸如此类的东西) 通过第一步可以解析出命令类型以及具体的SQL,下面以 Query 这种命令类型为例,这也是最常用的(这里的查询不局限select,crud都属于Query) 根据SQL进行路由计算,针对于单节点和多节点处理略有不同

最后将携带了路由信息的数据包打包成任务丢到 Executor 中去执行,这块任务要做的是:

根据路由信息关联 Mysql 通道 针对该会话绑定 Mysql 通道,主要是为了关联事务 发送数据包到 Mysql 并等待返回结果 提出猜想

通过上面的分析,再来想想之前提出过的两点疑问:

为什么某条Sql会占用id为1的行锁超过50s甚至更长? 为什么id为1的行锁占用会影响到整个 Cobar 的服务?

这边先来简单普及一个知识点,一般的手动事务需要三个步骤:

set autocommit = 0; Query Command commit/rollback

在正常情况下根据主键 update 肯定不可能超过50s,那么只有一种情况,那就是 update 操作之后没有 commit 。 为什么会没有 commit 呢,是不是 Executor 被挤满了? 为什么 Executor 满了?因为堵满了 update

这看起来像不像一个死锁(DeadLock)问题? Executor 中的某条线程(ThreadA)获取了锁,其余大多数线程都在等待该锁。假设此时 update 线程足够多并且都因为等待锁而阻塞,进而堵满了 Executor 。那么那条获得了锁的线程也就没有空余线程来释放锁了(commit/rollback)。DeadLock!

验证猜想

首先,我们来重现场景,和上面写的场景还原一样,采用500个线程来并发更新一条记录:

update `user` set balance = balance - 5 where id = 1;

不出意料,场景又再现了。此时,我们通过 Cobar 提供的管理节点来监控线程池,发现 Executor 跑满了,并且队列中还堆积了好多请求:

此时再去底层的 Mysql 看看,发现此时大量锁超时:

为了证明线程池里堵得全都是 update ,又通过修改 Cobar 源码打印出了 Executor 中任务执行的日志。至此,问题已经非常清晰,并且同时也解释了为什么在自动提交事务的场景下不会发生堵塞。 那么可以开始考虑解决方案了。

解决方案

加大 Executor 的线程池大小,这应该也是最容易想到的方式。 Cobar 会根据 CPU 核心数创建N个 Executor ,假设将 Executor 的线程池大小调整到256,那么理论上可支持对同一条记录的并发操作数可达到 N * 256。

优点:改起来非常方便,本身 Cobar 配置文件就有暴露该配置项

缺点:总觉得有点治标不治本的味道,并发量过高还是会有阻塞的危机

当然可以搭配死锁检查或对于线程中执行时间过长的SQL直接Kill并报警等机制

调整线程池策略,如配置超大maxSize,也就是保证线程资源管够,这涉及到修改源码,因为Cobar在线程池上并没有暴露扩展点。

再定义一种线程池,单独用来执行commit/rollback命令

优点:将资源隔离,可以从本质上来解决死锁问题

缺点:需要改动 Cobar 源代码,可能需要经过一轮全面的测试才能使用到生产环境

以上三种方式都可以达到想要的效果。个人倾向第三种,因为将资源隔离,才是从本质上解决问题。另外,修改的代码成功通过了Cobar 168个单测用例,并且很荣幸,这部分代码已经被合并至官方仓库。所以,放心大胆的使用吧。

结束语

感觉这个问题算是 Cobar 隐藏的一个BUG吧。可能在设计之初并没有考虑到一些对同一条记录高并发的更新场景。网上也没有太多关于这方面的文章。这篇文章算是一个探路者,为我之后研究开源中间件开一个好头~

最新文章

123

最新摄影

微信扫一扫

第七城市微信公众平台