线上四台机器同一时间全部 OOM:一次排查实录

事发:四台机器同一时间全部 OOM

昨天晚上突然短信收到 APM(即 Application Performance Management 的简称,我们内部自己搭建了这样一套系统来对应用的性能、可靠性进行线上的监控和预警)大量告警。

画外音:监控是一种非常重要的发现问题的手段,没有的话一定要及时建立哦。

紧接着运维打来电话告知线上部署的四台机器全部 OOM(out of memory,内存不足),服务全部不可用,赶紧查看问题!首先运维先重启了机器,保证线上服务可用,然后再仔细地看了下线上的日志,确实是因为 OOM 导致服务不可用。

第一时间想到 dump 当时的内存状态,但由于为了让线上尽快恢复服务,运维重启了机器,导致无法 dump 出事发时的内存。所以我又看了下我们 APM 中对 JVM 的监控图表。

画外音:一种方式不行,尝试另外的角度切入!再次强调,监控非常重要!完善的监控能还原当时的事发现场,方便定位问题。

不看不知道,一看吓一跳:从 16:00 开始,应用中创建的线程居然每时每刻都在上升,一直到 3w 左右,重启后线程也一直在不断增长。正常情况下的线程数是多少呢?600!问题找到了:应该是在下午 16:00 左右发布了一段有问题的代码,导致线程一直在创建,且创建的线程一直未消亡!查看发布记录,发现发布记录只有这么一段可疑的代码 diff:在 HttpClient 初始化的时候额外加了一个 evictExpiredConnections 配置。

问题定位了,应该就是这个配置导致的!(线程上升的时间点和发布时间点完全吻合!)于是先把这个新加的配置给干掉上线,上线之后线程数果然恢复正常了。那 evictExpiredConnections 做了什么导致线程数每时每刻在上升呢?这个配置又是为了解决什么问题而加上的呢?于是找到了相关同事来了解加这个配置的前因后果。

追因:NoHttpResponseException 是怎么来的

最近线上出现不少 NoHttpResponseException 的异常,而上面那个配置就是为了解决这个异常而添加的。那是什么导致了这个异常呢?在说这个问题之前我们得先了解一下 HTTP 的 keep-alive 机制(对 HTTP 协议本身的演进不熟的同学,可以先看看本站的《HTTP 前世今生:从 HTTP/0.9 到 HTTP/3》)。

一个正常的 TCP 连接,要经过三次握手建立连接后才能发送数据,要经过四次挥手才能断开连接(对这块还不熟的同学,可以看看《TCP 的那些事儿》)。如果每个 TCP 连接在 server 返回 response 后都立马断开,则发起多个 HTTP 请求就要多次创建、断开 TCP,这在 HTTP 请求很多的情况下无疑是很耗性能的。如果在 server 返回 response 后不立即断开 TCP 连接,而是复用这条连接进行下一次的 HTTP 请求,则无形中省略了很多创建/断开 TCP 的开销,性能上无疑会有很大提升。

对比一下两种情况:不复用 TCP 发起多个 HTTP 请求时,每个请求都要经历一次完整的建立/断开;而复用 TCP 的话,发起三次 HTTP 请求可以省去两次建立/断开 TCP 的开销。理论上一个应用只要开启一个 TCP 连接即可,其他 HTTP 请求都可以复用这个 TCP 连接,这样 n 次 HTTP 请求可以省去 n-1 次创建/断开 TCP 的开销,对性能的提升无疑是有巨大的帮助。

回过头来看,keep-alive(又称持久连接,连接复用)做的就是复用连接,保证连接持久有效。

画外音:HTTP 1.1 之后 keep-alive 默认支持并开启,目前大部分网站都用了 HTTP 1.1 了,也就是说大部分都默认支持连接复用了。

天下没有免费的午餐。虽然 keep-alive 省去了很多不必要的握手/挥手操作,但由于连接长期保活,如果一直没有 HTTP 请求的话,这条连接也就长期闲着了,会占用系统资源,有时反而会比复用连接带来更大的性能消耗。所以我们一般会为 keep-alive 设置一个 timeout,这样如果连接在设置的 timeout 时间内一直处于空闲状态(未发生任何数据传输),经过 timeout 时间后,连接就会释放,就能节省系统开销。

看起来给 keep-alive 加 timeout 是完美了,但是又引入了新的问题(一波已平,一波又起!)。考虑如下情况:如果服务端关闭连接,会发送 FIN 包(注:在设置的 timeout 时间内服务端如果一直未收到客户端的请求,服务端会主动发起带 FIN 标志的报文以断开连接释放资源)。在这个 FIN 包发送了但还未到达客户端期间,客户端如果继续复用这个 TCP 连接发送 HTTP 请求报文的话,服务端会因为在四次挥手期间不接收报文而发送 RST 报文给客户端,客户端收到 RST 报文就会提示异常(即 NoHttpResponseException)。

我们再用文字把上述产生 NoHttpResponseException 的流程梳理一遍,这样能看得更明白一些:

  1. keep-alive 的 timeout 到期,服务端主动断开连接,发出 FIN 包;
  2. FIN 包还在网络中传输,尚未到达客户端;
  3. 客户端此时恰好复用这条 TCP 连接,发出了 HTTP 请求;
  4. 服务端已进入四次挥手流程,不再接收报文,于是回 RST 报文给客户端;
  5. 客户端收到 RST,抛出 NoHttpResponseException。

费了这么大的功夫,我们终于知道了产生 NoHttpResponseException 的原因,那该怎么解决呢?有两种策略:

  1. 让服务端的 keep-alive timeout 设置得比客户端长,保证连接总是由客户端先主动关闭,避免「客户端复用一条服务端正在关闭的连接」这个竞态窗口;
  2. 客户端主动清理过期空闲连接,比如 HttpClient 的 evictExpiredConnections,用一个后台线程定期把连接池里过期的空闲连接淘汰掉,减少复用到已被服务端关闭的连接的概率。

evictExpiredConnections 与每请求一个 HttpClient

evictExpiredConnections 就是用的上述第二种策略,来看下官方用法使用说明:

Makes this instance of HttpClient proactively evict idle connections from the connection pool using a background thread.

大意是:让这个 HttpClient 实例用一个后台线程,主动把连接池里的空闲连接淘汰掉。

调用这个方法只会产生一个定时线程,那为啥应用中线程会一直增加呢?因为我们对每一个请求都创建了一个 HttpClient!这样由于创建每一个 HttpClient 实例时都会调用 evictExpiredConnections,导致有多少请求就会创建多少个定时线程!

还有一个问题,为啥线上四台机器几乎同一时间点全挂呢?因为由于负载均衡,这四台机器的权重是一样的,硬件配置也一样,收到的请求其实也可以认为是差不多的。这样这四台机器由于创建 HttpClient 而生成的后台线程也在同一时间达到最高点,然后同时 OOM。

解决问题

所以针对以上提到的问题,我们首先把 HttpClient 改成了单例,这样保证服务启动后只会有一个定时清理线程;另外我们也让运维针对应用的线程数做了监控,如果超过某个阈值直接告警,这样能在应用 OOM 前及时发现处理。

画外音:再次强调,监控相当重要,能把问题扼杀在摇篮里!

总结

本文通过线上四台机器同时 OOM 的现象,详细剖析定位了产生问题的原因。可以看到,我们在应用某个库时,首先要对这个库有充分的了解(上述 HttpClient 的创建不用单例显然是个问题);其次,必要的网络知识还是需要的,要成为一个合格的程序员,不光对语言本身有所了解,还要对网络、数据库等也要有所涉猎,这些对排查问题以及性能调优等会有非常大的帮助;再次,完善的监控非常重要,通过触发某个阈值提前告警,可以将问题扼杀在摇篮里!


   转载规则


《线上四台机器同一时间全部 OOM:一次排查实录》 由 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
Boost 系列导读:Boost.Asio 从入门到多线程 Boost 系列导读:Boost.Asio 从入门到多线程
这个系列围绕 Boost.Asio 展开:先用一篇概览建立设计思想,再沿着「入门 → 基本原理 → 核心类 → 实战 → 对比 → 多线程」的主线逐步深入,最后附带两篇 Boost 工具库的番外。全系列 10 篇,按发文时间顺序读即是推荐阅
2021-12-28
下一篇 
select 函数源码简析 select 函数源码简析
阻塞式 I/O:“有美人兮,见之不忘,一日不见兮,思之如狂。” select:“所用皆鹰腾,破敌过箭疾” select() 允许一个程序监听多个文件描述符,等待一个或者多个文件描述符的 I/O 操作变成“就绪”状态(比如:可读)。对 sel
2021-12-14
  目录