ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent Platform 超时故障排查:默认超时与重试放大效应

Agent Platform 超时故障排查:默认超时与重试放大效应 1. 从一次凌晨告警说起Agent Platform 的线上超时到底长什么样做 Agent Platform 这类系统最怕的不是功能跑不通而是功能在测试环境里跑得挺好一上线就开始抽风。我这次遇到的超时故障发生在凌晨两点多监控面板上一条红色的 P99 延迟曲线直接拉满告警消息一条接一条往外蹦。当时第一反应是某个下游服务挂了但登录服务器一看CPU、内存、网络都正常日志里也没有明显的异常堆栈只有大量请求卡在“等待响应”的状态。这个 Agent Platform 的核心职责是接收用户提交的任务然后调度多个 Agent 去执行每个 Agent 可能调用外部工具、访问数据库、或者调用模型服务。整个链路比较长涉及任务编排、工具调用、结果聚合等多个环节。超时故障最麻烦的地方在于它不像崩溃那样有明确的错误信息而是“慢”慢到请求堆积、线程池打满、最终雪崩。我先说结论这次超时的根因不是某一个服务挂了而是多个环节的默认超时配置不合理叠加了重试放大效应。具体来说Agent 调用外部工具的 HTTP 客户端默认没有设置连接超时和读取超时导致个别慢请求长时间占用线程同时任务编排层对失败任务做了无限制重试重试的请求又继续占用资源最终把整个线程池拖垮。这篇文章我会把整个排查过程、根因分析、修复方案和后续加固措施完整讲一遍。如果你也在做 Agent 平台、任务调度系统或者任何涉及多服务调用的后端项目这些经验应该能帮你少走一些弯路。文章会涉及具体的配置参数、代码片段和排查命令尽量做到可以直接参考复现。2. 故障现场还原告警、日志与线程池的异常信号2.1 告警信息里的关键线索凌晨两点十七分监控系统触发了一条 P99 延迟告警阈值是 3 秒实际值已经飙到 28 秒。紧接着任务队列积压告警也响了待处理任务数从平时的几十个涨到了两千多。再往后线程池活跃线程数告警触发活跃线程数达到了最大值 200队列里还有大量任务在排队。这三条告警其实已经勾勒出了问题的轮廓请求变慢导致任务积压任务积压导致线程池被占满线程池满了之后新请求只能排队排队又进一步加剧延迟。这是一个典型的正反馈循环如果不及时打断系统会一直恶化下去。我当时先做了一件事把线程池的堆栈 dump 下来。命令很简单用jstack加上进程 ID 就行jstack pid thread_dump.txt打开 dump 文件后发现大量线程都卡在同一个地方——SocketInputStream.socketRead0。这个 native 方法表示线程正在等待网络读取也就是说这些线程都在等下游服务返回数据。进一步统计发现有超过 120 个线程卡在等待外部工具调用的响应上而线程池最大线程数只有 200。2.2 日志里的“沉默大多数”有意思的是应用日志里几乎没有 ERROR 级别的记录。这是因为超时还没有触发到应用层的异常捕获请求只是“慢”还没有“失败”。但仔细翻看 WARN 日志能发现一些端倪部分工具调用的耗时从平时的 200 毫秒涨到了 10 秒以上而且这些慢请求集中在少数几个外部工具上。这里有一个经验当系统变慢但没有明显报错时优先看线程堆栈和耗时分布而不是盯着错误日志。错误日志只能告诉你什么失败了但超时故障往往发生在失败之前。我还用arthas做了一次在线诊断查看具体哪个方法的调用耗时最长trace com.example.agent.ToolInvoker invoke结果很直观ToolInvoker.invoke方法的耗时分布中有 5% 的调用超过了 15 秒而这些慢调用最终都指向了同一个外部 HTTP 接口。这个接口在正常情况下响应时间在 300 毫秒左右但当晚出现了间歇性的高延迟。2.3 线程池配置的隐患查看线程池配置后发现核心线程数是 50最大线程数是 200队列容量是 1000拒绝策略是CallerRunsPolicy。这个配置在平时够用但一旦出现大量慢请求线程会被长时间占用新任务只能进队列。队列满了之后CallerRunsPolicy会让提交任务的线程自己执行这又会阻塞上游的调度线程导致整个调度链路变慢。更关键的是HTTP 客户端使用的是默认配置连接超时和读取超时都没有显式设置。在大多数 HTTP 客户端实现中默认超时可能是无限等待或者是一个非常长的值。这意味着一旦下游服务不响应线程就会一直卡在那里直到 TCP 层超时或者连接被重置。3. 根因拆解为什么默认超时配置会成为线上炸弹3.1 HTTP 客户端的默认超时到底是多少很多人以为 HTTP 客户端有默认超时但实际上不同库的默认行为差异很大。以常见的 Java HTTP 客户端为例HttpURLConnection的默认连接超时是 0表示无限等待读取超时也是 0同样表示无限等待。Apache HttpClient 的默认超时同样是无限等待除非你显式设置RequestConfig。这就意味着如果下游服务因为网络抖动、GC 停顿或者自身负载过高而暂时不响应你的线程就会一直等下去。在 Agent Platform 这种需要调用多个外部工具的系统中只要有一个工具变慢对应的线程就会被占用累积起来就会耗尽线程池。我后来查了一下代码发现工具调用模块使用的是RestTemplate而RestTemplate底层默认使用SimpleClientHttpRequestFactory它的连接超时和读取超时默认都是 0。这就是问题的第一个根源。3.2 重试机制如何放大故障第二个根源是重试机制。任务编排层对失败任务做了重试重试次数配置为 3 次重试间隔是 1 秒。这个配置本身不算激进但问题在于重试的判断条件是基于异常而超时异常在默认配置下可能很久才抛出。也就是说一个请求可能卡了 30 秒才抛出超时异常然后触发重试重试又卡 30 秒三次重试下来就是 90 秒。在这 90 秒里线程一直被占用。更糟糕的是重试的请求会重新进入线程池排队如果线程池已经满了重试请求会进一步加剧排队。这就形成了一个恶性循环慢请求导致线程占用线程占用导致排队排队导致更多请求超时超时触发重试重试又增加线程占用。3.3 线程池参数与业务特性的匹配问题第三个根源是线程池参数没有根据业务特性做调整。Agent Platform 的任务分为两类一类是短任务比如简单的文本处理耗时在几百毫秒另一类是长任务比如调用外部模型服务耗时可能在几秒到几十秒。这两类任务混在同一个线程池里长任务会挤占短任务的资源。理想的方案是做线程池隔离把短任务和长任务分开或者至少给长任务设置独立的线程池和队列。但当时的实现为了简单所有任务共用一个线程池这就导致长任务一旦变慢短任务也跟着遭殃。我用一个简单的表格来对比一下故障前后的关键指标指标正常情况故障期间P99 延迟800ms28s活跃线程数30-50200打满任务队列积压 50 2000外部工具平均耗时300ms8s超时异常数量0持续增长这张表能很清楚地看到故障不是突然发生的而是逐步恶化的。如果能在活跃线程数开始上涨的时候就介入可能就不会发展到线程池打满的地步。4. 修复过程从止血到根治的完整操作4.1 第一步快速止血恢复服务凌晨的故障处理第一优先级是恢复服务而不是找到根因。我当时做了三件事第一临时扩容线程池。通过配置中心把最大线程数从 200 调到 500队列容量从 1000 调到 2000。这一步只是为了争取时间让积压的任务能尽快被处理。第二手动清理积压任务。把队列中优先级最低的任务先丢弃这些任务大多是批量提交的离线任务可以后续重新提交。丢弃操作通过管理后台完成避免直接操作数据库。第三重启部分实例。对于已经卡死的实例直接重启是最快的方式。重启后这些实例的线程池恢复到初始状态可以正常接收新请求。这三步做完之后P99 延迟从 28 秒降到了 5 秒左右虽然还是偏高但至少服务可用了。4.2 第二步给 HTTP 客户端加上合理的超时止血之后开始做真正的修复。第一件事是给所有 HTTP 客户端设置连接超时和读取超时。连接超时设置为 2 秒读取超时根据工具类型分别设置普通工具 5 秒模型服务 30 秒。代码改动如下Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(2000); factory.setReadTimeout(5000); return new RestTemplate(factory); } Bean public RestTemplate longTimeoutRestTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(2000); factory.setReadTimeout(30000); return new RestTemplate(factory); } }这里的关键是区分不同工具的超时需求。如果所有工具都用同一个超时值要么短了导致正常的长任务被误杀要么长了导致慢请求占用线程太久。分类设置是更合理的做法。注意设置超时后一定要确保超时异常能被正确捕获和处理否则会变成另一种形式的故障。4.3 第三步改造重试机制重试机制需要做两个调整一是限制重试次数二是增加重试的退避策略三是只对特定异常重试。原来的重试是无差别重试任何异常都重试 3 次。改造后只对连接超时和读取超时重试且最多重试 2 次重试间隔采用指数退避第一次 1 秒第二次 2 秒。Bean public RetryTemplate retryTemplate() { RetryTemplate retryTemplate new RetryTemplate(); ExponentialBackOffPolicy backOffPolicy new ExponentialBackOffPolicy(); backOffPolicy.setInitialInterval(1000); backOffPolicy.setMultiplier(2.0); backOffPolicy.setMaxInterval(5000); retryTemplate.setBackOffPolicy(backOffPolicy); SimpleRetryPolicy retryPolicy new SimpleRetryPolicy(); retryPolicy.setMaxAttempts(2); retryTemplate.setRetryPolicy(retryPolicy); return retryTemplate; }指数退避的好处是第一次重试很快如果下游只是短暂抖动第二次重试可能就成功了如果下游确实有问题退避间隔会逐渐拉长避免频繁重试给下游造成更大压力。4.4 第四步线程池隔离与参数调优线程池隔离是这次修复中最重要的一步。我把原来的单一业务线程池拆成了三个短任务线程池核心 20最大 50队列 500用于处理耗时小于 1 秒的任务。长任务线程池核心 30最大 100队列 200用于处理耗时 1 秒到 30 秒的任务。工具调用线程池核心 50最大 200队列 1000专门用于执行外部工具调用。这样拆分之后长任务变慢不会影响短任务工具调用变慢也不会阻塞任务编排。每个线程池可以独立监控、独立调参。线程池的拒绝策略也做了调整从CallerRunsPolicy改为AbortPolicy配合上层做降级处理。当线程池满时直接拒绝新任务并返回“系统繁忙”的提示而不是让提交任务的线程自己执行避免阻塞扩散。5. 排查超时故障时容易踩的坑与实操心得5.1 不要只看平均耗时要看 P99 和 P999这次故障初期我看监控面板上的平均耗时发现只从 200 毫秒涨到了 500 毫秒感觉问题不大。但实际上平均耗时具有欺骗性少数慢请求会被大量快请求平均掉。真正反映问题的是 P99 和 P999也就是最慢的那 1% 和 0.1% 的请求。后来我养成了一个习惯排查性能问题时第一眼看 P99第二眼看线程池活跃数第三眼看队列积压。这三个指标能最快定位到瓶颈。5.2 超时时间不是越长越好很多人担心超时设置太短会导致正常请求被误杀于是把超时设得很长比如 60 秒。但在高并发场景下一个 60 秒的超时意味着线程可能被占用 60 秒如果同时有几百个这样的请求线程池瞬间就满了。合理的做法是根据业务的实际耗时分布来设置超时。比如如果 99% 的请求都在 3 秒内完成那么超时可以设置为 5 秒留出一定的余量。对于确实需要长时间执行的任务应该用异步方式处理而不是让线程一直等着。5.3 重试要有上限更要有退避无限制重试是线上系统的大忌。我见过一些系统重试次数设置为 10 次重试间隔是固定的 100 毫秒。这种配置在下游服务出现故障时会瞬间产生大量重试请求把下游彻底打垮。重试的正确姿势是限制次数通常 2-3 次、指数退避、只对可重试的异常重试。另外重试最好配合熔断器使用当失败率达到阈值时直接熔断不再重试。5.4 线程池隔离是性价比最高的防护手段线程池隔离听起来简单但效果非常明显。把不同特性的任务放到不同的线程池里可以避免相互影响。比如把调用外部服务的任务和本地计算任务分开把长任务和短任务分开。隔离的粒度可以根据业务复杂度来定。最简单的做法是按任务类型分复杂一点可以按下游服务分。隔离之后每个线程池的监控和调参也更清晰。5.5 压测要模拟慢下游而不是只测正常情况这次故障之后我在压测环节增加了“慢下游”场景用工具模拟下游服务响应变慢观察系统的表现。结果发现在慢下游场景下系统很快就会出现线程池打满的情况。这个测试暴露了之前没有发现的问题。压测不能只测正常路径还要测异常路径下游超时、下游返回错误、下游限流等。只有把这些异常场景都覆盖到才能提前发现系统的脆弱点。6. 后续加固让 Agent Platform 具备抗超时能力6.1 引入熔断器防止故障扩散修复之后我还在工具调用层引入了熔断器。当某个工具的失败率超过阈值时熔断器打开后续请求直接返回降级结果不再调用该工具。这样可以防止一个工具的问题扩散到整个平台。熔断器的配置大致如下CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowSize(100) .minimumNumberOfCalls(20) .build();这段配置的意思是在 100 次调用的滑动窗口内如果失败率超过 50%且调用次数不少于 20 次则打开熔断器30 秒后进入半开状态尝试恢复。6.2 增加超时监控与告警除了熔断器我还增加了针对超时的专项监控。具体包括每个工具调用的 P99 耗时超过阈值时告警。线程池活跃线程数超过 80% 时告警。任务队列积压数超过 500 时告警。超时异常数量每分钟超过 10 次时告警。这些告警通过监控系统统一管理确保在故障初期就能发现。6.3 异步化改造减少线程占用对于耗时较长的工具调用我逐步改成了异步方式。使用CompletableFuture或者响应式编程让线程在等待下游响应时可以释放出来处理其他任务。CompletableFutureResult future CompletableFuture.supplyAsync(() - { return toolInvoker.invoke(request); }, toolExecutor); future.orTimeout(10, TimeUnit.SECONDS) .exceptionally(ex - fallbackResult());异步化的好处是线程利用率更高但复杂度也会增加。需要处理好超时、异常和结果聚合的逻辑。对于 Agent Platform 这种任务编排系统异步化是值得投入的方向。6.4 建立超时故障的应急预案最后我还整理了一份超时故障的应急预案包括如何快速确认是超时故障看线程堆栈、看 P99、看队列积压。如何快速止血扩容线程池、清理队列、重启实例。如何定位根因看慢请求分布、看下游服务状态、看重试日志。如何修复和验证改配置、改代码、压测验证。这份预案在后续的几次小故障中派上了用场处理时间从原来的一个小时缩短到了十几分钟。7. 写在最后一些个人体会这次超时故障给我的最大教训是Agent Platform 这类系统的稳定性不取决于最快的那个环节而取决于最慢的那个环节。一个工具调用变慢就可能拖垮整个平台。所以超时控制、线程池隔离、熔断降级这些看似基础的防护手段实际上是最重要的。另外监控和告警的粒度要足够细。如果只监控整体延迟很难定位到具体是哪个工具、哪个环节出了问题。把监控拆到每个工具、每个线程池排查效率会高很多。还有一点压测一定要覆盖异常场景。正常场景下的压测只能验证容量异常场景下的压测才能验证韧性。我现在的习惯是每次上线新功能都会用工具模拟下游超时、下游报错、下游限流等情况观察系统的表现。最后分享一个小技巧在排查超时问题时可以先用jstack看线程堆栈快速定位到卡在哪个调用上然后用arthas的trace命令看具体方法的耗时分布最后结合监控面板的 P99 曲线确认问题的时间范围和影响面。这套组合拳下来大部分超时问题都能在半小时内定位到根因。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进