小Cの已经记不起来的博客

一次线上 熔断 故障的复盘记录

先说结论

这次线上 熔断 故障,表面看是“下游价格服务慢”,实际看更像是“我们把保护开关当保险丝用了,结果保险丝自己把自己熔断器焊死了”。

真正出问题的是一个遗留配置:

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

结果发现这玩意儿是真的坑啊,平时熔断规则一堆参数,errorThresholdPercentagerequestVolumeThresholdsleepWindowInMillisecondstimeout,唯独 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

这里几个值是这么定的:

不出问题的话就没有问题了,但这句话现在听着都有点 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

观察结果:

这个恢复行为才是熔断该有的样子,而不是一打开就装死。

真正踩到的坑

这次线上 熔断 故障,复盘下来有几个特别容易复现的坑。

第一个坑是压测配置污染线上。很多熔断测试为了方便,会开 forceOpenforceClosed,或者把 timeout 设得特别短。这些开关最好只走环境变量或者独立 profile,不要直接改配置中心公共文件。

第二个坑是 fallback 返回“假成功”。熔断失败后返回空对象,如果调用方没有严格识别,业务数据就会被污染。尤其是价格、库存、优惠这类字段,0 和 false 的含义完全不同。

第三个坑是监控只看 HTTP 状态码。熔断故障最阴的地方就是它可能还是 200,但业务结果已经错了。应该至少把熔断器状态、fallback 比例、业务返回码放进大盘。

第四个坑是配置热更新不可全信。熔断这种状态机组件,配置改了,最好确认当前实例是否真的生效。可以滚动一个实例验证,再全量发。

总结

这次故障看起来是 forceOpen: true 一行配置引起的,但它暴露的是流程问题:压测环境、配置中心、发布流程、fallback 语义、验证断言,全都串起来一起漏。

如果以后再做线上 熔断 故障演练,我给自己定了几个硬规则:

  1. 所有压测用 override 配置必须带 pressure-test: true 标记。
  2. 线上发布前扫一遍配置,禁止存在 forceOpenforceClosedsleepWindow: 0timeoutInMilliseconds: 1 这类危险值。
  3. fallback 不许返回默认金额,必须带明确失败语义。
  4. 压测脚本必须断言业务字段,不能只断言 HTTP 200。
  5. 熔断演练要覆盖半开恢复窗口,不然不知道它会不会长期开而不关。

总的来说,这玩意儿不是真的难搞到无解,主要是平时容易把它当成一个“安全开关”,忘了它自己也是一种状态机。状态机配错,比普通超时配错还隐蔽,因为它会让系统看起来很稳,一直返回快速失败。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放