策略模式中如何设计默认策略和兜底策略?
简化版
策略模式不能只考虑正常命中,还要设计没有匹配、配置错误、策略执行失败时怎么办。常见做法是提供默认策略处理普通场景,提供兜底策略保护主流程,并区分“业务不支持”与“系统异常”。默认策略可以正常返回结果,兜底策略则要配合告警、日志和降级边界,避免悄悄吞掉严重问题。
详细版
面试中常见追问是:strategyFactory.get(type) 找不到策略怎么办?如果直接返回 null,调用方容易 NullPointerException;如果随便返回默认策略,又可能掩盖配置错误。
更稳的设计是把失败场景分开:
- 业务允许无匹配:走默认策略,例如普通用户无优惠;
- 业务不允许无匹配:明确抛业务异常,例如未知支付方式;
- 策略执行失败:根据业务重要性决定失败、重试或降级;
- 配置错误:启动时或变更时校验,尽量不要到运行时才爆。
DiscountStrategy strategy = registry.find(context)
.orElse(defaultDiscountStrategy);
但支付、扣款、风控这类强一致场景不适合随便兜底。兜底策略的核心原则是:能兜业务默认值,不能兜数据正确性。
记忆钩子:默认策略是“业务正常分支”,兜底策略是“异常保护网”,两者不能混成一个沉默的黑洞。
完整版教学
面试提示:默认策略和兜底策略不是偷懒分支,而是系统可用性设计的一部分,要说明触发条件和副作用。
一、为什么策略模式必须考虑兜底
很多策略模式示例只写正常路径:
Strategy strategy = strategies.get(type);
return strategy.execute(request);
但线上系统会遇到:
- 前端传了未知 type;
- 配置中心漏配了策略 key;
- 某个策略依赖的外部接口超时;
- 新策略灰度时只对部分用户开放;
- 老版本客户端仍然发送旧枚举。
如果没有兜底设计,策略模式会从“优雅扩展”变成“运行时炸点”。
二、默认策略和兜底策略不是一回事
| 类型 | 触发原因 | 是否正常 | 例子 |
|---|---|---|---|
| 默认策略 | 业务允许没有特殊规则 | 正常 | 普通用户无折扣 |
| 兜底策略 | 异常或无法选择 | 异常保护 | 推荐失败返回热门榜 |
| 空对象策略 | 不做任何增强 | 正常或保护 | NoOpLogger |
| 降级策略 | 依赖失败后简化处理 | 异常保护 | 风控超时转人工 |
例如优惠计算中,普通用户使用 NoDiscountStrategy 是默认策略;配置中心读取失败后使用本地旧配置,才是兜底策略。
三、找不到策略时的三种处理方式
第一种是业务异常:
PaymentStrategy strategy = registry.find(payType)
.orElseThrow(() -> new UnsupportedPayTypeException(payType));
支付方式未知,不能自动用余额支付兜底,因为这会造成严重资金风险。
第二种是默认策略:
DiscountStrategy strategy = registry.find(context)
.orElse(NoDiscountStrategy.INSTANCE);
没有命中特殊优惠时,原价结算是业务可接受结果。
第三种是降级结果:
RecommendStrategy strategy = registry.find(scene)
.orElse(hotListFallbackStrategy);
推荐场景更关注可用性,个性化失败后返回热门榜通常可以接受。
四、策略执行失败时不能一刀切
策略执行失败的处理要按业务风险分层:
| 场景 | 失败处理 | 原因 |
|---|---|---|
| 支付扣款 | 失败并回滚 | 资金正确性优先 |
| 库存锁定 | 失败或补偿 | 防止超卖 |
| 推荐排序 | 降级默认列表 | 可用性优先 |
| 日志增强 | 忽略或异步补偿 | 不阻断主流程 |
| 风控校验 | 保守拒绝或人工 | 安全优先 |
这说明兜底策略不是固定答案,要根据业务的正确性、可用性和用户体验取舍。
五、用 Optional 或 Result 表达选择结果
不要让 null 在策略工厂里流动:
class StrategyRegistry {
Optional<DiscountStrategy> find(OrderContext context) {
return strategies.stream()
.filter(strategy -> strategy.supports(context))
.findFirst();
}
}
调用方必须显式处理缺失:
DiscountStrategy strategy = registry.find(context)
.orElseGet(() -> defaultStrategy);
如果还要带错误原因,可以返回 Result<Strategy>,包含 matched、reason、fallbackUsed 等字段。
六、启动校验能减少运行时兜底
如果策略是按枚举注册的,可以启动时校验完整性:
for (PayType type : PayType.values()) {
if (!registry.contains(type)) {
throw new IllegalStateException("missing strategy for " + type);
}
}
对于配置化策略,也要在配置发布时校验 key 是否存在、优先级是否冲突、灰度条件是否合法。好的兜底设计不是鼓励运行时随便兜,而是让系统在错误进入主流程前就发现问题。
七、兜底策略必须可观测
兜底一旦发生,至少要记录:
strategy_scene=discount
selected=no-discount
fallback=true
fallback_reason=no_matched_strategy
request_id=R202
user_id=U100
对于高风险场景还要报警。否则兜底策略会把故障藏起来,短期看系统没挂,长期看业务结果已经偏了。
八、常见误区与追问
- 误区:找不到策略就返回 null。 这会把错误推迟到调用处,通常变成更难定位的空指针。
- 误区:所有场景都可以走默认策略。 支付、扣款、库存、权限、风控等场景不能随便默认通过。
- 误区:兜底策略就是吞异常。 兜底要记录原因、指标和告警,必要时还要补偿。
- 误区:默认策略和降级策略没有区别。 默认策略是正常业务分支,降级策略是异常保护手段。
- 追问:NoOp 策略适合什么场景? 适合日志、指标、可选增强、无折扣等不影响核心正确性的场景。
- 追问:如何防止配置漏配策略? 启动校验、配置发布校验、枚举覆盖测试、灰度前预检查都要做。
- 追问:兜底后要不要继续重试? 看幂等性和业务风险;读请求可以有限重试,写请求要防重复执行。
九、加强记忆
- 找不到策略先区分:业务允许、业务不允许、系统异常。
- 默认策略是正常分支,兜底策略是异常保护。
null不是策略选择结果,优先用 Optional 或明确异常。- 高风险业务不能随便兜底通过。
- 兜底必须有日志、指标、告警和原因。
- 启动校验和配置校验能减少运行时事故。