策略模式中多个策略都匹配时如何选择优先级?
简化版
多个策略都可能命中时,不能靠集合遍历的偶然顺序决定结果,而要把“匹配条件”和“优先级规则”显式建模。常见做法是让策略提供 supports(context) 和 priority(),注册表按优先级排序后选择第一个匹配策略;如果业务要求可解释,还要记录命中原因、冲突策略和兜底策略。
详细版
策略模式经常不是简单的 type -> strategy 映射。比如优惠计算、风控规则、运费计算、路由选择,可能有多个策略同时满足条件:新人优惠、会员优惠、渠道优惠、活动优惠都可能命中同一个订单。
这时有 3 个关键点:
- 匹配逻辑要放在策略或规则对象里,不能散落在调用方;
- 优先级要显式声明,不能依赖 Spring 注入顺序、Map 遍历顺序或文件加载顺序;
- 冲突处理要可观测,至少能回答“为什么选了这个策略,没有选那个策略”。
工程上可以这样设计:
interface DiscountStrategy {
boolean supports(OrderContext context);
int priority();
BigDecimal discount(OrderContext context);
String name();
}
注册表启动时把策略按 priority 排好,执行时过滤 supports 为 true 的策略,再选择优先级最高的一个。如果策略之间允许叠加,就不再是“选一个策略”,而要改成组合策略、责任链或规则引擎模型。
记忆钩子:策略选择不是“谁先遍历到算谁”,而是“匹配条件、优先级、冲突解释”三件事一起设计。
完整版教学
面试提示:优先级题要重点讲确定性,规则冲突时必须能解释为什么选中某个策略。
一、为什么策略选择会出现冲突
很多教材例子把策略模式写成:
DiscountStrategy strategy = map.get(type);
return strategy.discount(order);
这种写法适合“类型互斥”的场景,比如 WECHAT_PAY、ALI_PAY、BANK_CARD 三选一。
但真实业务经常是“条件叠加”:
订单 A:
用户: 新用户
等级: VIP
渠道: App
商品: 生鲜
活动: 周末满减
可能命中的策略:
NewUserDiscount
VipDiscount
AppChannelDiscount
WeekendFullReduction
如果这些策略只能选择一个,就必须有清晰优先级;如果可以同时生效,就必须有组合规则。
二、先区分“互斥策略”和“可叠加策略”
面试里回答优先级前,最好先反问或主动说明业务语义:
| 策略关系 | 含义 | 典型处理 |
|---|---|---|
| 互斥 | 同一时刻只能选一个 | priority 选最高 |
| 可叠加 | 多个策略可以依次执行 | 组合策略或责任链 |
| 分组互斥 | 每组一个,组间可叠加 | group + priority |
| 强制排斥 | A 命中后 B 不能用 | exclusion rule |
例如支付路由通常是互斥策略;优惠系统可能是分组互斥,例如“平台券”和“商家券”可叠加,但同一组内只能选最优。
三、用 supports 表达匹配条件
不要把所有判断都写在调用方:
if (user.isVip() && order.amount().compareTo(new BigDecimal("200")) > 0) {
return vipStrategy.discount(order);
}
这样调用方会再次膨胀。更好的方式是策略自己声明是否支持:
class VipDiscountStrategy implements DiscountStrategy {
public boolean supports(OrderContext context) {
return context.userLevel() == UserLevel.VIP
&& context.amount().compareTo(new BigDecimal("200")) >= 0;
}
public int priority() {
return 80;
}
public BigDecimal discount(OrderContext context) {
return context.amount().multiply(new BigDecimal("0.85"));
}
public String name() {
return "vip-discount";
}
}
这样新增策略时,调用方只负责“找策略”,具体命中规则跟策略放在一起。
四、用 priority 显式表达选择顺序
策略注册表可以在构造时完成排序:
class DiscountStrategyRegistry {
private final List<DiscountStrategy> strategies;
DiscountStrategyRegistry(List<DiscountStrategy> strategies) {
this.strategies = strategies.stream()
.sorted(Comparator.comparingInt(DiscountStrategy::priority).reversed())
.toList();
}
DiscountStrategy select(OrderContext context) {
return strategies.stream()
.filter(strategy -> strategy.supports(context))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("no discount strategy"));
}
}
这里的关键不是代码复杂,而是把“谁优先”变成可读、可测、可评审的规则。
五、不要依赖容器注入的偶然顺序
Spring 中 List<Strategy> 可以结合 @Order 使用,但不要让业务优先级变成隐式约定:
@Component
@Order(80)
class VipDiscountStrategy implements DiscountStrategy {
}
如果优先级属于业务规则,更推荐在策略接口里暴露 priority(),或者从配置中心读取。@Order 更适合技术顺序,例如过滤器、拦截器、处理器链顺序。
六、冲突要能解释
只返回最终策略,在排查问题时不够。可以记录候选策略:
requestId=R1001
matched=[vip-discount:80, app-channel:60, default:0]
selected=vip-discount
reason=highest_priority
这类日志对面试很加分,因为它说明你不是只会写模式结构,还考虑了线上可观测性。
七、什么时候应该换成规则引擎
如果策略数量达到几十个,并且条件由运营频繁配置,纯代码策略会开始吃力。判断标准可以是:
| 现象 | 继续策略模式 | 考虑规则引擎 |
|---|---|---|
| 策略数 | 5 到 20 个 | 50 个以上 |
| 变更人 | 开发为主 | 运营/风控为主 |
| 条件复杂度 | 少量字段判断 | 多维条件组合 |
| 发布方式 | 代码发布可接受 | 需要分钟级生效 |
但规则引擎也不是万能,它会引入规则调试、权限、回滚、性能和治理成本。
八、常见误区与追问
- 误区:策略模式就是
Map.get(type),不会有冲突。 只有类型天然互斥时才是简单映射;条件匹配型策略经常会多个同时命中。 - 误区:谁先注册谁优先就可以。 这会让结果依赖启动顺序或容器顺序,线上排查很困难。
- 误区:优先级数字越大越好,不需要规范。 团队要约定范围,例如 0 为默认、100 为最高,中间预留插入空间。
- 误区:所有优惠策略都应该只选一个。 有些业务要求叠加,这时应该用组合策略或责任链,而不是强行 priority。
- 追问:如果两个策略 priority 一样怎么办? 启动时检测并报错,或者再定义 group、version、createTime 等稳定 tie-breaker,不能随机选。
- 追问:如何让策略选择可测试? 构造多个 context,断言候选策略、最终策略和优先级排序,不只测最终金额。
- 追问:优先级放注解还是配置? 技术顺序可用注解,业务顺序更适合配置或策略字段,并配合灰度、审计和回滚。
九、加强记忆
- 策略选择先判断关系:互斥、叠加、分组互斥。
- 条件型策略要有
supports(context)。 - 多个命中要有
priority()或明确冲突规则。 - 不要依赖 Map、List、容器注入的偶然顺序。
- 线上要记录候选、选中、原因,方便解释和排查。
- 策略数量和规则复杂度过高时,再评估规则引擎。