1. 线上OOM事故现场还原与初步响应那天晚上我正吃着饭手机突然像疯了一样震动起来短信提示音连成一片。拿起来一看全是APM应用性能监控系统的告警。我心里“咯噔”一下知道出事了。APM是我们自己搭建的一套监控体系专门盯着线上应用的性能和可靠性它这么狂叫准没好事。果不其然还没等我点开告警详情细看运维兄弟的电话就打过来了语气急促“线上四台机器全挂了都是OOM内存溢出服务全不可用”四台机器同时OOM这可不是小事。我们的服务部署是分布式的理论上应该互不影响同时挂掉意味着问题可能出在一个共同的、全局性的因素上而不是某台机器的个别故障。我立刻放下筷子打开电脑。运维同事的第一要务是恢复服务所以他已经在执行重启操作。这是标准流程先保可用性再查根因。但这也意味着机器一重启事发时内存的完整状态也就是那个能告诉我们“到底是谁吃光了内存”的堆内存快照就丢失了没法直接生成heap dump来分析。这给后续的问题定位增加了不小的难度。注意在线上环境遇到OOM时如果条件允许例如有备用节点可以切换在重启前尽量先保存一份heap dump。这就像刑事案件的现场保护第一手证据至关重要。如果像我们这次一样为了快速恢复服务不得不立即重启那么就需要依靠其他监控手段来“还原现场”了。重启后服务暂时恢复了。但我知道这只是把火扑灭了起火点还没找到随时可能复燃。我马上登录到APM系统调出那四台服务器在故障时间段的监控图表。我们的APM集成了对JVM的详细监控包括堆内存使用情况、GC频率、线程数等等。当看到“线程数”这个指标的曲线时我后背一凉问题瞬间清晰了一大半。2. 问题排查从监控图表中锁定真凶监控图表清晰地显示从当天下午16:00开始其中一台应用实例的线程数开始脱离正常轨道从基线的大约600条像坐了火箭一样直线攀升。它不是阶梯式上涨而是近乎一条斜线持续增长在故障发生时达到了惊人的3万左右。重启之后图表上用一个蓝色箭头标记了重启时间点线程数短暂归零但紧接着又开始了完全相同的增长模式曲线几乎和重启前一模一样。这个现象太典型了。正常情况下一个Web应用的线程数应该是相对稳定的会根据连接池配置和请求量在小范围内波动。这种持续、线性的增长几乎可以肯定是有线程在不停地被创建而且创建后没有被正确回收导致了“线程泄漏”。线程本身会占用内存主要是线程栈空间当几万个线程同时存在时光是栈内存的消耗就可能挤爆JVM。我立刻去查了发布记录。故障时间点是16:00那么在这个时间点附近上线的代码就是最大的嫌疑人。果然在16:00左右的一次发布中我发现了一段唯一的、与网络通信相关的代码改动在初始化HttpClient的地方新增了一个配置项evictExpiredConnections。时间点完全吻合。线程开始暴涨的时刻正是这段代码生效的时刻。这基本上可以锁定这个新加的配置就是导致线程泄漏的元凶。为了快速止血我们立即回滚了这段代码发布上线。效果立竿见影监控图表上的线程数曲线停止了增长并逐渐回落到正常水平。服务稳定了。但事情还没完。我们知道了是evictExpiredConnections这个开关惹的祸但它到底做了什么我们当初为什么要加它不搞清楚这些我们只是暂时躲过一劫根本问题依然存在。于是我找到了当时添加这段代码的同事一起还原了整个事件的“前因”。3. 根源深挖从NoHttpResponseException到Keep-Alive机制同事告诉我加这个配置是为了解决近期线上频繁出现的NoHttpResponseException异常。这个异常通常发生在HTTP客户端认为连接还可用但服务端已经关闭了该连接的时候。要理解这个异常以及为什么evictExpiredConnections被当作解决方案就必须深入理解HTTP的Keep-Alive连接复用机制。HTTP协议早期每个请求-响应周期都要经历一次完整的TCP连接三次握手建立连接传输数据四次挥手断开连接。对于高频的HTTP请求这种频繁的建立和断开连接开销巨大严重影响了性能。Keep-Alive机制就是为了解决这个问题而生的。它允许在同一个TCP连接上发送和接收多个HTTP请求/响应省去了重复握手和挥手的开销。你可以把它想象成一条电话线一次接通后可以连续进行多次对话而不是每次说完就挂断下次再说再重新拨号。HTTP/1.1协议默认就启用了Keep-Alive。然而天下没有免费的午餐。如果一条连接建立后长时间没有请求空闲状态它就会一直占用着系统的资源如文件描述符、内存。为了应对这种情况通常会给Keep-Alive连接设置一个超时时间例如30秒。如果一条连接空闲时间超过了这个超时时间服务端或客户端就会主动发起关闭连接的操作。问题就出在这个“主动关闭”的过程上。考虑一个临界场景服务端因为连接空闲超时决定关闭连接。它发送了一个FIN包TCP断开连接的第一次挥手开始断开流程。这个FIN包在网络中传输尚未到达客户端。就在这个时间窗口内客户端恰好要发起一个新的HTTP请求。它看到这条TCP连接还存在因为还没收到FIN包认为它是可用的于是复用了这条连接将请求报文发送了出去。服务端此时正处于关闭连接的状态已发送FIN按照TCP协议它不应该再处理这条连接上的新数据。因此服务端会回复一个RST复位报文给客户端。客户端收到RST报文就会抛出NoHttpResponseException异常。简单来说这就是一个客户端和服务端对连接状态认知不同步的问题客户端以为连接还活着服务端却已经把它“判了死刑”。这种异常在高并发、长连接场景下虽然不频繁但确实会出现。4. 解决方案的陷阱evictExpiredConnections的工作原理与误用那么如何解决这个NoHttpResponseException呢通常有两种思路重试机制在客户端捕获到NoHttpResponseException后进行有限次数的重试例如1-2次。因为重试时连接池会提供一条新的、有效的连接从而避开那个“将死未死”的连接。这种方法简单但需要注意重试次数和策略避免对下游服务造成雪崩效应。主动驱逐不让连接活到服务端超时的那一刻。在客户端设置一个后台任务定期检查连接池中的空闲连接如果空闲时间接近或超过服务端Keep-Alive超时时间就主动将其驱逐关闭。这样客户端总是使用健康的连接从根本上避免了上述的临界状态。HttpClient的evictExpiredConnections()方法采用的就是第二种策略。它的官方说明是“使用后台线程主动地从连接池中驱逐空闲连接。” 这听起来是个完美的方案——我们主动管理连接的生命周期防患于未然。但这里隐藏着一个巨大的陷阱也是我们这次事故的直接原因这个方法的调用会为每个HttpClient实例创建一个独立的、守护线程Daemon Thread来执行定时清理任务。我们的代码是怎么写的呢在改造之前为了图方便或者说缺乏对HttpClient最佳实践的了解我们在处理每个外部请求的代码路径中都new了一个全新的HttpClient实例。这意味着每来一个请求就创建一个HttpClient每创建一个HttpClient就调用一次evictExpiredConnections()每调用一次这个方法就产生一个后台清理线程。从16:00发布上线开始随着请求不断进来HttpClient实例被疯狂创建与之对应的后台清理线程也随之疯狂创建。这些线程都是守护线程不会阻止JVM关闭但它们会一直存活定期执行任务。更致命的是由于我们的代码没有正确关闭HttpClient或者因为它是局部变量被GC回收时关联的资源未能及时释放这些线程可能无法被正常回收。于是线程数线性增长每个线程占用一定的栈内存通常默认512KB-1MB最终在创建了约3万个线程时耗尽了所有可用内存触发OOM进程崩溃。5. 为什么四台机器同时挂掉另一个有趣的问题是为什么四台机器几乎在同一时间点OOM这其实和我们的架构有关。这四台机器是同一个服务的无状态实例前面有一个负载均衡器如Nginx或硬件F5。负载均衡器通常采用轮询Round Robin或类似的均衡策略将外部流量大致均匀地分发到这四台机器上。这四台机器的硬件配置CPU、内存完全相同部署的代码版本也完全一致。因此从16:00问题代码生效开始每台机器接收到的请求量是相近的。那么每台机器创建HttpClient实例的速率、进而创建后台清理线程的速率也是高度同步的。它们的线程数曲线几乎是平行的。当线程数积累到接近内存极限比如2.8万时任何一点轻微的内存波动比如一次稍微大一点的请求处理都可能成为压垮骆驼的最后一根稻草。由于四台机器状态高度一致它们就在很短的时间间隔内相继达到了崩溃的临界点从而出现了“同时挂掉”的现象。这并非巧合而是分布式系统中间质化节点在相同错误代码影响下的必然结果。6. 正确的解决方案与架构改进定位到根本原因后我们的解决方案就非常明确了将HttpClient改为单例模式这是最重要的一步。对于HttpClient这种重量级、线程安全的对象最佳实践是全局共享一个或少量几个实例通过连接池管理而不是每次请求都创建。我们创建了一个全局的HttpClient单例配置好连接池参数最大连接数、每路由最大连接数等和超时设置。这样无论有多少请求都复用这一个客户端实例evictExpiredConnections()也只会被调用一次整个应用只会产生一个后台清理线程。这彻底解决了线程泄漏问题。配置合理的连接池参数单例化之后连接池的配置就至关重要了。我们需要根据服务的实际吞吐量和下游服务的承受能力设置MaxTotalConnections最大总连接数和DefaultMaxPerRoute每个路由/主机的默认最大连接数。设置太小会成为瓶颈设置太大会浪费资源并可能压垮下游服务。完善监控与告警这次事故暴露了我们在“线程数”监控告警上的缺失。我们只有基础的JVM内存和CPU监控却没有对线程数这个关键指标设置阈值告警。事后我们立即在APM系统中为每台机器的线程数增加了告警规则。例如设置一个基线正常600当线程数持续超过基线的150%即900并维持一段时间就触发预警超过200%1200则触发高级别告警。这样我们就能在问题演变为OOM之前提前介入排查真正做到“治未病”。代码审查与最佳实践推广我们在团队内部分享了这次事故的完整复盘将HttpClient单例化作为一条强制性的编码规范。同时也强调了对于引入的任何第三方库的配置项尤其是涉及线程、连接等资源的必须仔细阅读官方文档理解其行为模式和潜在代价。7. 经验总结与反思这次四机同时OOM的惊险排查给我和团队带来了几个极其深刻的教训第一对基础设施组件的理解必须透彻。HttpClient看似简单但背后的连接池、线程模型、资源管理机制并不简单。如果当初我们有人深入了解evictExpiredConnections的实现机制知道它会创建线程或许就能在代码审查阶段避免这个问题。使用任何一个库不能只停留在“它能用”的层面至少要了解其核心机制和关键配置的影响。第二监控体系的完备性就是线上系统的生命线。如果没有APM系统提供的线程数增长曲线我们很难如此快速地定位到问题方向。监控不仅要覆盖CPU、内存、磁盘、网络等硬件指标更要覆盖应用层面的核心指标线程数、连接数、队列长度、关键接口的耗时和QPS等。告警阈值的设置需要结合历史基线既不能太敏感导致告警风暴也不能太迟钝失去了预警意义。第三分布式系统的同质化风险。当所有服务实例的配置、代码完全相同时一个隐藏的Bug会同时影响所有实例可能导致全面崩溃。在设计容错机制时可以考虑引入一些“差异性”例如错开配置更新时间、采用蓝绿部署或金丝雀发布来逐步验证新代码。同时熔断、降级、限流等机制必须健全防止单个实例的问题蔓延成整个系统的雪崩。第四止血与根治并重。运维同事第一时间重启服务是正确的止血操作保障了业务连续性。但我们绝不能止步于“重启大法好”。必须沿着监控线索深入挖掘直到找到根本原因并实施修复。否则问题一定会换一种形式在你不注意的时候再次出现。这次事故就像一次高强度的消防演习虽然过程惊心动魄但也极大地锻炼了我们团队的问题排查、应急响应和系统性改进的能力。每一个线上故障都是提升系统稳定性和团队技术深度的宝贵机会。
从NoHttpResponseException到线程泄漏:HttpClient配置不当引发的OOM事故复盘
发布时间:2026/5/23 20:58:54
1. 线上OOM事故现场还原与初步响应那天晚上我正吃着饭手机突然像疯了一样震动起来短信提示音连成一片。拿起来一看全是APM应用性能监控系统的告警。我心里“咯噔”一下知道出事了。APM是我们自己搭建的一套监控体系专门盯着线上应用的性能和可靠性它这么狂叫准没好事。果不其然还没等我点开告警详情细看运维兄弟的电话就打过来了语气急促“线上四台机器全挂了都是OOM内存溢出服务全不可用”四台机器同时OOM这可不是小事。我们的服务部署是分布式的理论上应该互不影响同时挂掉意味着问题可能出在一个共同的、全局性的因素上而不是某台机器的个别故障。我立刻放下筷子打开电脑。运维同事的第一要务是恢复服务所以他已经在执行重启操作。这是标准流程先保可用性再查根因。但这也意味着机器一重启事发时内存的完整状态也就是那个能告诉我们“到底是谁吃光了内存”的堆内存快照就丢失了没法直接生成heap dump来分析。这给后续的问题定位增加了不小的难度。注意在线上环境遇到OOM时如果条件允许例如有备用节点可以切换在重启前尽量先保存一份heap dump。这就像刑事案件的现场保护第一手证据至关重要。如果像我们这次一样为了快速恢复服务不得不立即重启那么就需要依靠其他监控手段来“还原现场”了。重启后服务暂时恢复了。但我知道这只是把火扑灭了起火点还没找到随时可能复燃。我马上登录到APM系统调出那四台服务器在故障时间段的监控图表。我们的APM集成了对JVM的详细监控包括堆内存使用情况、GC频率、线程数等等。当看到“线程数”这个指标的曲线时我后背一凉问题瞬间清晰了一大半。2. 问题排查从监控图表中锁定真凶监控图表清晰地显示从当天下午16:00开始其中一台应用实例的线程数开始脱离正常轨道从基线的大约600条像坐了火箭一样直线攀升。它不是阶梯式上涨而是近乎一条斜线持续增长在故障发生时达到了惊人的3万左右。重启之后图表上用一个蓝色箭头标记了重启时间点线程数短暂归零但紧接着又开始了完全相同的增长模式曲线几乎和重启前一模一样。这个现象太典型了。正常情况下一个Web应用的线程数应该是相对稳定的会根据连接池配置和请求量在小范围内波动。这种持续、线性的增长几乎可以肯定是有线程在不停地被创建而且创建后没有被正确回收导致了“线程泄漏”。线程本身会占用内存主要是线程栈空间当几万个线程同时存在时光是栈内存的消耗就可能挤爆JVM。我立刻去查了发布记录。故障时间点是16:00那么在这个时间点附近上线的代码就是最大的嫌疑人。果然在16:00左右的一次发布中我发现了一段唯一的、与网络通信相关的代码改动在初始化HttpClient的地方新增了一个配置项evictExpiredConnections。时间点完全吻合。线程开始暴涨的时刻正是这段代码生效的时刻。这基本上可以锁定这个新加的配置就是导致线程泄漏的元凶。为了快速止血我们立即回滚了这段代码发布上线。效果立竿见影监控图表上的线程数曲线停止了增长并逐渐回落到正常水平。服务稳定了。但事情还没完。我们知道了是evictExpiredConnections这个开关惹的祸但它到底做了什么我们当初为什么要加它不搞清楚这些我们只是暂时躲过一劫根本问题依然存在。于是我找到了当时添加这段代码的同事一起还原了整个事件的“前因”。3. 根源深挖从NoHttpResponseException到Keep-Alive机制同事告诉我加这个配置是为了解决近期线上频繁出现的NoHttpResponseException异常。这个异常通常发生在HTTP客户端认为连接还可用但服务端已经关闭了该连接的时候。要理解这个异常以及为什么evictExpiredConnections被当作解决方案就必须深入理解HTTP的Keep-Alive连接复用机制。HTTP协议早期每个请求-响应周期都要经历一次完整的TCP连接三次握手建立连接传输数据四次挥手断开连接。对于高频的HTTP请求这种频繁的建立和断开连接开销巨大严重影响了性能。Keep-Alive机制就是为了解决这个问题而生的。它允许在同一个TCP连接上发送和接收多个HTTP请求/响应省去了重复握手和挥手的开销。你可以把它想象成一条电话线一次接通后可以连续进行多次对话而不是每次说完就挂断下次再说再重新拨号。HTTP/1.1协议默认就启用了Keep-Alive。然而天下没有免费的午餐。如果一条连接建立后长时间没有请求空闲状态它就会一直占用着系统的资源如文件描述符、内存。为了应对这种情况通常会给Keep-Alive连接设置一个超时时间例如30秒。如果一条连接空闲时间超过了这个超时时间服务端或客户端就会主动发起关闭连接的操作。问题就出在这个“主动关闭”的过程上。考虑一个临界场景服务端因为连接空闲超时决定关闭连接。它发送了一个FIN包TCP断开连接的第一次挥手开始断开流程。这个FIN包在网络中传输尚未到达客户端。就在这个时间窗口内客户端恰好要发起一个新的HTTP请求。它看到这条TCP连接还存在因为还没收到FIN包认为它是可用的于是复用了这条连接将请求报文发送了出去。服务端此时正处于关闭连接的状态已发送FIN按照TCP协议它不应该再处理这条连接上的新数据。因此服务端会回复一个RST复位报文给客户端。客户端收到RST报文就会抛出NoHttpResponseException异常。简单来说这就是一个客户端和服务端对连接状态认知不同步的问题客户端以为连接还活着服务端却已经把它“判了死刑”。这种异常在高并发、长连接场景下虽然不频繁但确实会出现。4. 解决方案的陷阱evictExpiredConnections的工作原理与误用那么如何解决这个NoHttpResponseException呢通常有两种思路重试机制在客户端捕获到NoHttpResponseException后进行有限次数的重试例如1-2次。因为重试时连接池会提供一条新的、有效的连接从而避开那个“将死未死”的连接。这种方法简单但需要注意重试次数和策略避免对下游服务造成雪崩效应。主动驱逐不让连接活到服务端超时的那一刻。在客户端设置一个后台任务定期检查连接池中的空闲连接如果空闲时间接近或超过服务端Keep-Alive超时时间就主动将其驱逐关闭。这样客户端总是使用健康的连接从根本上避免了上述的临界状态。HttpClient的evictExpiredConnections()方法采用的就是第二种策略。它的官方说明是“使用后台线程主动地从连接池中驱逐空闲连接。” 这听起来是个完美的方案——我们主动管理连接的生命周期防患于未然。但这里隐藏着一个巨大的陷阱也是我们这次事故的直接原因这个方法的调用会为每个HttpClient实例创建一个独立的、守护线程Daemon Thread来执行定时清理任务。我们的代码是怎么写的呢在改造之前为了图方便或者说缺乏对HttpClient最佳实践的了解我们在处理每个外部请求的代码路径中都new了一个全新的HttpClient实例。这意味着每来一个请求就创建一个HttpClient每创建一个HttpClient就调用一次evictExpiredConnections()每调用一次这个方法就产生一个后台清理线程。从16:00发布上线开始随着请求不断进来HttpClient实例被疯狂创建与之对应的后台清理线程也随之疯狂创建。这些线程都是守护线程不会阻止JVM关闭但它们会一直存活定期执行任务。更致命的是由于我们的代码没有正确关闭HttpClient或者因为它是局部变量被GC回收时关联的资源未能及时释放这些线程可能无法被正常回收。于是线程数线性增长每个线程占用一定的栈内存通常默认512KB-1MB最终在创建了约3万个线程时耗尽了所有可用内存触发OOM进程崩溃。5. 为什么四台机器同时挂掉另一个有趣的问题是为什么四台机器几乎在同一时间点OOM这其实和我们的架构有关。这四台机器是同一个服务的无状态实例前面有一个负载均衡器如Nginx或硬件F5。负载均衡器通常采用轮询Round Robin或类似的均衡策略将外部流量大致均匀地分发到这四台机器上。这四台机器的硬件配置CPU、内存完全相同部署的代码版本也完全一致。因此从16:00问题代码生效开始每台机器接收到的请求量是相近的。那么每台机器创建HttpClient实例的速率、进而创建后台清理线程的速率也是高度同步的。它们的线程数曲线几乎是平行的。当线程数积累到接近内存极限比如2.8万时任何一点轻微的内存波动比如一次稍微大一点的请求处理都可能成为压垮骆驼的最后一根稻草。由于四台机器状态高度一致它们就在很短的时间间隔内相继达到了崩溃的临界点从而出现了“同时挂掉”的现象。这并非巧合而是分布式系统中间质化节点在相同错误代码影响下的必然结果。6. 正确的解决方案与架构改进定位到根本原因后我们的解决方案就非常明确了将HttpClient改为单例模式这是最重要的一步。对于HttpClient这种重量级、线程安全的对象最佳实践是全局共享一个或少量几个实例通过连接池管理而不是每次请求都创建。我们创建了一个全局的HttpClient单例配置好连接池参数最大连接数、每路由最大连接数等和超时设置。这样无论有多少请求都复用这一个客户端实例evictExpiredConnections()也只会被调用一次整个应用只会产生一个后台清理线程。这彻底解决了线程泄漏问题。配置合理的连接池参数单例化之后连接池的配置就至关重要了。我们需要根据服务的实际吞吐量和下游服务的承受能力设置MaxTotalConnections最大总连接数和DefaultMaxPerRoute每个路由/主机的默认最大连接数。设置太小会成为瓶颈设置太大会浪费资源并可能压垮下游服务。完善监控与告警这次事故暴露了我们在“线程数”监控告警上的缺失。我们只有基础的JVM内存和CPU监控却没有对线程数这个关键指标设置阈值告警。事后我们立即在APM系统中为每台机器的线程数增加了告警规则。例如设置一个基线正常600当线程数持续超过基线的150%即900并维持一段时间就触发预警超过200%1200则触发高级别告警。这样我们就能在问题演变为OOM之前提前介入排查真正做到“治未病”。代码审查与最佳实践推广我们在团队内部分享了这次事故的完整复盘将HttpClient单例化作为一条强制性的编码规范。同时也强调了对于引入的任何第三方库的配置项尤其是涉及线程、连接等资源的必须仔细阅读官方文档理解其行为模式和潜在代价。7. 经验总结与反思这次四机同时OOM的惊险排查给我和团队带来了几个极其深刻的教训第一对基础设施组件的理解必须透彻。HttpClient看似简单但背后的连接池、线程模型、资源管理机制并不简单。如果当初我们有人深入了解evictExpiredConnections的实现机制知道它会创建线程或许就能在代码审查阶段避免这个问题。使用任何一个库不能只停留在“它能用”的层面至少要了解其核心机制和关键配置的影响。第二监控体系的完备性就是线上系统的生命线。如果没有APM系统提供的线程数增长曲线我们很难如此快速地定位到问题方向。监控不仅要覆盖CPU、内存、磁盘、网络等硬件指标更要覆盖应用层面的核心指标线程数、连接数、队列长度、关键接口的耗时和QPS等。告警阈值的设置需要结合历史基线既不能太敏感导致告警风暴也不能太迟钝失去了预警意义。第三分布式系统的同质化风险。当所有服务实例的配置、代码完全相同时一个隐藏的Bug会同时影响所有实例可能导致全面崩溃。在设计容错机制时可以考虑引入一些“差异性”例如错开配置更新时间、采用蓝绿部署或金丝雀发布来逐步验证新代码。同时熔断、降级、限流等机制必须健全防止单个实例的问题蔓延成整个系统的雪崩。第四止血与根治并重。运维同事第一时间重启服务是正确的止血操作保障了业务连续性。但我们绝不能止步于“重启大法好”。必须沿着监控线索深入挖掘直到找到根本原因并实施修复。否则问题一定会换一种形式在你不注意的时候再次出现。这次事故就像一次高强度的消防演习虽然过程惊心动魄但也极大地锻炼了我们团队的问题排查、应急响应和系统性改进的能力。每一个线上故障都是提升系统稳定性和团队技术深度的宝贵机会。