
晚上生产告警群里突然开始刷屏。网关 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/healthinitialDelaySeconds: 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 探针行为都过了一遍。有时候修复一个线上问题改的代码不超过十行但理解这十行为什么要改可能需要一整个下午。