一、背景
本次问题涉及的系统链路如上图,各系统的基本职责为:
- Proxy:提供请求代理服务。统一代理发往下游系统的各类请求,从而让上游系统无需感知下游各服务的使用差异;同时提供精细化的限流、导流等服务特性;
- 拉图 SDK:面向各存储平台提供统一的图片下载功能。它通常与图像类算法模型被前后编排在一起;
- 预估引擎:提供模型推理服务。支持各种机器学习、深度学习算法模型的工程部署
二、问题排查
说明:本文用到的消息队列内部称 MetaQ,外部开源版即为 RocketMQ,下文中统一用 “MQ” 指代。
2.1 问题描述
某天上午收到报警通知,提示 Proxy 系统的入口 MQ 有堆积。打开控制台,发现 500 台 Proxy 机器中,有 1 台机器堆积十分严重,而其余机器均运行正常:
2.2 一句话根因
先开门见山直接讲问题根因(详细排查流程可参考后文内容):
- 个别机器堆积:该机器有消费线程被卡住,虽然其余线程正常消费,但 MQ 机制决定消费位点并不前进;
- HTTP 下载卡住:所使用的 HttpClient 版本有 bug,特定情况下超时不生效,可能导致线程一直卡住
2.3 排查流程
【1、该机器消费速度太慢?】
第一反应是这台机器的消费太慢。同样的消息量,别的机器能快速消化,而它由于消费慢于是持续堆积。然而在 MQ 控制台详细对比后,发现该机器的业务处理时长、消费 TPS 都与其他机器相近,且不同机器的规格本身也是一样的:其次,观察 Arthas 画出的火焰图,右边两根比较细长的火焰即为我们系统的业务逻辑(火焰比较高是因为 RPC 的调用栈比较深)。从图中可看出,火焰图并没有明显的“平顶效应”,火焰的宽度都比较窄,也可说明该机器在执行业务逻辑时中并没有明显的耗时卡点,由此也可证明该机器并不是因为业务处理慢而造成的堆积:
【2、系统指标是否正常?】
登录机器,发现 CPU、MEM、LOAD 均正常,且与正常机器相近,没有发现明显线索:
且该机器并无明显的 Full GC:
【3、限流导致?】
Tip:Proxy 在代理请求时会有限流机制,超出限额的流量会触发阻塞等待,从而保护下游同步服务。
因此,假如今天系统流量很大,超出了下游服务能承受的限制,则超出部分的请求会触发 RateLimiter 限流而阻塞。于是大量 MQ 消费者线程被阻塞,故 Proxy 系统整体的消息消费速度会变慢,最终体现的结果即为入口 topic 堆积:然而,不管是看该机器的日志还是监控,因限流而阻塞的现象都并不严重,比较通畅:其次,如果真是因为今天系统入口流量大导致,那应该所有 Proxy 机器(MQ 消费者)都会出现程度相近的堆积。不应该全堆在同 1 台机器上,而其他 499 台机器都正常。因此,排查系统入口流量太大的可能性。
【4、MQ 数据倾斜?】
预期应该是 500 台 Proxy 机器均分入口 MQ 里面的消息,有没有可能是数据分配时出现了倾斜,导致这台机器分到的消息量太多,其他机器分到的消息很少?
查看 Proxy 上游系统的代码,该系统在向 Proxy 发消息时并没有做自定义 Shuffle,于是会使用 MQ 默认的 selectOneMessageQueue
策略。而该策略会把当前消息发到基于 index % queue_size
选出的队列中。进一步查看 index 的逻辑,发现它初始化时以一个随机数为起点,每次访问时会 +1 自增:
结合以上两点,实现的效果即为:对各个 queue 从左到右、均匀地分发消息,并通过 % 实现自动循环:
综上可知,MQ 默认 shuffle 策略为:把消息均分到各个 queue 中。因此,可排除因为 MQ 发送消息数据倾斜,导致我们那台 Proxy 机器堆积的可能性。
【5、CPU steal ?】
Tip:CPU steal 表示虚拟机中运行的进程被宿主机上的其他进程/虚拟机占用了 CPU 时间的百分比,高 CPU steal 值通常意味着虚拟机中的进程性能下降。
比如机器规格本身写的是 4C,实际因为被 steal 后能用的可能只有 2C。所以体现的结果就是单个请求的 RT 无明显变化(不同 C 之间差异不大),但 C 的量变少了,所以这台机器整体吞吐变小、消费能力变弱,导致堆积。
但排查发现,st 正常,排除这种可能:
【6、找到线索:MQ 消费位点没变!】
兜兜转转一圈都没找到异常点,由于问题现象是 MQ 堆积,所以又想到可以去查看中间件的日志寻找线索。果然,通过定向搜索卡住机器的 queueId,发现这个队列的消费位点长时间不推进,一直卡在某个位置:
Tip:MQ 拉消息的机制是,拉到的消息会先存放在内存中容量为 1000 的 cache 中,然后这些内存中的消息将被消费者线程消费。当 cache 满了时,就不会再从 queue 中拉取
由此怀疑:是否因为 Proxy 消费停止 or 消费极慢,导致本地缓存一直是满的,于是 MQ 会停止向 queue 中拉消息,于是 offset 一直没变?
但上文已提到,不管从 MQ 控制台、系统监控指标、机器日志来看,该机器都是正常的,与其余机器没什么区别。那为什么该机器的消费位点就是不动,导致堆积越来越严重?
【7、找到根因:某个消费者线程卡住了】
Tip:对于本地缓存中的消息,MQ 会开多线程(线程数量由用户指定)去拉取消费。且为了保证消息不丢失,offset 记录的只是最靠前的那条消息
首先,这个机制是合理的。因为在 At Least Once 语义下,消息允许被消费多次但不允许被遗漏。假设现在有两个线程同时拉取了前后两条消息,且后面的消息先执行完毕。由于前一条消息的执行可能会出现异常,所以不能直接用后者的偏移量去更新 offset,否则消费失败的消息就找不回了。因此,消费位点 offset 的含义是:在该位置及之前的所有消息都已经被正确消费(有点类似 Flink 的 Watermark 机制)。
根据以上机制,回到本问题,假如这台 Proxy 机器的众多 MQ 消费者线程中有任意一个被卡住,那么整个 queue 的消费位点将永远停留在被卡住的消息对应的 offset。此时虽然其他线程都在持续正常消费,但并不能往前推进 offset。另一方面,由于上游还在源源不断往队列中发消息,于是消息只进不出, 堆积量 = 最新入口消息的offset - 消费位点offset
只增不减,体现在控制台上就是堆积越来越严重!
基于此分析,通过 jstack 查看所有 MQ 消费者线程的状态,的确发现第 251 号线程永远处于 runnable 状态!
有理由怀疑,它就是被卡住的消费线程。因为在 Proxy 系统的业务场景中,绝大部分耗时都在 RPC 同步调用深度学习模型这个环节(快的几百毫秒,慢的可能要几秒),因此线程大部分时间应该处于等待同步调用返回的 waiting 状态!而此处 251 线程永远是 runnable,一定有问题。
进一步打印详情,可找到线程卡住的具体代码位置:
这里 getImageDetail
方法内部会通过 HTTP 下载图片,以便深度学习模型进行预估。其次,搜索业务日志也发现,搜不到这个线程的日志。因为从昨晚 10 点开始卡住后,该线程就不会产生任何新的日志了,随着机器日志滚动清理,现在所有日志都将不含该线程的内容:
至此,Proxy 系统个别机器 MQ 堆积严重的问题根因找到:该机器的某个消费者线程在通过 HTTP 下载图片时一直卡住,导致整个 queue 的消费位点无法往前推进,从而持续堆积。
【8、为什么 HTTP 会一直卡住?】
至此,虽然堆积的根因已经找到,且经过尝试短期内可通过应用重启/机器下线等方式临时解决。但长远来看始终存在隐患,因为近期每隔几天时不时就会出现相同问题。需要彻底查清楚 HTTP 卡住的根因,才能从根本上解决本次问题。通过几次蹲点,捞到了一些会导致线程卡住的图片 URL。发现它们都不是内部的图片地址,且地址也打不开,也不是 jpg 形式结尾的格式:
https://ju1.vmhealthy.cn https://978.vmhealthy.cn https://xiong.bhjgkjhhb.shop
但问题是,即便是输入这种打不开的极端 URL,由于我们对 HttpClient 设置了 5s 超时,顶多也就会卡住 5s 啊,为什么好像超时机制没生效,一直卡住了十多个小时?
Tip:HTTP 会需要先建立连接,再进行数据传输。由此对应着两种超时:1、连接超时,针对建立连接这一阶段;2、socket 超时,针对数据传输过程中的超时
经检查,目前代码中只设置了 socket timeout,没设置 connection timeout,因此怀疑上面那些请求直接卡在了前面的 connect 阶段,而又由于没设置 connect 超时,所以请求一直卡住?然而,修改代码后重新尝试请求这些地址,发现还是会一直卡住,需要进一步排查。
【9、找到 HTTP 卡住的根因】
Tip:找到根因。在特定版本的 HttpClient 中,基于 SSL 连接的请求有 bug:实际会先尝试建立连接,再设置超时时长,先后顺序反了。因此若在连接时卡住,超时还没来得及设置,本次 HTTP 请求就会一直卡下去...
再回过头看上面那几个卡住的 URL,无一例外都是https开头的!破案了,原来是项目用的 HttpClient 有 bug。在升级了 HttpClient 版本后,重新在预发构造测试请求,线程不再卡住,且全部发到线上环境后,近期频繁出现的少量机器堆积问题再也没有出现过。
至此,经历了许多曲折,问题终于彻底解决。
2.4 通盘梳理
从最外层 Proxy 个别机器堆积的表象,不断往内层排查中经历了许多关键节点,直到找到根源。目前所有问题已全部查清,现在站在上帝视角,从内向外完整的因果链条如下:
--> Proxy 系统会基于 HttpClient 去下载图片,然后调用图像类模型做预估
--> 所使用 HttpClient 版本有 bug,在访问 https 地址时,超时不会生效
--> Proxy 系统恰好遇到了少量 https 地址,且恰好卡住(低概率事件),于是会因超时不生效而一直卡住
--> 基于 MQ 的 At Least Once 机制,消费位点将一直停留在卡住消息所对应的 offset 处(尽管其余线程都在正常消费)
--> 上游系统仍然源源不断向 Proxy 发送消息,于是消息只进不出,堆积越来越严重
--> 堆积量超过一定阈值,触发监控报警,用户察觉
三、总结
本次问题的排查过程略微曲折,但也能学到不少共性的方法论和经验教训,在本节统一提炼归纳:
- 善用排查工具:学会使用 jstack、Arthas、jprofile 等利器,在不同情景下善用合适的工具,能高效地发现异常点,找到线索,从而逐步找到问题根因;
- 异常的敏感度:有时一些发现问题的线索其实早早就展现在了眼前,但当时因为各种原因(比如最初对问题的疑团很多,未排除的干扰因素多等)导致并不能立马发现线索,还需多积累经验;
- 广泛查阅资料:除了内网查文档、找相关人员咨询,还要学会去外部英文网站查第一手资料;
- 困难时要坚持:对一些隐蔽的问题,很多时候不是出现一次就能立马发现问题并解决的。可能需要前前后后经历了几轮排查,最终才能找到根因;
- 补充底层知识:如果最开始能知道 MQ 的 offset 机制,一些问题就不会走那么多弯路了。后续在用各种中间件的过程中,要多去了解它们的底层原理;
- 没有玄学问题:代码和机器不会骗人,一切“玄学”问题的背后,都有一套严谨又合理的成因。
四、参考
- 1、HttpClient Bug:https://issues.apache.org/jira/browse/HTTPCLIENT-1478
- 2、Connection timeout v.s Socket timeout:https://stackoverflow.com/questions/7360520/connectiontimeout-versus-sockettimeout