在使用 RoaringBitmap 之后,我是如何通过规则算子与编排收住复杂度的

33 阅读5分钟

在上一篇文章中,我介绍了在电商核心链路中引入 RoaringBitmap

解决活动命中计算的性能与内存问题。

但当这部分性能瓶颈被解决之后,一个新的问题很快浮现出来:

计算已经足够快了,但规则正在变得越来越难以维护。

这篇文章想聊的,不是 RoaringBitmap 本身,

而是在它之后,规则是如何被一步步抽象、拆解、编排,

最终演进成一套可长期扩展的结构的。


一、规则是如何被“自然引入”系统的?

在业务初期,规则并不会一次性出现。

最早可能只有:

  • 商品是否参与活动

  • 活动是否限制区域

于是代码里很自然地出现了:

if (hitProduct && hitRegion) {
    // 命中活动
}

后来,规则开始增加:

  • 渠道限制

  • 客户类型

  • 黑白名单

  • 特殊活动兜底逻辑

代码也随之演化成:

if (hitProduct
        && hitRegion
        && hitChannel
        && hitCustomer
        && !inBlackList) {
    // 命中活动
}

规则不是设计出来的,而是被业务一点点“推”进系统的。


二、当规则变多时,最先失控的不是性能,而是结构

在引入 RoaringBitmap 之前,我们以为问题在性能;

但性能问题解决后,很快发现:

  • 判断逻辑横向扩散

  • 不同规则之间开始隐式耦合

  • 新需求必须改旧代码

  • 排障时很难定位是哪一条规则出了问题

这时的痛点已经变成:

规则本身已经失去了“边界”。


三、第一次关键抽象:规则 ≠ 判断条件

一次重要的认知转变是:

规则不是 if / else,

而是在特定上下文下,产出一个“命中结果”。

在活动场景中,这个结果是:

  • 一批命中的活动 ID

而 RoaringBitmap,正好是这个结果的理想承载结构。

于是我们开始把规则抽象成一个统一概念:

👉 规则算子(Rule Operator)

每个规则算子只做一件事:

  • 输入:统一的业务上下文
  • 输出:一个命中集合(RBM)
  • 不关心其他规则的存在

四、算子化设计:每个规则只负责“算自己”

最早的接口定义非常简单:

public interface RuleOperator {
    RoaringBitmap match(RuleContext context);
}

对应的实现:

  • RegionRuleOperator

  • ProductRuleOperator

  • ChannelRuleOperator

  • CustomerTypeRuleOperator

它们之间没有任何调用关系,也不感知执行顺序。

规则第一次有了清晰的边界。


五、为什么引入抽象类,而不是只用接口?

随着规则继续增加,一个新问题出现了:

  • 不同算子实现风格不一致

  • 有的算子提前返回

  • 有的算子夹杂日志、监控、兜底逻辑

接口只能约束“方法签名”,

却无法约束执行流程

而在规则系统中,真正重要的是:

哪些步骤是必须的,哪些地方才允许扩展。

于是我们引入了 抽象类 + 模板方法


六、用模板方法收住规则执行边界

抽象类的核心目标只有一个:

流程固定,变化收敛。

public abstract class AbstractRuleOperator {

    public final RoaringBitmap match(RuleContext ctx) {
        if (!support(ctx)) {
            return RoaringBitmap.bitmapOf();
        }

        return doMatch(ctx);
    }

    protected abstract boolean support(RuleContext ctx);

    protected abstract RoaringBitmap doMatch(RuleContext ctx);
}

这里有几个关键点:

  • match 被 final 修饰,流程不可被破坏

  • 子类只能实现自己的命中逻辑

  • 是否参与计算由 support 决定

抽象类不是为了复用代码,而是为了限制自由。


七、规则编排:把“顺序”和“组合”从规则中拿走

当规则都被算子化之后,下一步是一个非常重要的决定:

规则本身不应该知道执行顺序,也不应该关心如何组合。

于是我们引入了独立的 规则编排层

public class RulePipeline {

    private final List<AbstractRuleOperator> operators;

    public RoaringBitmap execute(RuleContext ctx) {
        RoaringBitmap result = null;

        for (AbstractRuleOperator operator : operators) {
            RoaringBitmap hit = operator.match(ctx);
            result = (result == null) ? hit : RoaringBitmap.and(result, hit);
        }

        return result;
    }
}

这样做的好处非常明显:

  • 顺序由编排层控制
  • 算子只关心“我能命中什么”
  • 新规则只需插入 pipeline

八、RoaringBitmap 在这里扮演的真实角色

在这套结构中,RoaringBitmap 的定位非常清晰:

  • 不是规则引擎

  • 不是业务抽象

  • 而是 高效、稳定的集合计算底座

它解决的是:

当规则被拆得足够细时,

集合计算不会成为系统瓶颈。


九、自然扩展:优惠券规则并没有“另起一套系统”

当活动规则稳定之后,我们发现:

优惠券场景在本质上是同一类问题。

区别只是:

  • 规则维度不同

  • 输出集合不同

  • 编排策略略有差异

于是我们:

  • 复用同一套算子模型

  • 复用同一套编排机制

  • 只扩展新的算子实现

不是我们为优惠券设计了系统,

而是规则系统自然“长”到了优惠券场景。


十、这套设计带来的真实收益

落地一段时间后,这套结构带来的变化非常直观:

  1. 新规则 = 新算子
  2. 旧规则几乎不需要改动
  3. 单个规则可独立测试
  4. 排障成本显著降低

十一、最后的总结

可以用一句话概括这次演进:

RBM 解决的是“算得快”,

规则算子解决的是“各算各的”,

编排层解决的是“怎么一起算”。

当这三层边界清晰之后,

系统的复杂度,才真正被关进了笼子里。


结语

很多系统之所以变复杂,

并不是因为技术不够先进,

而是因为:

规则在不断增长,却从未被认真抽象过。

这次实践中,真正起决定性作用的,

不是某个具体工具,

而是对边界的持续克制。