
最近监控群里连着弹出几条告警都在说同一个问题“MA 部分节点心跳超时”“MA 部分任务调度失败”“MA 部分数据源采集异常”。一开始大家都挺懵——明明是同一套代码同一个版本为什么只有一部分出问题后来排查下来发现这类“部分问题”恰恰是整个故障链里最有价值的信息入口。这篇内容就是一次完整的复盘。MA 是我们内部对 Management Agent 的缩写一个部署在各个业务节点上的代理服务负责数据采集、任务执行、状态上报。如果你维护过 Agent、中间件、监控采集类系统或者你是后端、运维、SRE这篇应该能给你一些可复用的排查思路。我尽量把从报警到定位、再到修复的每个环节都写清楚包括过程中踩的坑和后来沉淀的方法论。1. MA 在系统里到底扮演什么角色先把问题的边界划清楚1.1 模块定位与职责边界MA 这个名字听起来笼统但它在整个系统里干的活其实很具体每个业务节点上部署一个 MA 实例它向上对接调度控制端向下对接业务进程和各类数据源。日常主要做三件事采集节点状态和业务指标比如内存、队列长度、接口延迟接收控制端下发的指令在本地执行脚本或任务把执行结果、日志、指标统一上报到监控平台。这三件事对应三条独立的数据链路采集链路、调度链路、上报链路。我后来复盘时才发现正是因为这三条链路在代码里共用了不少基础组件比如 HttpClient、线程池、配置加载器才导致一次局部异常能快速扩散成“部分功能不可用”的复杂故障。换句话说MA 是一个典型的中转型组件它的稳定不仅取决于自身代码还取决于上游数据源、网络链路、下游接收端任何一环出问题最终都会表现为“MA 有问题”。1.2 “部分问题”最开始的报警形态故障不是一次性爆发的而是渐进式的。刚开始只有监控平台显示节点3的 MA 心跳间歇性超时过了一个多小时告警范围扩大到“部分任务调度失败”再往后数据转发成功率从 99.99% 掉到了 93% 左右。这里的关键点在于所有告警都用了“部分”这个词。部分节点心跳异常部分任务失败部分数据源采集超时。这种表述看起来不够精确但它其实已经帮我们缩小了范围——出问题的不是整个 MA而是 MA 的某个子集。我们当时犯的最初错误是试图直接去搜日志里的错误关键字而不是先认真回答一个问题到底是哪部分坏了哪部分没坏这两个问题想清楚之前所有日志搜索都相当于盲人摸象。2. 把“部分”拆开看异常的边界比异常本身更有价值2.1 按功能维度拆三个子功能只挂了一个我先按功能把 MA 的行为拆成三块心跳上报、数据转发、任务调度。从告警和监控面板看心跳上报始终是正常的节点3虽然偶尔超时但没有完全中断任务调度在故障后期出现大面积失败数据转发成功率明显下降。这个拆分非常有意义。心跳上报走的是长连接加轻量级协议数据转发走的是 HTTP 轮询加批量提交任务调度则是本地线程池加同步调用。三条链路只有心跳链路相对稳定说明问题大概率出在 HTTP 客户端或者线程资源上而不是 MA 进程本身挂掉。否则心跳也不可能保持正常。2.2 按数据源维度拆有些通道正常有些通道异常再把数据源维度拉出来看异常并不是均匀分布的。我们当时接入的数据源有几十个其中大部分是正常的异常集中在那几个走“HTTP 轮询拉取”的通道上走消息队列推送的通道完全不受影响。这里就出现了一个很明显的规律凡是依赖同步 HTTP 调用获取数据的源都出现了不同程度的超时凡是异步推送或者本地文件读取的源都安然无恙。也就是说异常的触发条件大概率跟“长连接”“同步等待”“连接复用”这几个关键字相关。到这里我已经基本把问题定位到 MA 的网络通信层而不是业务解析层。2.3 按节点维度拆同一套代码每个节点命运不一样节点维度的观察更有意思。三个节点跑了同一版本的 MA 代码但只有节点3持续异常节点1和节点2只是偶尔抖动。按常理如果问题是代码 bug应该三个节点一起挂如果问题是硬件资源节点3确实可能独立出问题。于是我们把节点3和其他节点的配置文件做了一次 diff立刻发现了第一个关键差异节点3的配置里下游地址指向的是一组“旧地址”而节点1、2已经使用新地址。这个差异单看并不致命但它是整个故障链的起点。2.4 从“部分”中提取公共交集把上面三个维度放到一起“部分”的公共特征就很清晰了功能维度异常集中在数据转发和任务调度数据源维度异常集中在 HTTP 同步轮询通道节点维度异常集中在配置未更新的节点。三个维度交叉之后我们可以构建一个初步假设MA 的网络请求层存在连接资源瓶颈并且部分节点因为配置滞后还在访问已经不再健康的下游实例。这个假设在后续的日志分析中得到了完整验证。排查维度正常的部分异常的部分差异点功能心跳上报数据转发、任务调度链路类型不同数据源消息队列、本地文件HTTP 轮询源同步等待节点节点1、节点2节点3配置版本不一致3. 逐层排查从日志到配置再到代码的完整链路3.1 第一层日志取证先分清“没收到”和“收不到”排查一开始我们没有着急看代码而是先把 MA 的运行日志按时间窗口拉下来。日志里的报错信息主要有三类connection pool exhausted、task schedule timeout after 5000ms、EOF。这三类信息放在一起看指向性已经非常明确——HTTP 连接池被耗尽导致大部分需要网络调用的请求都在等待连接进而引发调度线程阻塞。这里必须强调一个容易踩的误区看到connection pool exhausted后很多人第一反应是“连接池太小”于是直接调大连接数。但连接池耗尽只是结果不是原因。真正的问题是为什么连接会被占满于是我们继续翻日志发现大量指向下游地址的超时记录而且全部集中在同一个目标端口。这说明不是所有连接都在正常工作有一部分连接被“挂起”了。3.2 第二层配置差异新旧配置互相覆盖结合节点3的配置 diff 结果我们把问题进一步锁定到“使用旧地址的节点访问了一个已经不健康的实例”。这个实例在负载均衡层面已经被摘除但 MA 本地配置里还保留着它的地址所以请求仍然会发过去。问题在于即使这个实例已经不处理新请求它的网络栈依然可以接受 TCP 连接只是应用层不响应于是所有请求都进入了超时等待状态。最糟糕的是超时时间。MA 默认的 socket 超时是 30 秒也就是说一个坏连接最长会占用 30 秒才会被释放。如果连接池里有几个连接都被这样的坏请求占住整个连接池很快就会被“僵尸请求”填满正常请求反而拿不到连接。3.3 第三层连接池与超时坏请求吃掉了所有资源这一步我们需要精确计算一下故障的演化过程。MA 使用 Apache HttpClient默认配置下maxConnPerRoute为 20maxConnTotal为 100。假设有 5 个请求同时指向不健康的旧地址每个连接被占用 30 秒那么在这 30 秒窗口内这 5 个连接都无法被复用。由于连接池是按路由维度隔离的同一个下游地址的路由池里20 个连接很快被占满。后续新进来的请求全部排队等待释放连接默认的connectionRequestTimeout如果设置得比较长请求会在连接池层面无限堆积。更致命的是连接池被占满后那些本来指向健康地址的请求也会因为池子里的连接被坏请求占用而跟着超时。这就是为什么故障后期看到的现象是“全面变慢”而不是仅仅“部分地址失败”。3.4 第四层线程池与 GC潜伏在背后的资源竞争连接池耗尽只是第一层根因。继续往下看MA 的调度线程池也出现了问题。MA 内部用固定大小的线程池执行任务默认 16 个线程。因为任务是同步调用下游线程会阻塞在httpClient.execute()这一步等待连接所以连接池一旦耗尽线程池里的线程也会全部被占住。线程池被占满后新的任务无法获得线程执行只能丢进任务队列而任务队列本身也有限于是开始出现task schedule timeout。更隐蔽的是 GC 层面的影响大量线程阻塞后线程栈和等待对象在堆里堆积GC 明显变得频繁尤其是老年代回收时 STW 时间变长又进一步放大了所有请求的延迟。整个过程就是经典的“连接池耗尽 → 线程池阻塞 → GC 恶化”的资源竞争连环套一环扣一环最终把 MA 拖到半瘫痪状态。3.5 根因确认日志里的最后一个关键线索最后让我们确认根因的是一个细节在正常节点上即使配置了新地址也偶尔出现短时间的连接池水位升高但很快会恢复。只有节点3的连接池水位是一条直线持续处于满值状态。结合配置 diff 结果和超时日志我们可以把根因链完整串起来配置漂移部分节点未拉取最新配置仍在使用旧下游地址下游不健康旧地址指向的实例在应用层已处于不响应状态超时过长MA 对该实例的请求要等满 30 秒才会失败连接池过小默认 20 的连接数被僵尸请求占满全局污染连接池占满后所有经过该连接池的请求都会阻塞最终波及调度线程池。4. 修复方案既要止血也要防止复发4.1 立即止血重启、降级、摘流量修复的第一步不是改代码而是先恢复服务。我们做了三件事先把节点3的 MA 切换为最新配置并重启让它立刻丢弃旧地址如果遇到无法立即切换的节点就把本地配置里已经不健康的下游实例地址手动摘除避免继续占用连接同时把一部分非核心的数据采集任务暂时降级给系统让出资源。重启之后心跳超时告警很快消失连接池水位也慢慢降了下来。这里有一个小技巧重启后不要只看业务成功率要盯两眼连接池监控如果水位立刻回落到正常区间基本可以确认根因在连接管理层面。4.2 短期优化连接池、线程池和超时参数调整止血之后我们做了一轮参数优化。核心思路是“快失败代替慢等待”httpclient: maxConnPerRoute: 100 # 单路由连接数上调 maxConnTotal: 500 # 总连接数上调 connectTimeout: 2000 # 建立连接超时缩短到2秒 socketTimeout: 5000 # 等待响应超时缩短到5秒 connectionRequestTimeout: 3000 # 从池中获取连接的等待时间 evictIdleConnections: 30 # 每30秒清理空闲连接 evictExpiredConnections: true scheduler: corePoolSize: 32 # 调度线程数适度增加 maxPoolSize: 64 queueCapacity: 200 rejectedExecutionPolicy: CallerRunsPolicy # 拒绝时快速失败而非无限堆积参数调整的原理很简单把连接等待时间从 30 秒压到 3~5 秒即使某个下游实例不健康单个连接最多占用 5 秒就会被释放20 个连接足以支撑每秒几十次请求的突发。4.3 根治措施配置中心与双版本兼容短期优化只能治标真正要解决的是“配置漂移 单点故障影响全局”这两个结构性问题。配置漂移的根治方案是接入配置中心所有节点强制从配置中心拉取最新配置禁止手工修改本地配置文件。同时增加启动时校验逻辑Agent 启动时比对配置版本号如果不一致就自动拉取最新配置而不是继续沿用旧配置启动。连接隔离的根治方案是为每个下游服务分配独立的 HttpClient 或连接池避免一个下游实例不健康时把全局连接池全部占满。这样即使某个下游全部挂掉MA 也只会在日志里报这个下游的错其他链路不受影响。另外我们还为同步调用链路增加了熔断机制连续失败 3 次后对该下游地址熔断 30 秒熔断期间直接快速失败不再发起真实请求。这个机制非常有效相当于把“坏请求”关在门外避免它们长期占用连接资源。4.4 验证过程灰度观察与回归测试修复不能直接全部上先做验证。我们在测试环境里模拟了一个不健康的下游实例配合旧的连接池参数和超时参数成功复现了连接池耗尽的故障然后应用新参数确认连接池水位不再升高任务调度恢复稳定。生产环境采用灰度升级先升级节点3观察 15 分钟确认连接池水位和调度成功率都正常后再批量升级其余节点。升级完成后我们比对了修复前后的核心指标指标修复前修复后心跳上报成功率96.2%100%数据转发成功率93%99.99%连接池最大水位100% 持续占满峰值 40%调度任务失败率7.8%0.01%GC 平均 STW 时间780ms120ms指标恢复只是一方面更重要的是这次修复让团队意识到Agent 类组件的问题十有八九不是单个 bug而是资源管理不合理叠加外部依赖不稳定之后形成的故障链。单纯调参数能解决眼前问题但如果不做连接隔离和配置兜底下次换个故障形式还会再犯。5. 从这次故障里沉淀出的通用排障方法论5.1 给“部分”建立多维坐标轴这次故障最值钱的收获是让我意识到“部分”不是一个模糊的描述而是一个多维度的排查坐标轴。遇到任何“部分功能异常”“部分节点异常”“部分请求失败”先别急着搜日志花五分钟建立坐标轴功能维度、对象维度、时间维度把正常和异常的分布画出来。比如这次功能维度上下线、对象维度节点3、时间维度持续恶化。三个坐标交叉后公共交集就是排查入口。如果异常分布是随机的、没有交集的那大概率是资源型问题如果异常集中在某几个对象那大概率是配置或依赖问题如果异常集中在某段时间那大概率是外部波动或变更引起的问题。用坐标轴把问题画出来很多排查动作会变得非常精确。5.2 排障第一分钟该做的三个判断基于这次经验我总结了一个“第一分钟三问”的排障习惯第一个问题是全部坏还是部分坏全部坏通常是自身问题比如进程挂了、依赖全挂部分坏一定是某种差异导致的必须找到差异。第二个问题正常和异常之间的差异是什么这个差异可能是配置不同、版本不同、网络路径不同、资源水位不同。找到差异就等于找到了故障的一半原因。第三个问题现在“坏”的到底是什么到底是连接不够、线程不够、内存不够还是下游本来就慢用这个判断决定下一步是看连接池、看线程栈还是看下游系统。这三个问题听起来简单但在真实排障中很多人会跳过去直接改参数。结果往往是参数改了、指标暂时好转但根因未除几小时后换个形式再次爆发。5.3 常见“部分问题”诱因清单最后放一个我在实际项目中反复用到的清单遇到“部分问题”时可以对照排查诱因典型现象排查方向连接池耗尽请求大面积超时连接池水位满连接池参数、坏连接回收、下游健康状态超时设置过长单请求耗时正常并发一高就堆积连接超时、读超时、连接获取超时配置漂移部分节点行为与其余节点不一致配置文件 diff、配置中心版本一致下游不健康请求集中失败但 TCP 能通应用层健康检查、重试与熔断状态线程池阻塞新任务无法调度队列堆积线程池拒绝策略、阻塞调用链GC 停顿服务整体抖动无明显错误日志GC 频率、堆占用、STW 时间DNS 缓存部分实例解析到旧 IPDNS 缓存时间、连接池连接的地址是否陈旧负载均衡摘除延迟部分节点持续访问已摘除实例LB 健康检查周期、客户端本地缓存这次故障对我的直接改变是再遇到类似的“部分问题”我不再一上来就翻代码而是先把“部分”的边界画清楚再决定排查路径。很多时候问题的定义比问题的答案更重要。作为排障的人我们最不该做的就是在一堆日志里漫无目的地搜索然后靠直觉改参数。把“哪里正常、哪里异常、两者差异是什么”这三个问题回答清楚根因往往就自己浮出水面了。