1. 先把结论拍桌上
这篇文章不是想讲"怎么把一种位图塞进 Redis"。
我真正想讲的,是一条 8 亿 级人群包同步链路,最后为什么会落成这样一个看起来有点拧巴、但其实很顺手的方案:
RoaringBitmap用来运Redis Bitmap用来查
一开始老方案走过很多"看起来更直接"的路:
- 直接拉 BI 明细
- 直接上单体 Bitmap
- 直接把 RoaringBitmap 常驻内存
- 直接把 RoaringBitmap 二进制丢进 Redis
这些路都不能说错。
但真把数据量、同步窗口、线上多节点、Redis 成本这些东西一起摆上台面后,你会发现:
真正难的,从来不是"选一个数据结构",而是把"离线结果"稳稳地交付成"在线判断能力"。
所以这篇文章想聊的,不是某个小技巧,而是一次完整的链路重构。
2. 历史方案:表面是慢,骨子里是架构太重
先看历史方案的几个数字:
- 最多人群包总量只能到
8 亿 - Redis 占用约
20G - Redis 存储模型是Set 集合:
key = uidvalue = 这个 uid 对应命中的 cdpId 集合
- 每次全量同步耗时
8h+
如果只看现象,这个问题很容易被归类成:
- Redis 顶不住了
- BI 吐数太慢了
- 同步线程池不够大
但我后来回头看,会越来越觉得这个判断不够准。
因为这件事真正的问题,不是"某个点太慢",而是:
我们一开始就用了一条很重的链路,去做一个本来应该很轻的判断。
2.1 历史方案到底怎么存
可以先把它理解成下面这种模型:
text
key: user:n-24319998
type: Set
value: [10001, 10022, 10089, 10155]
也就是说,一个用户命中了哪些 cdpId,都堆在这个用户自己的集合里。
示意代码大概是这样:
java
public void syncUserPackages(String uid, List<String> cdpIds) {
String redisKey = "user:" + uid;
redisTemplate.delete(redisKey);
if (!cdpIds.isEmpty()) {
redisTemplate.opsForSet().add(redisKey, cdpIds.toArray(new String[0]));
redisTemplate.expire(redisKey, Duration.ofDays(7));
}
}
查询也很直觉:
java
public boolean inPackage(String uid, String cdpId) {
String redisKey = "user:" + uid;
Boolean member = redisTemplate.opsForSet().isMember(redisKey, cdpId);
return Boolean.TRUE.equals(member);
}
只看查询,其实没什么毛病。
真正重的,是"同步"。
2.2 为什么同步天然会重
历史方案同步的,不是"一个结果集",而是"每个用户对应的命中明细关系"。
换句话说,它更像下面这样:
java
for (String uid : allUsers) {
List<String> cdpIds = biClient.queryUserHitPackages(uid);
syncUserPackages(uid, cdpIds);
}
这段伪代码里,问题已经非常明显了:
- 每个用户都要走一次远程取数
- 每个用户都要组装一次结果
- 每个用户都要写一次 Redis
当数据规模来到 8 亿 时,这条链路本身就不可能轻。
所以这件事最关键的结论其实是:
历史方案不是"某一层慢",而是"从数据表达方式开始就选重了"。
3. 老方案到底重在哪里:不是一个点慢,是三层一起重
回头拆这套方案,会发现它最麻烦的地方就在于:
它不是单点问题,而是三层同时重。
3.1 BI 重
BI 本来应该只负责"算人群"。
但在历史方案里,它还要负责:
- 按用户维度吐命中明细
- 在业务同步窗口里稳定提供结果
- 承受海量明细拉取
这会让 BI 从"计算层"变成"计算 + 交付层"。
3.2 业务服务重
业务服务本来应该是结果消费方。
但在历史方案里,它还要负责:
- 发起远程拉取
- 承接大批量用户明细
- 组装 Redis 写入结构
- 做重试、补偿、并发任务控制
它被迫变成了一个大规模结果搬运工。
3.3 Redis 也重
Redis 最终承接的是"在线查询态",但它存下来的却是大量用户维度关系数据。
结果就是:
- Redis 内存越堆越高
- 多版本并存时更明显
- 同步窗口越拉越长
3.4 这个模型真正别扭的地方
对外服务真正想回答的问题,其实只有一句:
这个用户在不在这个包里?
但历史方案做的却是:
先把所有用户和所有包的命中关系按明细形式搬一遍,再把它落成查询态。
这就像你本来只是想做一个布尔判断,结果却把中间所有过程都长期物化了一遍。
所以它表面上是"同步慢",本质上其实是:
用明细链路去表达集合判断,本来就不经济。
4. 新方案的出发点:既然本质是 membership 判断,那就应该用位图思维
把问题抽象一下,人群包服务对外的能力其实非常简单:
java
boolean inPackage(String uid, String cdpId)
说到底,它就是一个 membership 判断问题。
既然本质是"判断元素是否属于集合",那你几乎绕不过去几种思路:
- Hash
- Set
- Bitmap
- 压缩位图
到这里,很多人的直觉都一样:
既然是 membership 判断,那是不是直接上 Bitmap 就好了?
一开始我也是这么想的。
但真往下做,很快就会遇到 3 个问题:
- 为什么不是直接用普通 Bitmap
- 为什么中间要引入 RoaringBitmap
- 为什么最终在线侧又没有直接用 RoaringBitmap
后面这几节,就是把这 3 个问题一个个讲透。
5. 先把 RoaringBitmap 讲明白:它到底是什么
如果一句话概括,RoaringBitmap 可以先粗暴理解成:
一种专门用来存"整数集合"的压缩位图结构。
但如果只停留在"压缩版 Bitmap"这个层面,其实会低估它。
更准确一点说,RoaringBitmap 干的是这件事:
- 先把整数空间切块
- 再根据每块里的数据分布,选择更合适的容器来存
常见容器大概有 3 类:
Array Container- 适合稀疏数据
Bitmap Container- 适合稠密数据
Run Container- 适合连续区间数据
所以它不是简单地"把 Bitmap 压小",而是:
根据数据形态,选择更合适的表达方式。
5.1 它适合解决什么问题
RoaringBitmap 特别适合这类场景:
- 结果集已经算完了
- 结果集本质是整数集合
- 需要落盘
- 需要跨系统传输
- 需要尽量小
- 需要快速反序列化
也就是说,它非常适合当:
- BI 侧的结果输出格式
- 文件交换格式
- 大规模集合交付格式
它最擅长的是:
表达和传输。
5.2 最简单的使用例子
java
import org.roaringbitmap.RoaringBitmap;
RoaringBitmap bitmap = new RoaringBitmap();
bitmap.add(888);
bitmap.add(1_200_000);
bitmap.add(99_999_999);
bitmap.runOptimize();
序列化到文件:
java
try (DataOutputStream dos = new DataOutputStream(outputStream)) {
bitmap.serialize(dos);
}
反序列化:
java
ByteBuffer buffer = ByteBuffer.wrap(fileBytes);
ImmutableRoaringBitmap bitmap = new ImmutableRoaringBitmap(buffer);
遍历也很直接:
java
for (int index : bitmap) {
// index 就是 memberIndex
}
5.3 这里先埋一个关键前提
RoaringBitmap 本质上存的还是数字。
这意味着:
- 它不能直接存
uid - 它更适合存
memberIndex
所以后面业务上一定要有一层稳定的 uid -> memberIndex 映射,这个问题我们后面单独讲。
6. 为什么不能直接用普通 Bitmap:不是 Bitmap 不好,而是高位稀疏太吃亏
讲普通 Bitmap 时,我后来越来越喜欢直接说一句最关键的话:
普通 Bitmap 的空间,主要跟最高位有关,不完全跟真实命中数有关。
这句话听起来有点抽象,但一举例子就很直观。
6.1 一个很小但很典型的例子
假设某个人群包只命中了 3 个用户,对应 index 是:
8881_200_00099_999_999
如果你用普通 Bitmap 去表示它,哪怕只有这 3 个点,你也得把位图撑到 99_999_999 这个位置。
空间大致是:
text
100,000,000 bit / 8
≈ 12.5 MB
注意,这里真实命中只有 3 个点。
但因为最高位很高,普通 Bitmap 依然会为中间所有空洞买单。
6.2 用代码感受一下这个问题
java
BitSet bitSet = new BitSet();
bitSet.set(888);
bitSet.set(1_200_000);
bitSet.set(99_999_999);
byte[] bytes = bitSet.toByteArray();
System.out.println(bytes.length);
这时候 bytes.length 的量级会非常接近 12.5MB,即使你只 set 了 3 个点。
6.3 同样的数据,为什么 RoaringBitmap 会好很多
还是刚才这 3 个数:
java
RoaringBitmap bitmap = new RoaringBitmap();
bitmap.add(888);
bitmap.add(1_200_000);
bitmap.add(99_999_999);
bitmap.runOptimize();
RoaringBitmap 不会像普通 Bitmap 那样,为整段稀疏空洞做完整分配。
它会按块选择更合适的容器来表达这些值。
所以这里真正要记住的不是"哪个结构更高级",而是:
普通 Bitmap 很适合做在线查询态。
但在"离线结果表达"和"高位稀疏数据传输"这件事上,RoaringBitmap 往往更合适。
7. 新方案真正的前提:必须先有一套稳定的 ID Mapping
前面已经埋过这个点了。
无论是 RoaringBitmap,还是 Bitmap,本质上都更适合存数字。
但业务真实进来的却是 uid。
所以整套方案成立的前提,是先有一层稳定的 ID Mapping。
7.1 这层映射至少长这样
text
uid -> memberIndex
memberIndex -> uid
例如:
text
n-24319998 -> 10086
n-8732091 -> 10087
n-5421001 -> 10088
在线查询路径依赖的是:
text
uid -> memberIndex
审计、回查、导出依赖的是:
text
memberIndex -> uid
7.2 为什么这层映射一定得稳定
如果今天:
text
n-24319998 -> 10086
明天又变成:
text
n-24319998 -> 20001
那原来写进位图里的数据就全部错位了。
所以这层映射至少要满足:
- 全局唯一
- 长期稳定
- 并发初始化不能重复分配
- 能双向回查
一句话概括就是:
- RoaringBitmap 和 Redis Bitmap 解决的是"怎么存"
ID Mapping解决的是"存的到底是谁"
后者如果没做好,前面的所有优化都会失去意义。
7.3 在线查询示例
java
public boolean inPackage(String uid, String cdpId) {
long memberIndex = idMappingService.toMemberIndex(uid);
long shardNo = shardNo(memberIndex);
int offset = offsetInShard(memberIndex);
String key = "cdp:" + cdpId + ":index:" + shardNo;
return Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, offset));
}
这段代码其实已经把整条在线查询路径讲得很清楚了:
在线服务真正查的,不是原始
uid,而是它映射后的memberIndex。
8. 新方案整体架构:BI 负责算,OSS 负责载,业务负责物化
把前面的点串起来,整条链路就比较清楚了:
生成 RoaringBitmap BI->>OSS: 2. 上传 RoaringBitmap 文件 OSS-->>BI: 上传成功,返回文件路径 BI->>MQ: 3. 发送 MQ 消息
(只带元数据:文件路径/人群ID等) MQ->>BizSvc: 4. 推送消息 BizSvc->>DB: 5. 初始化异步同步任务
(记录任务状态:进行中) DB-->>BizSvc: 返回任务 ID BizSvc->>OSS: 6. 下载 RoaringBitmap 文件 OSS-->>BizSvc: 返回文件流 BizSvc->>BizSvc: 7. 反序列化 RoaringBitmap BizSvc->>BizSvc: 8. 转换为 Redis 分片
Bitmap 二进制 loop 批量写入各分片 BizSvc->>Redis: Redis-->>BizSvc: 写入成功 end BizSvc->>DB: 更新任务状态:完成 Note over BizSvc,DB: 任务结束 Note over Redis: --- 在线查询阶段 --- User->>BizSvc: 10. 查询用户是否命中人群包 BizSvc->>Redis: 11. GETBIT key offset Redis-->>BizSvc: 返回 0/1 BizSvc-->>User: 返回是否命中
8.1 MQ 消息体长什么样
示意一下消息体:
json
{
"cdpId": "1011552",
"version": "202605260001",
"ossUrl": "oss://bucket/cdp/1011552/202605260001.rbm",
"checksum": "md5:12ab34cd56ef"
}
这里没有海量用户明细。
只有:
- 哪个包
- 哪个版本
- 去哪里拿结果文件
- 怎么校验文件一致性
8.2 消费端伪代码
java
public void onMessage(CdpPackageMessage message) {
asyncExecutor.submit(() -> {
byte[] fileBytes = ossClient.download(message.getOssUrl());
verifyChecksum(fileBytes, message.getChecksum());
ImmutableRoaringBitmap bitmap = deserialize(fileBytes);
writeToRedisBitmap(message.getCdpId(), message.getVersion(), bitmap);
});
}
这条链路最关键的变化,不是"引入了 OSS",而是:
新方案搬运的是结果文件,不是逐行明细。
9. 为什么不把 RoaringBitmap 常驻业务内存
做到这里,一个很自然的问题就会冒出来:
既然 RoaringBitmap 已经这么适合表达集合了,为什么不直接把它常驻业务内存?
单机、小规模、单实例场景下,这当然是能做的。
但一旦进入分布式多节点服务,这条路就会很快变得别扭。
9.1 单机能做,不代表分布式能优雅地做
如果每个节点都把热点人群包的 RoaringBitmap 常驻内存,你马上会遇到这些问题:
- 节点 A 已经拿到新版本
- 节点 B 还在用旧版本
- 扩容节点怎么预热
- 滚动发布时新旧版本怎么共存
- 回滚时怎么快速一致恢复
本地缓存天然是每台机器各管各的。
这就把一个本来应该由共享存储层统一解决的问题,变成了每个节点自己处理版本切换的问题。
9.2 内存成本会按节点数线性放大
假设一个包的 RoaringBitmap 文件反序列化后占 30MB。
如果你有:
50个热点包10个业务节点
那本地缓存的理论占用就是:
text
30MB * 50 * 10 = 15GB
而这 15GB 还是业务节点自己的 JVM 内存,不是共享存储。
9.3 JVM 和 GC 也会一起变丑
让大对象长期常驻业务 JVM,通常还会带来几个副作用:
- Heap 被大对象挤占
- GC 变重
- 新节点冷启动需要预热
- 低频包和热点包混在一起,不好统一淘汰
所以这一节最终想落下来的结论很简单:
RoaringBitmap 适合做离线交换格式,但不适合直接作为分布式在线查询态常驻在业务内存里。
10. 为什么不把 RoaringBitmap 二进制直接存 Redis
接下来另一个很顺手的思路又会冒出来:
不常驻内存我理解,那为什么不把 RoaringBitmap 二进制直接放 Redis?
这条路看起来像是"两头都占了":
- 有统一存储
- 又保留了压缩格式
但问题在于,它并不适合高频在线查询。
10.1 Redis 不原生理解 RoaringBitmap
如果你只是把 RoaringBitmap 二进制对象塞进 Redis String,那么 Redis 本身并不知道这是什么结构。
它不能像 GETBIT 一样原生回答:
这个
memberIndex在不在这个集合里?
10.2 查询路径会退化成"取对象 + 反序列化 + contains"
示意代码大概会变成这样:
java
public boolean inPackage(String uid, String cdpId) {
long memberIndex = idMappingService.toMemberIndex(uid);
byte[] bytes = redisTemplate.opsForValue().get("cdp:" + cdpId + ":rbm");
ImmutableRoaringBitmap bitmap = deserialize(bytes);
return bitmap.contains((int) memberIndex);
}
这条路径的问题非常直接:
- 每次查询都要先拉整个对象
- 每次查询都要反序列化
- 每次查询都要在 JVM 里执行 contains
对于高频在线服务来说,这条链路明显太重了。
10.3 不做本地缓存会频繁回源,做本地缓存又回到上一节的问题
如果不做本地缓存:
- 每次都回 Redis 拉整个二进制对象
- 网络成本高
- 反序列化 CPU 成本也高
如果做本地缓存:
- 又回到了上一节的多节点一致性问题
- 又会把单机内存问题带回来
10.4 BigKey 问题也很现实
如果一个包、一个版本对应一个完整的 RoaringBitmap 二进制对象,那它天然更接近 BigKey:
- 单次传输包体大
- 主从复制更重
- AOF / RDB 更重
- Redis 集群迁移抖动更明显
举个很直接的数量级例子:
如果一个对象是 5MB,查询 QPS 是 1000,那理论回源流量就是:
text
5MB * 1000 = 5GB/s
这在高频查询里几乎不可接受。
所以这一节真正想打掉的误区是:
放进 Redis,不等于 Redis 就获得了高效查询能力。
11. 为什么最终落地的还是 Redis Bitmap
走到这里,真正的目标其实已经很清楚了:
- 离线结果交付阶段,用 RoaringBitmap
- 在线高频判断阶段,用 Redis 能直接回答的问题模型
从这个角度看,Redis Bitmap 其实就很自然。
11.1 为什么还是选了 Redis Bitmap
原因很直接:
- Redis 原生支持位操作
- 查询路径简单
- 多节点共享同一份查询态
- 业务节点尽量无状态
GETBIT语义和 membership 判断天然契合
11.2 为什么不是分布式 RoaringBitmap 方案
从产品形态看,也不是完全没有这种能力。
比如:
- Tair 有
TairRoaring
但在我们当时的场景里,直接引入现成的分布式 RoaringBitmap 组件,并不是当前最现实的落地路径。
所以最终还是回到了:
在线查询结构仍然用 Redis Bitmap。
但这里有个前提:
不能原封不动按单体 Bitmap 去存。
否则高位稀疏的问题还会原样回来。
12. 为什么按 100 万分片:这是 key 数量和单 key 大小之间的平衡
既然最终还是 Redis Bitmap,那就一定要同时控制两件事:
- 单 key 不要太大
- key 数量不要太多
我们最后选择的是:
按
100 万 memberIndex为一个 shard。
Redis key 格式类似这样:
text
cdp:{cdpId}:index:{shardNo}
例如:
text
cdp:1011552:index:1
cdp:1011552:index:2
cdp:1011552:index:3
12.1 先算一下 100 万分片的量级
100 万 bit 对应的原始大小:
text
1,000,000 / 8 = 125,000 byte
≈ 122 KB
也就是说,每个 shard 大概只有 122KB。
这个量级的好处是:
- 单 key 足够轻
- 远小于典型 BigKey 风险量级
- 单次传输成本可控
- 主从复制、迁移、重建都比较温和
12.2 为什么不是 10 万
如果按 10 万 分片:
- 单 shard 大小会下降到约
12.2KB - 但
8 亿空间会被切成8000个 shard
问题马上就来了:
- key 数量膨胀
- pipeline 命令数上升
- Redis 元数据开销增加
- 版本和过期管理都更重
12.3 为什么不是 1000 万
如果按 1000 万 分片:
8 亿空间只需要80个 shard- 但单 shard 会膨胀到约
1.25MB
这又会带来新的问题:
- 单 key 变重
- 单次网络包体更大
- BigKey 风险上升
- 迁移和复制抖动更明显
12.4 100 万不是魔法数字,是一个工程折中点
所以 100 万 这个值的本质是:
在"key 数量"和"单 key 大小"之间取一个平衡。
同时它还有个额外价值:
即使最终还是位图,它也把普通 Bitmap 的"高位稀疏撑大单 key"问题,控制在了 shard 内部。
风险没有消失,但被约束在了一个更可接受的颗粒度里。
13. 怎么写入 Redis:不是循环 setbit,而是本地拼 byte[] 后整块 set
这一节是全文实现层面最关键的地方。
13.1 一个最自然、但其实不对的做法
很多人第一反应会这么写:
java
for (int index : bitmap) {
long shardNo = shardNo(index);
int offset = offsetInShard(index);
String key = "cdp:" + cdpId + ":index:" + shardNo;
redisTemplate.opsForValue().setBit(key, offset, true);
}
如果担心网络来回太多,还会进一步包一层 pipeline:
java
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (int index : bitmap) {
long shardNo = shardNo(index);
int offset = offsetInShard(index);
String key = "cdp:" + cdpId + ":index:" + shardNo;
connection.setBit(key.getBytes(StandardCharsets.UTF_8), offset, true);
}
return null;
});
13.2 为什么这依然不行
因为 pipeline 解决的是:
- 网络 RTT
但它解决不了:
8 亿条命令本身的发送8 亿次 Redis 命令解析8 亿次位修改执行- 巨量命令带来的 CPU 和复制压力
也就是说:
pipeline只能解决"来回跑太多趟"的问题,解决不了"你本来就发了 8 亿条命令"的问题。
13.3 正确思路:每个 shard 在本地先拼成整块 byte[]
我们真正想做的不是:
把
8 亿个 bit 一个一个打进去
而是:
先在 JVM 里把每个 shard 对应的位图二进制拼好,再整块
SET一次
这样命令量会从:
text
按 bit 写:最多 8 亿次命令
变成:
text
按 shard 写:满量级包理论上最多 800 次命令
13.4 先定义 shard 规则
java
private static final int SHARD_SIZE = 1_000_000;
long shardNo(long index) {
return (index - 1) / SHARD_SIZE + 1;
}
int offsetInShard(long index) {
return (int) ((index - 1) % SHARD_SIZE);
}
String shardKey(String cdpId, long shardNo) {
return "cdp:" + cdpId + ":index:" + shardNo;
}
13.5 为什么不能直接用 BitSet.toByteArray()
这里有个很容易踩的坑:
不要直接拿 Java BitSet.toByteArray() 往 Redis 塞。
因为 Java BitSet 和 Redis GETBIT/SETBIT 的 bit 顺序语义并不完全一致。
Redis 的 offset 0 对应的是第一个字节的最高位,所以最稳妥的方式,是自己按 Redis 的 bit 规则构建二进制。
13.6 本地构建 Redis Bitmap 二进制
java
class RedisBitmapBuilder {
private final byte[] bytes;
RedisBitmapBuilder(int bitSize) {
this.bytes = new byte[(bitSize + 7) >>> 3];
}
public void set(int offset) {
int byteIndex = offset >>> 3;
int bitIndex = offset & 7;
bytes[byteIndex] |= (byte) (1 << (7 - bitIndex));
}
public byte[] toByteArray() {
return bytes;
}
}
13.7 遍历 RoaringBitmap,按 shard 归组
java
Map<Long, RedisBitmapBuilder> shardMap = new HashMap<>();
for (int index : bitmap) {
long shardNo = shardNo(index);
int offset = offsetInShard(index);
RedisBitmapBuilder builder = shardMap.computeIfAbsent(
shardNo,
k -> new RedisBitmapBuilder(SHARD_SIZE)
);
builder.set(offset);
}
13.8 最后整块写 Redis
java
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
RedisSerializer<String> keySerializer = redisTemplate.getStringSerializer();
for (Map.Entry<Long, RedisBitmapBuilder> entry : shardMap.entrySet()) {
String key = shardKey(cdpId, entry.getKey());
byte[] keyBytes = keySerializer.serialize(key);
byte[] valueBytes = entry.getValue().toByteArray();
connection.set(keyBytes, valueBytes);
connection.expire(keyBytes, 7 * 24 * 60 * 60);
}
return null;
});
这一节最值得记住的一句话其实就是:
不是 100 万数据逐个 setbit,而是 100 万范围内的 bit 先在本地拼成一个 shard,然后只
SET一次。
14. 这次优化真正带来的收益是什么
这次优化真正的收益,不是一句"压缩率更高"就能讲完的。
它至少体现在下面几个层面。
14.1 BI 压力下降了
BI 不再需要按用户维度持续吐海量命中明细。
它只需要:
- 算出人群包结果
- 产出 RoaringBitmap 文件
- 交付 OSS 地址和元数据
14.2 业务服务职责变轻了
业务服务不再是"拉明细 + 组装 + 同步"的搬运工。
它现在只需要:
- 消费消息
- 下载文件
- 反序列化
- 物化在线查询态
14.3 Redis 存储模型更适合在线查询了
Redis 存下来的不再是"大量用户维度关系明细",而是:
- 按包组织
- 按 shard 切分
- 为在线
GETBIT查询优化过的结构
14.4 同步效率的优化是结构性的
最关键的变化不是线程池加大了,也不是某个接口调快了。
而是:
- 历史方案在搬明细
- 新方案在搬结果文件
同时:
- 历史方案写 Redis更像"海量小写入"
- 新方案写 Redis更像"少量整块写入"
所以它不是参数调优,而是链路模型变了。
14. 总结
BI 压力降了,OSS 承接大对象了,业务只做物化了,Redis 存储结构收敛了,同步时长和系统复杂度也一起下来了。
我们不是在优化某一个 Redis key,而是在重构"人群包结果从离线到在线"的整条交付链路。
最后真正落下来的设计,其实很朴素:
RoaringBitmap用来运Redis Bitmap用来查