# Tomcat服务,OOM导致异常不自动恢复研究
某晚,收到同事的告警:“xx服务预发环境挂了,报超时和404错误,来回持续半个小时了,不像是发布导致的,看下?”
由于是预发环境,且整体影响面不大(只是间歇不可用),有足够的时间慢慢排查,因此暂时没有回滚。
服务栈
- 服务为内部服务,服务间使用OpenFeign作为RPC调用手段,使用SpringBoot+默认Tomcat作为Http服务提供者
- 使用注册中心进行服务注册发现,使用SpringCloud默认的
/actuator/health
接口作为健康检查指标- k8s服务整合进程探测,同时与注册中心通信,作为容器健康检查的2个因子,持续3次全部健康检查失败的容器会被销毁并重新拉起(预发环境关闭了这个逻辑)
# 1. 404错误
404错误一般是由于预发环境仅单节点,服务因为FullGC等原因健康检查失败导致被注册中心摘除后,暂时没有服务提供者导致,对于线上环境会快速拉起新容器短暂恢复,对于预发环境一般需要等服务恢复后重新注册,然后又出现问题
- 查看注册中心事件日志发现确实持续有节点健康检查失败事件
- 容器内部检查后发现服务进程正常,8080端口正常绑定,但是
netstat
指令的输出,第2列和第3列与平时貌似不太一样 - 经验判断服务出现了某些异常导致健康检查失败,考虑到调用方有较多的超时报错,从此处入手是一个不错的思路
# 2. 健康检查失败同时调用方超时的一般思路
# 2.1 RT高时的一般现象
接口调用超时一般分为2种,建连超时(connectionTimeout
)和响应发送/读取超时(socketReadTimeout
),按一般经验,当出现某接口RT高时,两者会交替出现
最初是接口RT高,调用方报错超时(也有可能不报错,看调用方的容忍度),本服务Tomcat线程堆积
- 这里与接口RT(ms),QPS,Tomcat最大线程数3个参数有关,当3者出现如下关系时,会导致Tomcat线程持续堆积,即线程无法即使处理请求并释放到线程池
- 注意这里是假定服务只有一个接口,实际上服务同时有很多接口所以会有所偏差
- 举例,一般来说Tomcat线程池默认200,假设某接口集群QPS为20000,集群规模50,则单节点承受400QPS压力,此时接口RT至少要保持在 200*1000/400=500ms 以下才可保证线程池始终有可用的线程
Tomcat线程很快达到最大线程数,线程池没有线程来立即处理请求,此时请求排队进行处理,上游
socketReadTimeout
超时报错逐渐增多线程堆积到一定程度,开始拒绝TCP连接,此时上游开始报错
connectionTimeout
,建立连接失败与此同时,健康检查调用
/actuator/health
接口也超时,此时被暂时从注册中心摘除随着被注册中心摘除,新请求暂时不会调用到问题pod,积压请求处理完成后,pod恢复正常,健康检查成功,重新放入注册中心
由于接口RT仍旧存在问题,如此往复
# 2.2 问题排查与解决
一般思路
根据上面的分析,碰到双超时出现,首先分析接口RT,90%的情况下通过接口监控定位到特别慢的接口,再结合调用链监控或者性能剖析,定位到具体的代码、服务、依赖可解决。
问题原因
但是这次通过接口定位发现所有接口的RT都有大幅上升,排查性能瓶颈与依赖后,发现根本原因是某处请求处理代码,实现时未考虑大数据量的处理,导致出现OOM报错,代码修改后问题解决。
延伸
深入观察发现,问题代码需要某条特殊的响应才会触发(1小时以上才会有1次请求),确实导致了1分钟的FullGC和OOM异常,经验来说,请求处理线程因为OOM报错后,线程销毁,栈上引用消失,最多1次FullGC后,容器会恢复正常响应,但是现象中说超时持续了半小时没有自动恢复,为什么?
详细排查日志后发现,在MQ线程OOM报错的同时,Tomcat有个Acceptor线程同时OOM异常,以往碰到的情况都是exec线程崩溃但会自动恢复,会不会这个线程不一样?
# 3. Accetpor线程不会自动恢复
# 3.1 Tomcat线程模型
Tomcat的核心Acceptor线程在建连后崩溃,但是端口仍旧绑定中,最终的结果是TCP连接可以建立(系统内核处理的部分),但是TCP请求体无法被取回处理(Tomcat的Acceptor线程处理的部分),这个时候需要了解下Tomcat的线程模型(图引用自 Tomcat线程模型全面解析 (opens new window)),以及线程(池)恢复机制
以上是模型图,事实上不同的线程初始化的数量是不一致的,线程崩溃后的恢复策略也不一致
# 3.2 问题模拟与现象观测
下面是具体的实例,在5分钟左右模拟了一次OOM导致的Acceptor线程崩溃(代码参见Github (opens new window))
进一步的观测网络异常栈
服务正常 Acceptor线程崩溃 崩溃后新请求进入
从模型、模拟以及现象观察中可以得出初步结论:程序启动时,Acceptor线程只有一个,此线程OOM后没有被恢复,导致新请求阻塞在内核的TCP队列中
# 3.3 简单源码剖析
下述代码为Tomcat启动Acceptor线程的代码,可以看到,只有一个线程,且无异常恢复机制(线程,内部对象均为方法本地变量)
// org.apache.tomcat.util.net.AbstractEndpoint#startAcceptorThread
protected void startAcceptorThread() {
acceptor = new Acceptor<>(this);
String threadName = getName() + "-Acceptor";
acceptor.setThreadName(threadName);
Thread t = new Thread(acceptor, threadName);
t.setPriority(getAcceptorThreadPriority());
t.setDaemon(getDaemon());
t.start();
}
2
3
4
5
6
7
8
9
10
参考