一次线上 熔断 故障的复盘记录
先说结论
这次线上 熔断 故障,表面看是“下游价格服务慢”,实际看更像是“我们把保护开关当保险丝用了,结果保险丝自己把自己熔断器焊死了”。
真正出问题的是一个遗留配置:
hystrix:
command:
productPriceQuery:
circuitBreaker:
forceOpen: true
这个 forceOpen: true 是上周压测前为了让所有请求直接走 fallback 配的,本来想测“价格服务完全不可用时前端怎么展示”。结果压测结束,配置中心里这个值忘了改回来。上线后正常流量一打进来,所有价格查询请求全部被 Hystrix 直接快速失败,根本没走到真实调用。
后面看了一眼监控,更离谱的是 fallback 返回的是一个空价格对象:
return ProductPriceVO.builder()
.hasPrice(false)
.price(BigDecimal.ZERO)
.source("fallback")
.build();
前端有一部分代码没有严格判断 hasPrice,直接取 price 展示,于是线上就出现了“很多商品显示价格为 0”的情况。这个锅一半在熔断配置,一半在 fallback 写得太乐观。
故障现场大概是怎样的
当天中午 13:05 左右,运营群里开始说“好几个商品价格不对”。一开始以为是促销配置,结果技术群里也看到接口返回 success: true,但 data.price 是 0。
查网关日志:
POST /api/product/detail
traceId=9a8b7c6d
status=200
看起来没问题,甚至有点太正常了,正常得像没有调用下游一样。
再查应用日志:
productPriceQuery fallback executed
reason=com.netflix.hystrix.HystrixCommandProperties$CircuitBreaker...
这时候基本就有数了,请求没出去,直接进 fallback 了。
然后我打开配置中心,搜索 productPriceQuery,第一眼没看到错误阈值,只看到一行熟悉的:
forceOpen: true
结果发现这玩意儿是真的坑啊,平时熔断规则一堆参数,errorThresholdPercentage、requestVolumeThreshold、sleepWindowInMilliseconds、timeout,唯独 forceOpen 这个开关最容易让人忽略。它不像错误率阈值那样有“慢慢触发”的感觉,它是直接告诉你:别试了,全给我熔断。
可能是我压测时太想偷懒吧,没有单独给压测环境配一套 override,直接在配置中心改主配置,想着测完就回滚。然后线上配置和预发、压测配置共用了一份,这就很危险。
为什么之前没有暴露
这个问题不是没有暴露,而是压测的时候暴露了,但我压测只盯着 QPS 和 RT,没盯业务结果。
压测脚本里 fallback 返回 hasPrice=false,脚本又只判断 HTTP 状态码:
resp = requests.post(url, json=payload)
assert resp.status_code == 200
这就很离谱了。接口返回 200 只能说明网关没炸,不代表价格对。
后来复盘时大家都觉得这行代码应该打脸。因为这种场景下,最应该断言的是:
body = resp.json()
assert body.get("data", {}).get("hasPrice") is True
但是当时我确实没写这么细。结果 forceOpen: true 被压测“合法地”跑过去了,还给我制造了一种“系统保护能力真强”的错觉。
修复过程
第一步肯定是在配置中心改回来。
把:
hystrix:
command:
productPriceQuery:
circuitBreaker:
forceOpen: true
删掉,或者显式改成:
hystrix:
command:
productPriceQuery:
circuitBreaker:
forceOpen: false
但是光改这个还不够,因为 Hystrix 有本地缓存和初始化状态,线上应用不一定立刻按预期恢复。稳妥一点还是滚动重启。
重启前我先在一个实例上改,然后用 curl 打了一组价格接口:
curl -s 'http://10.20.30.40:8080/api/product/price?skuId=10001' | jq .
返回正常价格后才滚动其他节点。
不过这里也有坑,如果你用的是配置中心动态刷新,hystrix.command.* 有些属性不是热更新友好。尤其是熔断器状态和命令配置混在一起,很容易出现“配置看着改了,但当前实例还没完全生效”的情况。我这里为了确定性,直接重启。
另外还顺手改了 fallback,不再默认返回 0,而是返回明确错误:
@HystrixCommand(fallbackMethod = "priceQueryFallback")
public ProductPriceVO getProductPrice(String skuId) {
return priceClient.query(skuId);
}
private ProductPriceVO priceQueryFallback(String skuId, Throwable throwable) {
log.warn("product price query fallback, skuId={}", skuId, throwable);
return ProductPriceVO.builder()
.hasPrice(false)
.errorMessage("price service unavailable")
.build();
}
前端那边同步发版,强制判断:
if (!res.data.hasPrice) {
showError('价格暂时无法获取,请刷新重试')
} else {
showPrice(res.data.price)
}
熔断配置后来调成了这样
这次故障之后,我把价格查询命令单独配置,不再吃全局默认:
hystrix:
command:
productPriceQuery:
execution:
timeout:
enabled: true
isolation:
thread:
timeoutInMilliseconds: 800
circuitBreaker:
forceOpen: false
errorThresholdPercentage: 50
requestVolumeThreshold: 100
sleepWindowInMilliseconds: 5000
threadpool:
productPriceQuery:
coreSize: 20
maximumSize: 50
这里几个值是这么定的:
timeoutInMilliseconds: 800,价格下游正常 RT 在 80ms 左右,压测 P99 大概 300ms,给到 800ms 足够覆盖偶发抖动,又不至于把线程拖住太久。requestVolumeThreshold: 100,没有流量样本时不要随便打开熔断,之前默认值太小,低峰期一两个慢请求就可能触发误伤。errorThresholdPercentage: 50,下游真的炸了一半请求才熔断,不是稍微抖一下就全切。sleepWindowInMilliseconds: 5000,打开之后等 5 秒放部分请求试探恢复,比 30 秒更适合这个只读查询接口。forceOpen: false单独写出来,主要是防止以后有人误配了没看见。
不出问题的话就没有问题了,但这句话现在听着都有点 PTDS。
后续验证
验证没有只看接口 200,而是分三类打:
curl -s 'http://10.20.30.40:8080/actuator/hystrix.stream' \
| grep -m 1 '"command":"productPriceQuery"'
然后看返回里:
{
"type": "HystrixCommand",
"name": "productPriceQuery",
"circuitBreaker": {
"open": false
}
}
再看监控图,Hystrix Circuit Breaker 不再长期红色,调用量恢复后错误率稳定在 1% 以内。
接着模拟下游延迟:
# 在下游 pod 里临时模拟 1s 延迟,不是真把生产搞挂,只在预发/灰度实例上做
tc qdisc add dev eth0 root netem delay 1000ms
观察结果:
- 错误率没有一上来就触发熔断。
- 熔断器在连续失败超过阈值后短暂打开。
- 5 秒后进入半开恢复,真实流量逐步成功。
- fallback 不再返回 0 价格。
这个恢复行为才是熔断该有的样子,而不是一打开就装死。
真正踩到的坑
这次线上 熔断 故障,复盘下来有几个特别容易复现的坑。
第一个坑是压测配置污染线上。很多熔断测试为了方便,会开 forceOpen、forceClosed,或者把 timeout 设得特别短。这些开关最好只走环境变量或者独立 profile,不要直接改配置中心公共文件。
第二个坑是 fallback 返回“假成功”。熔断失败后返回空对象,如果调用方没有严格识别,业务数据就会被污染。尤其是价格、库存、优惠这类字段,0 和 false 的含义完全不同。
第三个坑是监控只看 HTTP 状态码。熔断故障最阴的地方就是它可能还是 200,但业务结果已经错了。应该至少把熔断器状态、fallback 比例、业务返回码放进大盘。
第四个坑是配置热更新不可全信。熔断这种状态机组件,配置改了,最好确认当前实例是否真的生效。可以滚动一个实例验证,再全量发。
总结
这次故障看起来是 forceOpen: true 一行配置引起的,但它暴露的是流程问题:压测环境、配置中心、发布流程、fallback 语义、验证断言,全都串起来一起漏。
如果以后再做线上 熔断 故障演练,我给自己定了几个硬规则:
- 所有压测用 override 配置必须带
pressure-test: true标记。 - 线上发布前扫一遍配置,禁止存在
forceOpen、forceClosed、sleepWindow: 0、timeoutInMilliseconds: 1这类危险值。 - fallback 不许返回默认金额,必须带明确失败语义。
- 压测脚本必须断言业务字段,不能只断言 HTTP 200。
- 熔断演练要覆盖半开恢复窗口,不然不知道它会不会长期开而不关。
总的来说,这玩意儿不是真的难搞到无解,主要是平时容易把它当成一个“安全开关”,忘了它自己也是一种状态机。状态机配错,比普通超时配错还隐蔽,因为它会让系统看起来很稳,一直返回快速失败。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。