凌晨三点,大促压测现场,支付渠道的调用开始大面积超时。奇怪的是监控大屏上所有服务都还活着,TCP连接数也正常,机房专线拨测显示“通了”。可业务请求就是大量堆积,直到四十分钟后我们才定位到是跨机房的一条专线在剧烈抖动——光模块光衰异常,转发节点CPU软中断飙升。那一刻我突然意识到,我们平时说的“监控正常”,其实只覆盖了连通性这一层,线路质量、稳定性、可调度性这些更细的问题,完全没有被观测到。
这个项目Agent-Reach就是在那个凌晨之后启动的。它解决的核心问题是一句话:服务之间到底能不能“稳定触达”对方。Agent负责感知线路的真实状态,Reach负责把感知结果转化为调度决策。它不是一个业务功能,而是一层基础设施组件,适合所有依赖多条网络链路做高可用调度的团队,尤其是异地多活、跨机房调用、多家云厂商混合部署的场景。这篇文章我会把整个项目的设计逻辑、关键实现、还有落地过程中踩过的坑一并讲清楚,希望能帮正在做类似事情的人少走几步弯路。
1. 为什么做Agent-Reach:一次压测事故留下的监控盲区
1.1 事故经过:基础设施看着健康,业务却在报错
先复盘那天的现象。凌晨的压测流量其实不算大到离谱,但支付回调的P99延迟从平时的80ms直接飙到2秒以上,超时率往6%冲。第一反应是查业务服务,CPU、内存、GC、线程池全部看起来正常。接着查下游依赖,数据库也扛得住。最后靠链路追踪系统的火焰图才找到异常入口:所有跨机房的调用都在排队,而排队的源头是Socket连接上迟迟等不到响应。
团队里做网络的同学去拨测那条专线,ICMP ping的丢包率是0%,但RTT的抖动幅度很大,平均值200ms左右,峰值能到800ms。TCP端口也能正常连上,因为协议栈不会因为链路抖动就拒绝握手。正是这种“能通但很慢”的状态,把常规监控全部骗了过去。
再往下查才发现,光模块的收光功率已经低于阈值,转发节点的软中断从10%一路上到70%。可是这类硬件层指标,业务监控平台不会采集,网络监控平台只管通断和丢包率,不会细致到“延迟分布”和“质量评分”这个粒度。
1.2 监控盲区的本质:中间缺少一层“线路质量”的观测
那天之后我把整个监控体系盘了一遍,发现一个结构性问题:基础拨测工具解决的是“通不通”,业务监控解决的是“服务行不行”,唯独“链路好不好用、能不能继续承载流量”这一层,没有专门的系统来管。
举几个日常场景你们就有画面了:
- 某条专线偶尔丢两个包,TCP重传能兜住,业务无感。但这种“无感”持续积累,会让连接池里的连接变得越来越老化。
- 跨云厂商的链路经常出现不对称路由,从A到B很顺畅,从B到A延迟翻倍,普通拨测只测单向,看不见回程问题。
- 一家供应商的多条线路之间没有统一调度,哪条先超时就去哪条重试,结果把故障线路又打满了。
所以Agent-Reach的第一个设计目标不是“切换到备用线路”,而是先建立一套线路质量的量化观测体系。探针必须持续采集RTT、丢包率、超时率、响应码分布这些原始信号,调度中心才能在此基础上做决策。没有这个观测层,所谓高可用调度全是拍脑袋。
2. Agent-Reach的整体设计:探针、调度中心与SDK三件套
2.1 三个角色各自的边界
Agent-Reach的整体架构被我拆成了三个角色,职责很明确:
Agent探针:部署在每个机房边缘节点、转发节点或业务机器上。它负责真实链路的探测,比如TCP拨测、HTTP探活、DNS解析、TLS握手耗时。探针之间也互相探测,这样能拿到“机房A到机房B”的全链路数据,而不只是单机视角。心跳报文每隔10秒发一次,携带的是最近一个周期内的探测统计结果,不是原始采样点。
Reach调度中心:集中接收所有探针上报的数据,维护一张“线路健康评分表”。每个线路有独立的健康等级、熔断状态、动态权重。调度中心本身不带状态存储,用内存缓存就够了,顶多把历史数据落到TSDB供回顾。
SDK:业务进程通过SDK拿到调度中心下发的“线路优先级列表”和“可用集合”。SDK不直接访问调度中心的所有数据,只关心自己依赖的那几条线路。它的核心逻辑是加权随机选择,同时内部维护一个小型的本地熔断器,防止调度中心短暂失联时业务无脑重试。
下面这段心跳数据是Agent上报的一个例子:
{ "agent_id": "agent-az1-edge-03", "ts": 1699999999, "target_zone": "az2", "target_ip": "10.20.30.40", "probe_type": "tcp_connect", "sample_count": 50, "avg_rtt_ms": 158.2, "p95_rtt_ms": 270.3, "loss_rate": 0.012, "timeout_rate": 0.04, "status": "degraded" }2.2 为什么探针必须独立部署,不能塞进业务代码里
最开始有人提方案,说在业务进程里加个线程定期ping一下不就行了。这个方案在单体阶段确实能用,但到了跨机房、多租户、容器化的阶段就会出问题。
业务线程被阻塞的时候,探针线程往往也跑不动,这时候上报的心跳全是“假死”状态;业务进程因为FullGC停住的时候,探针也会误报“线路故障”,触发不合理的切换。反过来也一样,如果业务进程假活但链路已经断了,探针却因为心跳和业务共用一条连接而依然显示正常,又会导致误判为健康。
独立部署的探针保证了探测行为和业务行为完全解耦。它是“趴在地上听铁轨振动的人”,不是“坐在火车上感受车厢晃动的人”,两者感知到的东西完全不一样。探针独立部署还有一个额外好处:可以在不被业务发版打扰的情况下快速升级探测协议、增加新探针类型。
2.3 一条请求最终是怎么被动调度的
假设业务集群有三个可用区域,分别管三条不同的出口线路。业务SDK启动时会从调度中心拉取一份线路列表,大概是这样的:
| 线路ID | 目标区域 | 最近健康等级 | 权重 |
|---|---|---|---|
| line-az1 | zone-a | 健康 | 100 |
| line-az2 | zone-b | 亚健康 | 55 |
| line-az3 | zone-c | 故障 | 0 |
每次发起调用时,SDK不是盲目轮询,而是按权重随机选一条线路。如果选中的线路连续失败两次,SDK本地熔断器立刻打开,把该线路摘出选择池,接下来只走剩下的健康线路。同时SDK把这个失败事件异步上报给调度中心,调度中心更新全局评分。
这样从用户请求发出到真正触达对端服务,中间多了两层的保护:全局的动态权重决定了“大概率往哪走”,本地的熔断器兜底了“万一全局数据来不及刷新怎么办”。
3. 线路健康判定:从RTT样本到三态模型
3.1 探针到底应该采集哪些指标
做这个模块的时候我最大的体会是:指标不是越多越好,采集成本和真实价值之间要平衡。Agent-Reach最终保留的核心指标只有五个:
- 连通性:TCP拨测能否按预期完成握手,这是最底层的信号。
- RTT平均值与P95值:平均值反映整体水平,P95反映尾部体验,两者缺一不可。
- 丢包率:按周期统计丢失的探测包比例,这个对网络抖动特别敏感。
- 超时率:超过阈值的探测请求占比。
- 响应码分布:对HTTP探活来说,重点关注5xx比例和连接重置比例。
探测频率我默认设成5秒一次TCP拨测,30秒做一次HTTP探活,1分钟跑一次DNS解析。这个频率对大多数业务场景足够了。如果某个线路已经被标记为亚健康,会临时把探测频率提高到2秒,确保尽快捕捉到恢复信号。
3.2 用EWMA平滑RTT:别被一次抖动带偏
RTT原始样本的波动非常大,一次网络重传就能让瞬时值翻好几倍。直接用瞬时值触发告警,故障电话会响个不停。我们采用的是指数加权移动平均(EWMA),核心公式是:
avg = alpha * new_sample + (1 - alpha) * avg alpha 默认取 0.3这个公式的好处是不用存全量样本,一个变量就能持续更新。alpha越小,平滑效果越强,但对拐点的感知也越迟钝。如果你希望系统快速响应线路恶化,alpha可以调到0.5;如果追求稳定、尽量避免误判,0.2到0.3之间比较合适。
在EWMA的基础上,我还会额外维护一个近200个样本的滑动窗口,用来计算P95和丢包率。EWMA负责趋势判断,滑动窗口负责尾部统计,两者组合起来比只看平均值要立体得多。
3.3 三态判定:健康、亚健康、故障
Agent-Reach没有采用黑白分明的二元判定,而是把线路分成三档,这样调度策略才有更多操作空间。具体判定规则如下:
| 状态 | 丢包率 | P95 RTT | 超时率 | 调度行为 |
|---|---|---|---|---|
| 健康 | < 1% | < 200ms | < 1% | 正常承载流量 |
| 亚健康 | 1% ~ 5% | 200ms ~ 500ms | 1% ~ 5% | 降低权重,逐步切走部分流量 |
| 故障 | > 5% | > 500ms | > 5% | 熔断,完全摘除 |
这个阈值的配置必须能按业务调整。比如内部RPC调用,P95超过100ms就觉得卡了;但离线同步任务可能完全不在乎500ms的延迟,只看丢包率。所以我把这些阈值做成了配置项,而不是硬编码。
3.4 判断的核心逻辑:连续N次才算数
真正让这套系统稳住的是“连续故障才动作”的机制。单次故障可能是探测包被网络设备优先丢弃,不代表整条线路不可用。但连续三次探测周期都处于故障区间,那基本上可以确认线路出问题了。
伪代码逻辑类似这样:
func JudgeLine(line *LineStatus, sample *ProbeSample) { line.Push(sample) currentState := line.DecideState() // 基于EWMA和滑动窗口 if currentState == StateDegraded { line.degradedCount++ if line.degradedCount >= 2 { line.State = StateDegraded line.degradedCount = 0 } } else if currentState == StateDown { line.failedCount++ if line.failedCount >= 3 { line.State = StateDown line.failedCount = 0 line.CooldownUntil = time.Now().Add(60 * time.Second) } } else { line.failedCount = 0 line.degradedCount = 0 } }这个设计在工程上非常关键。如果你在第一次故障信号出现的时候就立刻切换流量,遇到网络偶发抖动就会来回切换,每次切换又会导致连接重建,反而造成更多超时。让子弹飞一会儿,本质上是为稳定性买单。
4. 动态权重调度:让流量自己躲开故障线路
4.1 健康评分到权重的映射
有了三态模型,接下来就是怎么把状态变成流量分配比例。Agent-Reach里权重计算公式是:
weight = base_weight * exp(-lambda * fault_counter)其中base_weight是线路正常时的基准权重,fault_counter是进入亚健康以来累计的异常探测次数,lambda是衰减系数,默认取0.15。这个指数衰减的好处是:权重下降刚开始很轻微,后续越来越陡峭,能快速把流量从持续恶化的线路上挪走。
举个例子,某条线路基准权重100,连续有10次探测进入亚健康区间,此时:
weight = 100 * exp(-0.15 * 10) = 100 * 0.223 = 22.3权重掉到22左右,调度中心会把这个结果下发给SDK,requests自然大量转移到别的线路。如果这条线路之后恢复正常,fault_counter会按时间衰减,权重逐步回弹。
4.2 A/B分组与主备模式:两种实用的调度策略
动态权重适用于多个线路能力相似的场景。但真实世界里线路容量不总是一样的,有的机房带宽大、链路贵,有的便宜但稳定性差。这时候单纯按健康度加权不一定合理。
我额外设计了两种模式:
模式一:主备模式。主线路健康时,约95%流量走主线,备线承载小流量用于保活,同时持续探测。主线路一旦降级为亚健康,权重立刻下调到与备线同档,流量对半劈。主线路故障时,备线直接承接全部流量。
模式二:A/B分组模式。把线路分成两组,一组叫流量组,一组叫兜底组。流量组正常时全量承担业务;兜底组日常只承载1%的哨兵流量,用于探测。当流量组整体降级,SDK本地熔断器感知后,会自动把一部分新请求打到兜底组。
这两种模式在Agent-Reach里不冲突,甚至可以叠加。线路是主是备、是流量组还是兜底组,都可以在调度中心的规则引擎里配置。
4.3 一次真实摘除过程:三分钟内完成
我用一个实际案例来说明这套调度是怎么跑起来的。某天下午运营反馈某个区域用户打开页面偶发白屏。查看Agent-Reach控制台,发现该区域出口线路的丢包率从0.4%涨到3.2%,P95 RTT从110ms涨到350ms。
第一个探测周期,系统把线路标记为亚健康,但权重只从100降到87。第二个周期,丢包率继续涨到4.1%,权重降到55。这时候SDK拿到的可用列表里,该线路已经排到第二位。第三个周期,线路超时率达到6%,触发熔断,权重清零。从首次异常到完全摘除,用了不到三分钟,而业务白屏的投诉在摘除后大约两分钟就停止了。
整个过程没有人工介入。如果按传统方式,等监控告警推给人、人再看链路、找人切流量,至少需要十到二十分钟——自动化不是为了省人力,是为了把恢复时间从分钟级压缩到秒级。
5. 断线恢复与半开探测:比熔断更考验耐心的一环
5.1 为什么恢复比熔断更棘手
熔断容易,恢复难。一条线路被摘除后,如果运维人员修好了链路,系统什么时候把它加回来?加早了容易再次雪崩,加晚了浪费容量。我们要设计一个严谨的状态机。
Agent-Reach采用经典的三状态熔断模型:关闭(Close)、打开(Open)、半开(Half-Open)。关闭状态是正常流量承载状态;打开状态是熔断状态,完全不分配流量;半开状态是试探性恢复状态,只允许有限比例的流量通过。
从打开到半开的条件是一个冷却期,默认60秒。冷却期之后,调度中心会把这条线路恢复为半开状态,允许5%的流量进入。如果这5%的流量表现稳定,下一个周期再提升到20%,之后逐步爬升回满权重。
5.2 半开探测的窗口设计:用小流量换取安全
半开状态最大的风险来自“流量过小而误判”。5%的流量如果全部落在低峰期,几十个请求里恰好都成功,不能说明线路恢复了。所以半开探测窗口需要同时满足两个条件:至少持续30秒,且至少累积200个成功请求,两者都满足才能进入下一步恢复。
反过来,半开窗口内只要出现三个失败请求,立即重新熔断,并且冷却期翻倍。这是关键细节:一次半开失败说明线路尚未稳定,直接翻倍冷却能避免频繁试探把故障线路压死。
| 参数 | 默认值 | 说明 |
|---|---|---|
| 熔断冷却期 | 60s | 故障后基数等待时间 |
| 半开窗口时长 | 30s | 小流量观察的最短时间 |
| 半开流量比例 | 5% | 观察期允许的流量比例 |
| 半开最小成功数 | 200 | 观察期内必须达到的请求数 |
| 半开失败触发数 | 3 | 触发重新熔断的失败次数 |
| 冷却期翻倍 | 2x | 反复失败的惩罚因子 |
5.3 为什么恢复时要逐步放大而不是直接全量
我见过一些团队把线路修好后就立刻全量切回,结果TCP连接池、服务端线程池、下游数据库连接全部瞬间被冲垮。原因很简单:线路断开的这段时间里,业务流量已经被其他路径接管,这些路径上的连接都是热的;而刚恢复的线路,连接都是冷的,服务端缓存可能也是旧的,突然涌入大量流量必然出问题。
逐步放大恢复的策略本质上是“把冷连接预热的过程”纳入调度逻辑。30秒观察、翻倍流量、再次观察,这个过程让连接池有时间建立、让拥塞控制慢慢加速、让服务端缓存逐步更新。说实话这个过程中业务侧的感知几乎为零,但系统整体的稳定性高了一个量级。
配置文件的示例长这样,方便你们直接参考:
circuit_breaker: cooldown_seconds: 60 half_open: traffic_ratio: 0.05 min_window_seconds: 30 min_success_requests: 200 max_success_requests: 1000 fail_threshold: 3 cooldown_multiplier: 2.0 recovery: step_ratios: [0.05, 0.1, 0.2, 0.4, 0.6, 1.0] step_interval_seconds: 306. 落地实战中的坑与参数调优
6.1 坑一:探针探测链路与业务链路不一致,导致误判
第一个版本上线后,我们发现了一个很隐蔽的问题:探针部署在机房边缘的独立机器上,它探测到的路由可能是走专线A;但业务请求实际走的却是同一机房的另一台转发设备,路由是专线B。探针说链路健康,业务却一直超时。
根本原因在于探针的探测流量和业务流量的网络路径在某个节点上发生了分叉。解决方式是在每个边缘节点部署至少两个探针实例,每个实例绑定不同的源IP或网卡,让探测路径尽可能覆盖业务路径的多样性。同时在调度中心加了一层数据核对:如果某条线路的探针健康状态连续三次与SDK上报的实际失败率冲突,系统会优先采信业务侧数据,并降低该探针的权重。
6.2 坑二:ICMP探测被云厂商限制,换用HTTP探活
一开始探针默认用ping来测RTT和丢包率。在自有机房可行,但混合云场景下,部分云厂商的安全组或底层网络会限制ICMP,导致探针显示的丢包率虚高,线路被反复误熔断。
后来我们把默认探活协议调成了TCP连接和HTTP GET的组合。TCP握手时间能近似反映网络延迟,HTTP状态码能反映对端服务的健康程度。ICMP保留为辅助探测,只有TCP和HTTP都失败时才把故障等级往上提。这个改动上线后,误熔断率下降了八成以上。
6.3 坑三:单节点探测容易误伤全局,引入投票机制
某个边缘节点的本地网络抖动,比如网卡降速、交换机缓存打满,会导致这个节点上所有的探针数据都异常。如果不加过滤,调度中心可能把所有线路都标记为故障,造成全局雪崩。
我加了一个简单的投票机制:同一条线路的所有探针中,只有当超过半数的探针汇报异常时才允许变更线路状态。单点异常会导致该探针自身的置信度下降,但不会影响全局调度。这招说穿了很朴素,但能挡住大量边缘情况。
6.4 参数调优的两类场景:快速恢复优先 vs 高稳定优先
调优方向取决于你对误判的容忍度。我整理了一张对比表,直接照着设就行:
| 参数 | 快速恢复场景 | 高稳定场景 |
|---|---|---|
| EWMA alpha | 0.5 | 0.2 |
| 亚健康触发次数 | 1 | 2 |
| 故障触发次数 | 2 | 4 |
| 冷却期 | 30s | 120s |
| 半开流量比例 | 10% | 3% |
| 半开最小成功数 | 50 | 500 |
| 权重恢复步长 | 0.25 | 0.1 |
快速恢复场景适合流量高峰前的系统,宁可短暂误切换也不要让故障线路带病运行。高稳定场景适合对成本敏感、线路资源有限的集群,尽量减少无效切换和流量颠簸。没有一套参数通吃所有场景,关键是你要清楚自己的业务偏重哪个方向。
7. 后续怎么演进:从单组件到可观测性平台
Agent-Reach做到现在,我最大的体会是:基础设施组件最怕一上来就奔着自动化去,先把可观测性做扎实,自动化反而水到渠成。如果你没有足够的线路质量数据沉淀,任何调度策略都是没有根据的。Agent-Reach的第一步其实是让每条线路的状态“肉眼可见”,然后才是阈值告警,最后才是自动调度和自愈。
后续有几个方向我准备继续探索。一是和K8s的EndpointSlice做联动,让服务网格自动感知Agent-Reach的线路健康度,按区域优先路由;二是把多Region容灾编排纳入调度中心,让故障切换从“打平流量”升级为“按业务优先级跨区域重排”;三是把每次调度的决策日志都回流到TSDB,基于MTTR数据反推参数优化。
现在每次大促前,我都会把Agent-Reach的线路健康报告拉出来看一遍,提前把亚健康线路降权、把冗余线路调到预备状态。它不再只是一个宕机时才被想起的系统,而是日常容量规划、演练预案的一部分。如果你也在处理多链路调度的稳定性问题,我的建议是从最简单的探针加拨测开始,先让链路质量变得可见,再逐步加入权重调度和熔断恢复。这条路不需要一开始就建得很重,但每一步都要留着扩展的口子。