在真实业务中使用 RoaringBitmap 的一次实践与取舍

19 阅读7分钟

一、背景

在电商系统中,营销板块有一类非常典型、但性能要求极高的场景:

  • 商品打活动标

  • 到手价计算

这些请求都会涉及:

根据多个规则维度,计算命中了哪些活动

活动规模并不算夸张:

  • 活动总量:万级

  • 商品明细:几十万级

但问题在于:

  • 规则维度多(区域 / 商品 / 渠道 / 客户类型/黑白名单等)
  • 请求频率极高
  • 位于核心链路,对 RT 和稳定性非常敏感

二、问题本质:我们真正要解决的是什么?

如果抽象一下,其实问题非常明确:

  • 每一个规则维度,都会命中一批活动 ID

  • 最终结果是:多个规则命中集合的交集

例如:

命中区域的活动集合
∩ 命中商品的活动集合
∩ 命中渠道的活动集合

难点不在于“能不能算出来”,

而在于:

  • 能否在高并发下稳定计算****
  • 能否长期常驻内存而不产生明显抖动

三、最早的方案:ConcurrentHashMap + 多重循环匹配

在真正引入 RoaringBitmap 之前,我们最早运行的方案其实非常“常规”:

ConcurrentHashMap + 规则遍历 + 多重循环匹配

1️⃣ 数据组织方式

大致结构是:

  • 每个规则维度维护一份 ConcurrentHashMap

  • key 是规则条件

  • value 是活动规则对象或活动 ID 集合

请求进来后:

  1. 根据商品、区域等条件,在多个 Map 中查找命中的规则
  2. 再通过 双重甚至多重循环,计算最终命中的活动集合

2️⃣ 为什么一开始会这么做?

这个方案在初期看起来非常合理:

  • JDK 原生

  • 线程安全

  • 实现直观

  • 对业务同学友好

在规则数量不多、并发压力不大的时候,确实能正常工作。


3️⃣ 很快暴露的问题

随着业务增长,活动和规则变得更加复杂,这个方案的问题开始集中出现。

❗ 多重循环匹配,CPU 开销大

  • 每增加一个规则维度,就多一层遍历

  • 规则组合后,复杂度迅速放大

  • 在高并发场景下,CPU 消耗明显

尤其是在:

  • 商品打活动标

  • 到手价计算

这些高频核心链路中,问题被无限放大。


❗ 内存占用高,GC 压力明显

  • ConcurrentHashMap 本身对象多

  • value 中又嵌套规则对象 / 集合

  • 数据规模上来后,对堆内存非常不友好

线上表现为:

  • Young GC 频繁
  • 偶发 Full GC
  • RT 抖动明显

❗ 高并发下 CPU / 内存抖动剧烈

在压测和高峰期可以明显观察到:

  • CPU 使用率波动大

  • RT 不稳定

  • 内存曲线呈锯齿状

这时我们逐渐意识到一个事实:

问题已经不是实现方式的问题,而是数据结构选型不适合这个问题。


四、问题重新抽象:这是一个“集合计算”问题

在复盘这个方案时,我们重新抽象了需求本身:

  • 每个规则维度 → 命中一批活动 ID

  • 最终结果 → 多个集合的 交集运算

而我们之前的做法,本质上是:

用 Map + 多重循环,手动模拟集合交集

这一步认知转变,是后续引入 RoaringBitmap 的关键。


五、为什么会考虑 RoaringBitmap?

RoaringBitmap 本质上是:

对整数集合进行高度优化的一种位图结构

非常适合以下特征的场景:

  • 活动 ID 是 int / long

  • 数据整体稀疏,但局部密集

  • 核心操作是 AND / OR

这正好与我们的业务高度契合。


六、RoaringBitmap 带来的改变

1️⃣ 集合交集计算效率大幅提升

以前需要多重循环的逻辑,现在可以简化为:

RoaringBitmap result = RoaringBitmap.and(a, b);

底层是批量位运算,

相比逐个判断,效率优势非常明显


2️⃣ 内存占用更可控

相比 ConcurrentHashMap + Set:

  • 没有大量对象

  • 内存布局更紧凑

  • GC 压力明显下降

对于长期常驻内存的数据结构,这一点非常关键。


3️⃣ 业务语义更清晰

RoaringBitmap hitActivities =
        regionBitmap
            .and(productBitmap)
            .and(channelBitmap);

表达的是:

规则命中集合的计算

而不是容器操作的堆砌,可读性反而更好。


七、并发与更新问题:RoaringBitmap 不是银弹

在真正落地时,我们也遇到了一个绕不开的问题:

RoaringBitmap 本身不是线程安全的

因此必须明确它的使用边界


八、我们的使用原则(非常关键)

✅ 使用方式

1. 冷启动阶段:纯构建期

系统启动或规则全量加载时:

  • 构建 RoaringBitmap

  • 全量初始化规则数据

  • 对外服务尚未开放

这一阶段属于纯构建阶段

不存在并发读写问题,也不涉及锁竞争。


2. 运行期:基于 MQ 的受控更新模型

在系统运行过程中:

  • 活动 / 商品规则发生变更

  • 通过 MQ 广播通知

  • 由单个消费者串行处理更新逻辑

这类更新具备非常明确的特征:

  • 更新频率远低于读请求

  • 更新逻辑天然串行

  • 不存在并发写问题

因此,实际并发模型是:

高并发读 + 串行写


3. 更新时的并发安全如何保证?

在我们的场景中:

  • 写操作由单线程执行

  • 不存在多个线程同时修改 Bitmap

  • 因此不依赖复杂的并发控制机制

在实现上:

  • 更新过程采用原地更新(in-place update)

  • 不进行全量重建

  • 不引入 Copy-on-write

在个别关键路径上,仍保留极轻量的防御性同步措施,用于防止未来并发模型变化带来的风险,但该同步:

  • 不参与高并发读路径
  • 不会成为性能瓶颈

4. 为什么没有采用“全量重建 + 原子替换”?

在调研阶段,我们也评估过

全量重建 + 原子引用替换 的方案。

但结合实际业务特征:

  • 更新由 MQ 驱动

  • 单消费者串行处理

  • Bitmap 规模较大(商品维度几十万)

全量重建会带来:

  • 明显的 CPU 突刺

  • 额外的对象分配与 GC 压力

在并发模型已经被业务层简化的前提下,这种成本并不划算。

因此最终选择:

通过约束写模型,换取更低的更新成本,而不是用重构换取并发安全。


5. 我们刻意避免的事情

即便允许运行期更新,我们仍然明确不做以下事情:

  • ❌ 在运行期频繁增删 Bitmap

  • ❌ 在 Bitmap 上引入复杂的并发控制逻辑

  • ❌ 将 Bitmap 当作通用可变集合使用

在整个系统中,RoaringBitmap 的定位始终是:

规则命中集合的计算载体,而不是通用存储结构


九、为什么不是所有地方都用 RoaringBitmap?

我们并没有把它当成“万能解法”:

  • 规则简单

  • 数据量小

  • 不涉及集合运算

👉 这些场景下,普通 Map / Set 反而更合适。


十、事后复盘

这次选型过程中,有 3 点经验非常重要:

  1. 先抽象问题,再选数据结构
  2. 集合计算问题,不要用循环硬抗
  3. 在核心链路上,稳定性优先于“看起来高级”

十一、最后一点个人感受

很多性能问题,表面上是实现细节的问题,

但真正的分水岭,往往发生在更早的地方:

你是否用对了“问题的表达方式”。

当我们把它抽象成一个集合交集问题时,

RoaringBitmap 只是一个顺理成章的选择,

而不是一项炫技式的优化。