海量人群包存储优化:基于 RoaringBitmap 交换格式与 Redis 分片 Bitmap 的实践

1. 先把结论拍桌上

这篇文章不是想讲"怎么把一种位图塞进 Redis"。

我真正想讲的,是一条 8 亿 级人群包同步链路,最后为什么会落成这样一个看起来有点拧巴、但其实很顺手的方案:

  • RoaringBitmap 用来运
  • Redis Bitmap 用来查

一开始老方案走过很多"看起来更直接"的路:

  • 直接拉 BI 明细
  • 直接上单体 Bitmap
  • 直接把 RoaringBitmap 常驻内存
  • 直接把 RoaringBitmap 二进制丢进 Redis

这些路都不能说错。

但真把数据量、同步窗口、线上多节点、Redis 成本这些东西一起摆上台面后,你会发现:

真正难的,从来不是"选一个数据结构",而是把"离线结果"稳稳地交付成"在线判断能力"。

所以这篇文章想聊的,不是某个小技巧,而是一次完整的链路重构。


2. 历史方案:表面是慢,骨子里是架构太重

先看历史方案的几个数字:

  • 最多人群包总量只能到 8 亿
  • Redis 占用约 20G
  • Redis 存储模型是Set 集合:
    • key = uid
    • value = 这个 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 个问题:

  1. 为什么不是直接用普通 Bitmap
  2. 为什么中间要引入 RoaringBitmap
  3. 为什么最终在线侧又没有直接用 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 是:

  • 888
  • 1_200_000
  • 99_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 负责载,业务负责物化

把前面的点串起来,整条链路就比较清楚了:

sequenceDiagram participant BI as BI 系统 participant MQ as 消息队列 participant BizSvc as 业务服务 participant DB as 数据库 participant OSS as 对象存储 participant Redis as Redis 分片 BI->>BI: 1. 基于 memberIndex
生成 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 用来查
相关推荐
风味蘑菇干8 小时前
IO流(字节流)
java
还有多久拿退休金8 小时前
我在自家页面嵌了个 iframe,结果对方说"你不配"——跨域和 CSP 的那些坑
前端·架构
heimeiyingwang8 小时前
【架构实战】安全性设计:让系统固若金汤
架构
叫我少年9 小时前
C# 类型系统
后端
weixin_408318049 小时前
教育行业直播系统搭建指南
java·大数据·数据库
小宋10219 小时前
Tycoon AI 新手快速上手指南
java·大数据·人工智能
java修仙传9 小时前
Java 实习日记:断面分析基态限额为空问题的排查与修复
java·开发语言·bug·实习
日取其半万世不竭9 小时前
Linux 云服务器磁盘扩容:从分区到文件系统的完整流程
java·linux·服务器
亚空间仓鼠9 小时前
Docker容器化高可用架构部署方案(十五)
android·redis·docker·架构·sentinel