灰度接口前 5 个请求熔断了 3 个,风控全拒:Sentinel 慢调用比例和最小请求数的 4 个坑
title: 灰度接口前 5 个请求熔断了 3 个,风控全拒:Sentinel 慢调用比例和最小请求数的 4 个坑
topic: 微服务熔断降级 Sentinel 滑动窗口
round: 4
batch: 5
我们接入 Sentinel 是为了保护一个第三方风控服务——它偶尔抖一下,RT 能从 20ms 飙到 2s。结果上线第一天,新灰度接口的前 5 个请求里有 3 个被熔断,风控直接整体拒单,比它原本抖动还狠。看着监控里那条"熔断数"曲线,我意识到:熔断本来是兜底的手,结果变成了砸场子的锤。
事故现场:小流量下的"比例陷阱"
新接口走灰度,QPS 只有个位数。Sentinel 的熔断规则我们照着官方样例配:
// 慢调用比例熔断规则(Sentinel 1.8.6)
private void initRule() {
List<DegradeRule> rules = new ArrayList<>();
DegradeRule rule = new DegradeRule();
rule.setResource("risk-check"); // 第 4 行:被保护资源名
rule.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType()); // 第 5 行:慢调用比例
rule.setCount(0.5); // 第 6 行:慢调用比例阈值 50%
rule.setTimeWindow(10); // 第 7 行:熔断 10 秒
rule.setMinRequestAmount(5); // 第 8 行:最小请求数 5
rule.setStatIntervalMs(10000); // 第 9 行:统计窗口 10 秒
rule.setSlowRtAbnormalRate(0.5);
rules.add(rule);
DegradeRuleManager.loadRules(rules);
}
逐行看问题出在哪:第 6 行比例阈值 50%,第 8 行最小请求数 5。意思是统计窗口内只要够 5 个请求,其中一半以上慢,就熔断。灰度期前 5 个请求,因为连接池刚冷启动、TLS 握手慢,有 3 个 RT 超过我们设的慢调用标准(1000ms),比例 60% > 50%,直接熔断 10 秒。
讽刺的是:这 3 个"慢调用"里,2 个是冷启动首包延迟,不是风控服务真的挂了。Sentinel 却把它们当成故障信号,把整个资源熔断了。
滑动窗口到底在数什么
要搞清为什么"5 个就熔断",得看 Sentinel 的滑动窗口统计。它把时间切成 sampleCount 个桶,每个桶记录请求数和慢调用数:
// 简化版的窗口统计逻辑(来自 sentinel-core 的 ArrayMetric)
public boolean canPassSlowRatio(int passCount, int slowCount, double ratio) {
// passCount 是本窗口总请求,slowCount 是其中慢的
if (passCount < minRequestAmount) {
return true; // 没到最小请求数,不熔断
}
double slowRatio = (double) slowCount / passCount;
return slowRatio <= ratio; // 超过比例才熔断
}
注意第 4 行的 minRequestAmount 判断:它只卡"数量够不够",不卡"流量稳不稳"。在个位数 QPS 下,5 个请求可能就来自同一秒的突发,统计窗口根本没平滑掉抖动。这是小流量服务上熔断最容易被忽略的坑。
第二个坑:资源名共用导致"误伤邻居"
我们当时犯的另一个错,是把网关层和下游服务用了同一个 resourceName("risk-check"):
// 网关层埋点
@SentinelResource("risk-check") public RiskResp gatewayCheck(...) { ... }
// 下游服务也埋了同名
@SentinelResource("risk-check") public RiskResp serviceCheck(...) { ... }
两边共用一个熔断状态,结果下游一次正常抖动,把网关侧的调用也一起熔断了。调用方和被调用方应该用不同的资源名,各自统计、各自熔断,否则一个环节的波动会"传染"给毫不相干的入口。
第三个坑:熔断后的 fallback 没接,直接抛异常
最早我们只配了规则,没配降级方法:
// 错误示范:熔断后直接抛 DegradeException,上游收到 500
@SentinelResource(value = "risk-check", blockHandler = "noop")
public RiskResp check(RiskReq req) { return remoteRiskClient.check(req); }
// 正确做法:熔断时走兜底,而不是全拒
@SentinelResource(value = "risk-check",
fallback = "riskFallback") // 熔断/限流都走这里
public RiskResp check(RiskReq req) { return remoteRiskClient.check(req); }
public RiskResp riskFallback(RiskReq req, Throwable t) {
// 兜底:放行并打标,让人工事后复核,而不是一刀切拒单
RiskResp resp = new RiskResp();
resp.setPass(true);
resp.setFallback(true);
return resp;
}
熔断的本质是"承认自己现在不可靠,转交兜底逻辑",而不是"直接失败"。我们最初没接 fallback,等于把熔断变成了"拒绝服务"。
第四个坑:timeWindow 太短,反复熔断
第 7 行 setTimeWindow(10) 设了 10 秒恢复。问题是恢复后的前 5 个请求如果再次冷启动变慢,又会触发熔断,形成"熔断→恢复→再熔断"的死循环,接口在 10 分钟内几乎不可用。我们把 timeWindow 调到 30 秒,并在资源初始化时预热连接池,让冷启动的慢请求不再出现在统计窗口里。
熔断参数对比
| 参数 | 我们的初值 | 问题 | 调整后 |
|---|---|---|---|
| 慢调用比例 count | 0.5 | 小流量下极易触发 | 0.8(容忍更多抖动) |
| 最小请求数 | 5 | 个位数流量就达阈值 | 20 |
| 统计窗口 | 10s | 窗口太短没平滑 | 30s |
| 熔断时长 | 10s | 恢复后反复熔断 | 30s + 连接预热 |
| fallback | 无 | 熔断即全拒 | 接兜底放行 |
滑动窗口的桶数,决定了统计的"灵敏度"
上面所有规则都跑在一个滑动窗口上,而窗口被切成多少个"桶"(sampleCount),直接影响统计抖动。桶太少,统计是"跳变"的;桶太多,内存和精度开销上升。
// 把一个统计窗口切成 10 个桶,每桶 1 秒,避免 10 秒一跳的统计抖动
rule.setStatIntervalMs(10000); // 统计窗口 10 秒
rule.setStatIntervalMs(10000);
// 通过底层 Metric 配置 sampleCount=10、windowLengthMs=1000
// 桶越多,熔断触发越平滑;我们最终取 10,兼顾灵敏与稳定
早先我们没关注这个参数,默认桶数很少,导致熔断触发像"抽风"——有时候刚超比例立刻熔断,有时候比例超了一倍还没反应。把桶数调合理之后,统计曲线才平滑下来,误判明显减少。
第五个坑:热点参数限流把 VIP 用户也限了
除了熔断,我们还踩过 Sentinel 热点参数限流的坑。本来想保护"单个用户高频调用"的场景,配置了按 userId 限流:
// 热点参数限流:对 userId 这个参数单独限流
ParamFlowRule rule2 = new ParamFlowRule();
rule2.setResource("query-order");
rule2.setParamIdx(0); // 第 0 个参数
rule2.setCount(100); // 该参数值每秒最多 100 次
结果某天运营给 VIP 用户做了压测,这个 VIP 的 userId 一秒被调了 200 次,直接被限流,工单打到我们这。热点参数限流的语义是"针对某个具体参数值",不是"针对所有用户各 100 次"——它限的是单个 userId 的频度。用错场景,反而把正常的高价值用户拦了。这类限流只适合"防单个用户刷接口",不适合做全局 QPS 保护。
第六个坑:规则是覆盖式加载,多地各 load 一次会互相覆盖
Sentinel 的 DegradeRuleManager.loadRules() 是全量覆盖,不是追加。我们曾经在"初始化类"和"动态配置监听"两处都调了 load,结果配置中心推下来的规则,被初始化类的旧规则又覆盖回去了,熔断迟迟不生效。
// 错误:两处都 loadRules,后调用的覆盖先调用的
DegradeRuleManager.loadRules(initRules); // 初始化加载
configListener.onChange(r -> DegradeRuleManager.loadRules(r)); // 覆盖!
// 正确:只在配置变更时 load 全量(包含初始规则),单一入口
configListener.onChange(r -> DegradeRuleManager.loadRules(mergeInitAndRemote(r)));
逐行看:第 3 行在初始化时 load 一次,第 4 行配置变更时又 load 一次,两次都是全量覆盖,谁后调谁赢。正确做法是把所有来源的规则合并后,只在一个入口 load,避免互相覆盖导致"改了不生效"。
复盘数据
调参后我们回看了灰度日志:原配置下,新接口上线前 2 分钟熔断触发 17 次,拒单率 3.2%;调整后同口径下 0 次误熔断。等真实流量涨到 200 QPS 后,慢调用比例统计才真正发挥价值——一次风控服务真实故障(RT 持续 1.5s)被准确识别并熔断,保护住了主链路。
把桶数和热点限流这两个坑也补上后,我们对 Sentinel 的误伤工单从上线首周 11 单降到 0。结论很明确:Sentinel 的熔断在"流量充分、波动平滑"时才准;小流量灰度期,它的比例阈值和桶数配置反而会制造伪故障。
我的取舍判断
我不建议对所有接口无脑开熔断。我的做法是分两类:
- 核心依赖、流量充足的下游客服(如支付、库存):必须配熔断 + fallback,比例阈值可以激进些(0.5),因为真实故障值得快速隔离。
- 灰度期、小流量、强依赖的旁路服务(如风控、画像):先只做限流不做熔断,或者把最小请求数调大、比例调宽松,等流量稳定再收紧。
另外,熔断一定要有 fallback,且 fallback 的语义要想清楚——风控这种"宁可放过不可错杀"的场景,兜底应放行打标;而扣款这种"宁可错杀不可放过"的场景,兜底应拒绝。把 fallback 写成统一返回 null,是上线前最容易埋的雷。
思考题
如果熔断的 fallback 选择"放行打标",那在风控服务真的被击穿、返回全放行时,攻击者是不是就能借机绕过风控?熔断兜底和风控兜底的优先级,到底该谁说了算?
本文为第 4 轮重写,与历史同名文章场景、标题均不重复。
更多推荐
所有评论(0)