降级开关拆到方法级以后,风控校验被悄悄跳过了,等发现时订单都跑出去好几千条

事情是这样的——我们最近上了一个“降级开关精细化”的项目,把原来一个服务一个总开关,拆到了方法级。上线一周,风控校验被悄悄跳过,等发现的时候已经放出去三千多笔订单了。

对,三千多笔。没有风控校验的订单。

整个事情最讽刺的地方在于,这个精细化拆分是我自己提的方案,代码也是我写的,review的时候还跟同事吹了半天架构有多优雅。结果优雅到最后,优雅地把风控给优雅没了。


拆开关这件事,初衷确实没毛病

我们之前的降级开关设计得很粗——一个服务一个总开关。比如订单服务,只要一关,整个服务就返回兜底逻辑。这种做法的问题是:降级粒度太粗了。有时候我们只想关掉积分计算,但积分计算和下单流程在同一个服务里,一关就全挂了。

所以我想着,把降级开关精确到方法级别。用注解+AOP,每个方法上打一个@DegradeSwitch,配置中心(我们用的Nacos,版本2.1.0)里配置orderService.calculatePoints=false,这个方法就自动走降级逻辑。

代码大概是这么写的:

@DegradeSwitch(key = "orderService.calculatePoints")
public int calculatePoints(Order order) {
    // 正常积分计算逻辑
    return points;
}

AOP切面里判断如果开关关闭,就返回默认值0。逻辑很清晰,review的时候大家也觉得没什么问题。

问题出在哪:风控校验方法的“默认值”不是0

坏就坏在,我们有一个风控校验方法,长得跟积分计算差不多:

@DegradeSwitch(key = "orderService.riskCheck")
public RiskResult riskCheck(Order order) {
    // 调用风控接口,返回风控结果
    // 风控不通过会抛异常,通过返回pass
    return riskClient.check(order);
}

问题来了——这个方法如果走降级逻辑,应该返回什么?

积分类方法走降级返回0,没问题,无非是用户少拿点积分。但风控校验走降级,AOP切面里我当时偷了个懒,统一返回了null。然后调用方那边是这么写的:

RiskResult result = riskCheck(order);
if (result == null || result.isPass()) {
    // 放行
    createOrder(order);
}

看到了吗?null直接放行。

也就是说,只要orderService.riskCheck这个开关被关掉,所有订单全部跳过风控校验,直接放行。

开关是怎么被关掉的

更魔幻的是,这个开关不是谁手动关的。

我们在Nacos上配了一套自动降级规则,基于错误率和响应时间。风控服务那边有一次响应慢了大概两秒钟,触发了我们这边的自动降级阈值(配置的是1秒超时+50%错误率),然后orderService.riskCheck这个开关就自动关闭了。

自动关闭之后,AOP切面开始返回null,所有订单跳过风控,直接创建。

最骚的是,订单创建成功之后,下游系统一切正常——库存扣了,支付走了,物流单号都生成了。没有任何人发现异常,因为没有任何报错。订单服务自己的监控指标反而变得很好看:响应时间骤降、错误率归零。运维那边dashboard上一片绿。

什么时候发现的

第三天的下午,风控团队的人跑过来问我们:你们订单服务最近是不是有什么改动?我们这边的风控请求量断崖式下跌,从每天一万多条掉到几十条。

我当时还没反应过来,说没有啊,一切正常。然后去查了一下订单量,发现这三天订单量反而涨了——因为风控不拦截了,本来会被拒掉的订单全放进来了。

三千多笔。

后来复盘的时候算了一笔账:这三千多笔订单里面,正常风控会拦截的大概有200多笔,包括一些明显的地址异常、金额异常、以及几个被标记的高风险账号。这200多笔订单涉及的金额,够我们团队所有人这个季度绩效扣光的。

根因其实就一个:降级默认值的语义问题

回过头来看,整个事故的根因非常清晰:不同业务方法的降级默认值,语义完全不同

积分计算降级返回0,合理。但风控校验降级返回“通过”,这就是灾难。更可怕的是,这两个方法在代码层面长得一模一样——都是返回一个对象,调用方判空之后继续走。从AOP切面的视角看,它们没有任何区别。

我们的AOP切面是这样写的:

@Around("@annotation(degradeSwitch)")
public Object around(ProceedingJoinPoint pjp, DegradeSwitch degradeSwitch) {
    String key = degradeSwitch.key();
    if (configCenter.isDegraded(key)) {
        // 问题就在这里:统一返回null
        return null;
    }
    return pjp.proceed();
}

这个return null,对于某些方法来说是正确的降级行为,对于另一些方法来说就是生产事故。

我们后来怎么修的

第一件事,把所有降级开关全部回滚到服务级别。方法级的开关先全部下线,只保留服务级的几个总开关。这个改动从决定到上线用了四十分钟。

第二件事,重新设计了方法级降级开关的默认值策略。我们加了一个fallback属性,强制要求每个使用@DegradeSwitch的方法必须显式声明降级时的返回值,不允许有“默认null”这种模糊行为:

@DegradeSwitch(
    key = "orderService.riskCheck",
    fallback = "reject"  // 明确指定降级时拒绝
)
public RiskResult riskCheck(Order order) {
    return riskClient.check(order);
}

切面里改成根据fallback属性来构造返回值。如果fallbackreject,就返回一个RiskResult的拒绝态;如果是pass,才返回通过态。逼着开发者在写注解的时候就想清楚:这个方法降级了,到底意味着什么。

第三件事,加了一条硬规则:涉及安全、风控、鉴权的方法,禁止配置自动降级。这些方法的降级开关只能手动操作,而且需要二次确认。在Nacos配置中心里,我们把这些开关的命名加上了一个前缀manual:,自动降级规则匹配到manual:前缀的配置会直接跳过。

第四件事,加了一个风控请求量的监控告警。这个告警不依赖业务指标,而是直接统计风控客户端发出去的请求数量。只要这个数量在10分钟内下跌超过80%,直接电话告警。这个告警的优先级被我们调到了最高。

事后想想,这个问题其实可以更早暴露

如果我们在Code Review的时候多问一句:“这个方法降级返回null,对业务到底意味着什么?”可能就不会上线。

如果写单元测试的时候,针对降级场景写一个case,模拟开关关闭的情况,看看返回值会不会导致调用方做出错误判断,也能在测试环境就发现。

如果上线之后不是只看订单服务的RT和错误率,而是看一眼风控请求量的趋势图,第一天就能发现异常。

但没有那么多如果。三千多笔订单跑出去了,就是跑出去了。

这件事给我的最大教训是:降级策略的设计,关键不在于你能关掉多少东西,而在于你关掉之后系统会变成什么样。 方法级的降级开关看起来更灵活、更精细,但灵活性越高,出错的概率也越大。尤其是当某些方法承担的是“守门员”角色的时候,它的降级默认值不应该是技术决策,而应该是业务决策。


常见问题

方法级降级开关到底能不能用?你们现在还继续用吗?

还在用,但加了严格的约束。只有纯计算类、无副作用、不影响安全性的方法才允许配置方法级降级开关。风控、鉴权、数据校验这类“守门”方法,一律不允许自动降级,只能手动操作且需要审批。另外,每个降级注解必须显式声明fallback行为,不允许有任何默认值。

怎么判断一个方法适不适合加降级开关?

问自己一个问题:这个方法如果返回一个默认值或直接跳过,最坏会发生什么?如果答案是“少算几个积分”“少发一条通知”,那可以加。如果答案是“可能放行一笔问题订单”“可能泄露数据”,那就别加。判断标准不是技术复杂度,是业务影响面。

你们那个风控请求量的监控是怎么做的?

直接在风控客户端的SDK里埋了一个计数器,每发一次请求就+1,每分钟上报一次到Prometheus,然后在Grafana里配了一个告警规则:10分钟内请求量跌幅超过80%触发P0告警。这个监控不依赖任何业务指标,只看SDK层面的调用量,即使业务逻辑全跳过了,只要SDK没被调用就能发现。

自动降级规则是不是不该用?

该用,但要限定范围。只对那些确定无害的接口开自动降级,比如查询类、非核心计算类。涉及写操作、涉及安全校验的接口,自动降级就是埋雷。我们现在的策略是:读接口可以自动降级,写接口只能手动降级,安全相关接口禁止降级或者只能手动+审批。