难的不是状态机,是诊断标准
上篇把三态骨架搭了起来:CLOSED 睁眼统计、OPEN 快速失败、HALF-OPEN 放行试探。骨架不难,几十行代码的事,真正决定熔断器好坏的是另一个问题——病了怎么判定。判松了是漏诊,下游已经躺平你还在排队等它;判紧了是误诊,一次网络抖动就把半条业务线跳闸。诊断靠两根体温计:错误率与慢调用比例,再配两道防误判的护栏:最小请求数与滑动统计窗口。
第一根体温计:错误率,但什么算「错」
错误率 = 失败请求数 ÷ 总请求数,公式一眼就会,陷阱全藏在分子的定义里:不是所有异常都算病。
系统异常才算病:调用超时、连接被拒、连接池耗尽、HTTP 5xx、gRPC UNAVAILABLE——这些说明下游在系统层面出了问题,是熔断器该管的。业务异常不算病:库存不足、余额不够、参数校验失败——下游「清醒地拒绝了你」,服务本身健康得很,熔断器管不着也不该管。
把业务异常混进分子会怎样?大促零点下单,库存不足的业务失败天然冲高,错误率曲线跟着起飞,全公司的熔断器集体跳闸——故障没来,熔断器先把自己人打瘫了。这是熔断误判的头号事故来源。
public boolean isSystemFailure(Throwable e) {
return e instanceof TimeoutException // 调用超时
|| e instanceof ConnectException // 建连被拒
|| e instanceof PoolExhaustedException // 连接池耗尽
|| e instanceof HttpServerErrorException; // HTTP 5xx
// 库存不足、余额不够等业务异常:不算,走业务分支
}第二根体温计:慢调用
只盯错误率会漏掉最重要的前兆——慢。下游 RT 从 50ms 涨到 800ms,一个错误都没抛,但每个请求占线程的时间翻了 16 倍,同样的线程池吞吐只剩 1/16,第 1 篇的雪崩链已经开始转了。等超时异常大量出现再熔断,上游线程已经被拖死一轮。
所以第二根体温计是慢调用比例:RT 超过阈值的调用占总调用数的比例。它配两个参数:慢调用 RT 阈值(多久算慢)与慢调用比例阈值(多少算病)。RT 阈值别拍脑袋,取平时监控 P99 的 1.5~2 倍——P99 本来就是常态尖峰,持续越过它的变慢才值得动手。
小样本陷阱:最小请求数
凌晨四点,统计窗口里进来 2 个请求,失败 1 个——错误率 50%,按阈值早该跳闸。可这两个请求八成来自同一个拨测任务,样本毫无意义。比率只有在样本够多时才是信号,样本少时是噪声。
护栏叫最小请求数(minRequestAmount):窗口内请求总数不足这个数,不做判定,维持 CLOSED。低峰期宁可漏判几秒,绝不能被一两个请求的巧合触发跳闸——低峰跳闸之后的恢复探测,还会反过来打扰刚缓过来的下游。
滑动统计窗口:指标要看「最近」
错误率在多长的时间范围里算?用「上线至今的累计值」毫无意义——上周那场故障的错误数,不该影响今天的判定。指标必须滚动:只看最近一个窗口,旧数据不断滚出去。
实现直接复用限流篇的环形格子滑动窗口——限流统计流量,熔断统计成败与耗时,同一副骨架两副用法。窗口切 10 个桶、每桶 1 秒:请求落进当前桶,10 秒前的桶滚出窗口。
public class SlideStats {
static final int BUCKETS = 10; // 10 个桶
static final long BUCKET_MS = 1000L; // 每桶 1 秒,整个窗口 10 秒
final long[] bucketSec = new long[BUCKETS]; // 每桶归属的秒
final AtomicLongArray total = new AtomicLongArray(BUCKETS);
final AtomicLongArray fail = new AtomicLongArray(BUCKETS);
final AtomicLongArray slow = new AtomicLongArray(BUCKETS);
public synchronized void record(long rt, boolean ok, long slowRt) {
long sec = System.currentTimeMillis() / BUCKET_MS;
int idx = (int) (sec % BUCKETS);
if (bucketSec[idx] != sec) { // 桶里是 10 秒前的旧数据,清零复用
bucketSec[idx] = sec;
total.set(idx, 0); fail.set(idx, 0); slow.set(idx, 0);
}
total.incrementAndGet(idx);
if (!ok) fail.incrementAndGet(idx);
if (rt > slowRt) slow.incrementAndGet(idx);
}
public synchronized boolean tripped(double errLimit, double slowLimit, int minReq) {
long t = 0, f = 0, s = 0;
for (int i = 0; i < BUCKETS; i++) { t += total.get(i); f += fail.get(i); s += slow.get(i); }
if (t < minReq) return false; // 样本不足,宁可不判
return f * 1.0 / t >= errLimit // 错误率超标
|| s * 1.0 / t >= slowLimit; // 或慢调用比例超标
}
}窗口多长合适?太短(一两秒)指标抖得厉害,瞬时尖峰就能骗到它;太长(三五分钟)故障恢复半天了指标还没回神,跳闸迟钝、恢复更迟钝。经验值 10 秒到 1 分钟,配合 1 秒一桶让统计平滑滚动。
半开探测与一张参数表
最后是半开态的细节:休眠到期后放几个探测请求?放 1 个最脆——探测请求恰好撞上一次网络抖动,立刻打回 OPEN,恢复期被无限拉长;放 3~5 个,连续成功才闭合,误伤率低得多。Sentinel 与 Resilience4j 都留了这个参数(permitted-number-of-calls-in-half-open-state)。
把本篇的判定参数汇总成表,调参时对着填:
| 参数 | 含义 | 经验起点 |
|---|---|---|
| 统计窗口 | 错误率、慢调用比例的统计时间范围 | 10s ~ 60s |
| 分桶粒度 | 窗口切多细,越细统计越平滑 | 1 秒一桶 |
| 错误率阈值 | 系统异常占比超过即跳闸 | 30% ~ 50% |
| 慢调用 RT 阈值 | 超过此 RT 记为慢调用 | 平时 P99 的 1.5~2 倍 |
| 慢调用比例阈值 | 慢调用占比超过即跳闸 | 50% 左右 |
| 最小请求数 | 窗口内样本不足则不判定 | 20 ~ 50 |
| 熔断休眠时长 | OPEN 持续多久进半开 | 5s ~ 30s |
| 半开探测数 | 半开态放行的试探请求数 | 3 ~ 5 个 |
还有一个容易忽略的事实:熔断判定天然是实例级的——每个节点统计自己看到的成败,不需要全局一致,所以手写版只管单实例并不算缺陷,生产框架也是这么干的。参数齐了,但注解、控制台、动态规则这些生产配套还缺着——下篇把 Sentinel 装进 503 咖啡馆,熔断从手艺变成配置。
评论 (0)