线上四台机器同一时间全部 OOM,到底发生了什么?

本文深入分析了一次线上服务OOM故障,揭示了问题根源在于HttpClient配置不当导致线程池溢出,并详细解释了HTTP keep-alive机制及evictExpiredConnections配置的影响。

作者 | 码海

责编 | Elle


案发现场

昨天晚上突然短信收到 APM (即 Application Performance Management 的简称,我们内部自己搭建了这样一套系统来对应用的性能、可靠性进行线上的监控和预警的一种机制)大量告警画外音: 监控是一种非常重要的发现问题的手段,没有的话一定要及时建立哦。

紧接着运维打来电话告知线上部署的四台机器全部 OOM (out of memory, 内存不足),服务全部不可用,赶紧查看问题!


问题排查

首先运维先重启了机器,保证线上服务可用,然后再仔细地看了下线上的日志,确实是因为 OOM 导致服务不可用:

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

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

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

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


还原事发经过

最近线上出现不少 NoHttpResponseException 的异常, 而上面那个配置就是为了解决这个异常而添加的,那是什么导致了这个异常呢?

在说这个问题之前我们得先了解一下 http 的 keep-alive 机制。

先看下正常的一个 TCP 连接的生命周期:

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

如下图示,左图是不复用 TCP 发起多个 HTTP 请求的情况,右图是复用 TCP 的情况,可以看到发起三次 HTTP 请求,复用 TCP 的话可以省去两次建立 / 断开 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 的原因,这样能看得更明白一些:

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

  1. 重试,收到异常后,重试一两次,由于重试后客户端会用有效的连接去请求,所以可以避免这种情况,不过一次要注意重试次数,避免引起雪崩!

  2. 设置一个定时线程,定时清理上述的闲置连接,可以将这个定时时间设置为 keep alive timeout 时间的一半以保证超时前回收。

evictExpiredConnections 就是用的上述第二种策略,来看下官方用法使用说明:Makes this instance of HttpClient proactively evict idle connections from the connection pool using a background thread.

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

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


解决问题

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

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


总结

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

声明:本文为作者投稿,版权归作者个人所有。

【End】

热 文 推 荐 

☞百度起诉今日头条;腾讯云超 8000 名员工获 iPhone 11 Pro 奖励;PHP 7.4.1 发布 | 极客头条

从事 Android 开发六年,我学到的那些事!

英特尔首推异构编程神器 oneAPI,可让程序员少加班!

2019年区块链安全事件总结,全球损失超60亿美元 | 盘点

互联网诞生记: 浪成于微澜之间

GitHub宝藏项目标星1.6w+,编程新手有福了

马云穿布鞋演讲,任正非打的出行,盘点科技大佬们令人发指的节俭生活

点击阅读原文,即刻参加!

你点的每个“在看”,我都认真当成了喜欢

OOM,全称“Out Of Memory”,翻译成中文即为“内存用完了”,它来源于 `java.lang.OutOfMemoryError`。当 Java 虚拟机(JVM)因为没有足够的内存来为对象分配空间,并且垃圾回收器也无法释放出更多内存时,就会抛出这个错误[^2]。 在 Java 应用中,OOM 是一种严重的运行时错误,通常表明系统资源已经耗尽。它不是一种可以被应用程序轻易处理的异常(Exception),而是属于错误(Error),意味着问题已经严重到 JVM 无法继续正常执行程序[^2]。 OOM 可能出现在不同的内存区域中,常见的类型包括: - **Java heap space**:堆内存溢出,通常是因为创建了大量对象,而这些对象又无法及时被垃圾回收器回收。 - **PermGen space**(Java 7 及之前版本):永久代内存溢出,主要发生在类的元数据信息过多时。 - **Metaspace**(Java 8 及之后版本):元空间内存溢出,用于替代永久代,存储类的元数据。 - **Direct buffer memory**:直接缓冲区内存溢出,发生在使用 NIO 时分配的本地内存超出限制。 - **Stack overflow**:线程栈溢出,通常由于递归过深或线程栈大小不足导致[^3]。 OOM发生原因多种多样,可能包括内存泄漏、不合理的 JVM 参数设置、线程栈空间不足、频繁创建大对象等。为避免 OOM,开发人员可以采取以下措施: - 合理设置 JVM 启动参数,如堆大小、元空间大小等。 - 对代码进行优化,避免不必要的对象创建和内存占用。 - 使用内存监控工具检测并修复内存泄漏。 - 优化垃圾收集器的选择与配置,提高内存回收效率[^3]。 ### 分析与解决 OOM 的方法 分析 OOM 通常需要借助以下手段: - 查看异常堆栈信息,定位导致内存耗尽的代码位置。 - 利用内存分析工具(如 MAT、VisualVM)生成并分析 Heap Dump(堆转储文件),找出内存占用高的对象。 - 检查是否存在死循环、循环依赖、线程栈过深等问题。 - 调整 JVM 参数,如 `-Xmx`(最大堆内存)、`-Xms`(初始堆内存)、`-Xss`(线程栈大小)等[^5]。 例如,可以通过以下方式设置 JVM 参数来调整堆内存: ```bash java -Xms512m -Xmx1024m -jar your_application.jar ``` 上述命令将 JVM 的初始堆内存设置为 512MB,最大堆内存设置为 1024MB。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值