晚上生产告警群里突然开始刷屏。网关 Pod 不断重启,有客户反馈操作页面时好时坏,K8s 事件里清一色的:
Readiness probe failed: Get "http://192.168.5.1:7888/actuator/health": read tcp 192.168.5.90:53038->192.168.5.1:7888: read: connection reset by peer打开网关的日志查看原来是MQ连不上,一直在报异常;突然想起来今晚运维在做MQ的升级,评估升级不超过3分钟,但以现在情况看已经超过8分钟;之前提前将management.health.rabbit.enabled 明明已经设成 false 了啊。当时第一反应是:RabbitMQ 重启跟你们的健康检查有什么关系?
服务框架:线上 Kubernetes 集群跑着一套微服务架构,Spring Cloud Gateway 做网关,base-server 做基础服务,Nacos 做注册中心和配置中心,RabbitMQ 做消息总线。整体跑了几个月,没出过什么大问题。
直到今晚运维那边的 RabbitMQ 升级做了一次滚动重启。
排查网关:connection reset by peer
先看日志connection reset by peer 说明 TCP 连接被 Pod 内的进程直接拒绝了,内核发的 RST。通常意味着进程要么没启动完,要么根本没能力处理新连接。
pod的探针路径 /actuator/health,initialDelaySeconds: 40。40 秒的启动延迟,正常情况下绰绰有余。
但日志里发现了这么一条:
logger: c.j.servicemesh.gateway.service.AccessLogService msg: access logs save error:{} org.springframework.amqp.AmqpIOException: java.io.IOException at org.springframework.amqp.rabbit.core.RabbitAdmin.initialize(RabbitAdmin.java:591) at org.springframework.amqp.rabbit.connection.CachingConnectionFactory.createConnection(...) ... at com.join.servicemesh.gateway.service.AccessLogService.sendLog(AccessLogService.java:186)AccessLogService.sendLog() 是个 @Async 方法,往 RabbitMQ 发访问日志。RabbitMQ 挂了之后,amqpTemplate.convertAndSend() 不会立刻失败——Spring AMQP 内置了 RetryTemplate,会反复重试等待重连,单次阻塞可以卡 30 到 60 秒。
这本身没什么,@Async 嘛,在独立线程池里跑,不影响主链路。
直到我去看了线程池的配置。
executor.setCorePoolSize(50); executor.setMaxPoolSize(100); executor.setQueueCapacity(200); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());CallerRunsPolicy 看到这四个字的时候,我基本就知道问题出在哪了。
线程池的执行逻辑是这样的:先创建核心线程(50个),核心线程满了放队列(200个),队列满了继续创建线程直到最大线程数(100个)。当线程和队列全满的时候,触发拒绝策略。
CallerRunsPolicy 的行为是:谁提交的任务,谁自己来执行。
那提交任务的是谁?是 Netty 的 Event Loop 线程。
于是故障链就清晰了:
RabbitMQ 重启 → convertAndSend() 阻塞等待重连(30~60秒) → @Async 线程池里 100 个线程全部卡在 RabbitMQ 上 → 队列塞满 200 个任务 → 再来请求,触发 CallerRunsPolicy → Netty Event Loop 线程亲自执行 sendLog() → Event Loop 线程也被阻塞 → 整个网关无法接受任何连接 → K8s 探针打过来,收到 RST → connection reset by peer一个发日志的辅助功能,把整个网关拖死了。
在基础设施抖动期间,如果连接池恰好被打满,这指示器随便哪个卡一下,health 端点就响应不出来了。10 秒超时一到,K8s 判定 liveness 失败。
更严重的是 liveness 失败不是摘流量,是直接重启 Pod。Pod 重启 → 启动过程中 RabbitMQ 还没恢复 → 又失败 → 无限循环。
怎么修复
第一处,线程池拒绝策略改为 DiscardPolicy。
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());DiscardPolicy 在线程池满的时候直接丢弃任务,不抛异常,不阻塞调用者。丢几条访问日志无所谓,网关的核心职责是转发请求,不能因为日志发不出去就把自己搞挂了。
CallerRunsPolicy 适合什么场景?订单处理、支付回调这种任务必须执行的业务。拿它来发日志,属于用大炮打蚊子,还打到了自己脚上。
第二处,convertAndSend 加 try-catch 隔离。
try { amqpTemplate.convertAndSend(QueueConstants.QUEUE_ACCESS_LOGS, map); } catch (Exception e) { log.warn("access log amqp send failed, will be discarded: {}", e.getMessage()); }这是个兜底。即使线程池没满,单个 @Async 线程也不应该在 RabbitMQ 重连上卡太久。catch 住异常,快速释放线程。
事后复盘
1. 辅助功能和核心链路必须物理隔离
发日志、打埋点、上报指标,这些都是辅助功能。它们的线程池、连接池、超时配置都应该和核心链路隔离开。这次的问题本质上是日志发送的阻塞扩散到了请求转发的主链路。
2. CallerRunsPolicy 不是万能药
很多人觉得 CallerRunsPolicy 是个"保险"策略——任务不会丢,还能自动反压。但在异步场景下,它把任务回退到调用者线程执行,如果调用者是 Web 容器的工作线程,那就是在给自己埋雷。选拒绝策略之前,先想清楚"调用者线程被阻塞会怎样"。
3. 健康探针要越轻越好
show-details: ALWAYS 是个方便调试的配置,但在生产环境配合 K8s 探针使用,等于给每次健康检查都加了 N 个组件的连通性测试。任何一个组件抖动都可能导致探针超时。探针就应该是"你还活着吗"这种最简问题,不是"你所有零件都好吗"这种全面体检。
4. 一个 RabbitMQ 重启不应该导致服务雪崩
RabbitMQ 在这个架构里只承担访问日志投递和消息总线的职责,不是核心链路上的组件。但它的一次重启导致了网关瘫痪,说明系统对非核心依赖的故障隔离做得不够。核心原则:任何一个非核心组件的故障,都不应该导致核心服务不可用。
之前网关问题也写过《干货分享Gateway踩坑实录:Netty直接内存把网关吃垮了OutOfDirectMemoryError
》,不过这次是另一个问题。本次最终改动量很小:拒绝策略、 try-catch。但定位问题的过程,把线程池模型、Netty Event Loop、Spring AMQP 重连机制、K8s 探针行为都过了一遍。有时候修复一个线上问题,改的代码不超过十行,但理解这十行为什么要改,可能需要一整个下午。