一、背景
在电商系统中,营销板块有一类非常典型、但性能要求极高的场景:
-
商品打活动标
-
到手价计算
这些请求都会涉及:
根据多个规则维度,计算命中了哪些活动。
活动规模并不算夸张:
-
活动总量:万级
-
商品明细:几十万级
但问题在于:
- 规则维度多(区域 / 商品 / 渠道 / 客户类型/黑白名单等)
- 请求频率极高
- 位于核心链路,对 RT 和稳定性非常敏感
二、问题本质:我们真正要解决的是什么?
如果抽象一下,其实问题非常明确:
-
每一个规则维度,都会命中一批活动 ID
-
最终结果是:多个规则命中集合的交集
例如:
命中区域的活动集合
∩ 命中商品的活动集合
∩ 命中渠道的活动集合
难点不在于“能不能算出来”,
而在于:
- 能否在高并发下稳定计算****
- 能否长期常驻内存而不产生明显抖动
三、最早的方案:ConcurrentHashMap + 多重循环匹配
在真正引入 RoaringBitmap 之前,我们最早运行的方案其实非常“常规”:
ConcurrentHashMap + 规则遍历 + 多重循环匹配
1️⃣ 数据组织方式
大致结构是:
-
每个规则维度维护一份 ConcurrentHashMap
-
key 是规则条件
-
value 是活动规则对象或活动 ID 集合
请求进来后:
- 根据商品、区域等条件,在多个 Map 中查找命中的规则
- 再通过 双重甚至多重循环,计算最终命中的活动集合
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 点经验非常重要:
- 先抽象问题,再选数据结构
- 集合计算问题,不要用循环硬抗
- 在核心链路上,稳定性优先于“看起来高级”
十一、最后一点个人感受
很多性能问题,表面上是实现细节的问题,
但真正的分水岭,往往发生在更早的地方:
你是否用对了“问题的表达方式”。
当我们把它抽象成一个集合交集问题时,
RoaringBitmap 只是一个顺理成章的选择,
而不是一项炫技式的优化。