从Bitmap到Roaring Bitmap:一文搞定位图分片与Redis集群设计

一、引言:一个看似不可能的需求

来看一个经典场景:给你40亿个QQ号(无符号整数),如何用1GB内存做去重?

  • 方案A(存数据库):40亿条 × 20+字节 ≈ 几百GB → 直接否决
  • 方案B(存HashSet):40亿个int × 4字节 ≈ 15GB → 内存爆炸
  • 看似不可能的任务,为什么大厂面试官却要求你1GB内存搞定?

答案就是今天的主角——位图(Bitmap),而海量数据下真正的挑战是 位图分片

二、位图基础:用1个bit干1个int的活

用户ID=1001

使用int类型存储状态

int占4字节=32bit

仅用1位存启用/禁用
剩余31位全部浪费

海量用户内存爆炸
100万用户≈4MB

看对比

用户ID=1001

映射为位图指定bit位

1个bit只存0/1状态

无任何空间冗余

极致节省内存
100万用户≈122KB

2.1 什么是位图?——不存数据,只存“有没有”

  • 位图是一种极致轻量化的二进制数据结构,它不存储具体业务数据本身,只记录元素的存在状态。核心逻辑十分简单:用单个比特位(bit)一一映射目标数据,约定二进制 0 代表该数据不存在,二进制 1 代表该数据已存在。不同于普通集合需要完整存入数据内容,位图直接以比特位的下标作为数据唯一标识,依靠位的位置完成数据映射,仅用极小空间就能完成海量元素的存在性标记,彻底摒弃冗余存储,实现空间利用率最大化。

2.2 直观对比:存3个数,差距有多大?

存储方案实现逻辑占用空间
传统整数数组存储3个4字节的整数12字节
位图方案(比特数组)用1个字节(8bit),将对应位置设为11字节

整整节省12倍空间!

2.3 内存占用公式

内存(字节) = 数据值域范围(最大数) 8 内存(字节)= \frac{数据值域范围(最大数)}{8} 内存(字节)=8数据值域范围(最大数)

  • 这里为什么是8,一个字节占用8个bit位,可以存8个数字
  • 40亿个QQ号去重场景:值域0~42.9亿 → 4294967296 bit ÷ 8 ≈ 512MB → 仅需1GB内存即可搞定!
  • 传统方案需要约15GB,位图节省约30倍空间

三、为什么要分片?

普通位图只解决了 “省内存” 的问题,而位图分片,才真正解决了位图在生产环境 “用不了、存不下、跑不动” 的落地难题。

3.1 对连续数据场景和稀疏数据场景的适配

  • 连续数据场景:用户ID从1~1亿连续 → 仅需约12MB
  • 稀疏数据场景:用户ID范围1~10亿,仅1万活跃用户 → 必须分配10亿位(约119MB),有效数据仅占0.001%,99.999%的空间是浪费!

3.2 为什么稀疏数据会“空间爆炸”?

  • 位图的存储空间不由实际数据量决定,而是由最大索引数值直接决定。哪怕业务中仅有极少数有效数据,只要存在一个极大的索引值,程序就必须提前开辟出从 0 到该最大值的全部比特位空间来完成映射。
  • 在实际业务里,雪花算法 ID、UUID、毫秒级时间戳这类非连续、跨度极大的字段十分常见,这类数据分布极度稀疏,有效点位零散分布在巨大数值区间内。此时直接使用原生位图存储,绝大部分比特位长期处于空置无效状态,却依旧要占用同等内存空间。
  • 最典型的踩坑场景就是直接用毫秒时间戳作为偏移量执行位图写入,仅仅少量业务数据,就能让单个位图 Key 体积暴涨至数百兆,不仅严重浪费服务器内存与 Redis 存储空间,还会大幅拖慢位运算、统计查询的执行效率,最终出现数据极少、内存占用却居高不下的空间爆炸问题。

3.3 分片是什么?——把1张大地图切成N个小地图

分片简单来说就是把一张体积庞大的完整数据大图,均匀切割成许许多多尺寸更小的独立小地图片段,后续想要查找对应数据时,只需先定位所属分片编号,再找到分片内部的位置偏移就能精准寻址,这么做既能有效缩减单个存储键的体积大小,适配数据稀疏分布的使用场景,还能大幅提升数据在分布式集群中的存储与扩容能力。

public final class BitmapShard {
    // 每个分片的位数(32K 位 => 4KB/分片)
    public static final int CHUNK_SIZE = 32_768;

    public static long chunkOf(long userId) {
        return userId / CHUNK_SIZE;
    }

    public static long bitOf(long userId) {
        return userId % CHUNK_SIZE;
    }

    private BitmapShard() {}
}

3.4 为什么分片解决了稀疏存储的问题

分片核心解决逻辑:

  • 拆分后只创建存在有效数据的分片,无数据的分片直接不初始化、不存储,彻底舍弃大片空白位;
  • 不再用一张超大位图承载全量编号,有效数据被分散到独立小分片里,只占用有效点位对应的存储空间;
  • 小分片体积小,Redis 等存储天然压缩效果更好,大幅削减稀疏场景下的无效内存开销;
  • 读写仅操作目标小分片,不用遍历整张布满空值的大位图,查询与统计效率大幅提升。

四、位图分片实战方案

4.1 方案一:按固定长度分片——最直接的解法

核心思路:把大位图拆成多个固定长度的小位图,每段独立建key

  • 段长选择:10000~65536之间平衡(太小key过多,太大压缩效果不佳)
  • 路由逻辑:
    seg_id = floor(original_offset / SEG_SIZE)
    offset_in_seg = original_offset % SEG_SIZE
    key = f"bitmap:{seg_id}"
    
  • 经典场景:用户ID范围广但稀疏,例如最大ID达2亿但只有50万活跃用户

应用案例:大型系统签到/活动记录设计中,按用户ID范围或时间段做分层分片,常用于自研中间件或分库分表层

/**
 * 位图分片配置与帮助函数。
 * 采用固定分片大小,避免单键因用户ID偏移过大而膨胀。
 */
public final class BitmapShard {
    // 每个分片的位数(32K 位 => 4KB/分片)
    public static final int CHUNK_SIZE = 32_768;

    public static long chunkOf(long userId) {
        return userId / CHUNK_SIZE;
    }

    public static long bitOf(long userId) {
        return userId % CHUNK_SIZE;
    }

    private BitmapShard() {}
}

优缺点

  • 实现简单、灵活可控、可按业务特性调优段长
  • 必须自己维护路由逻辑,不支持跨段BITOP运算(需在应用层聚合)

4.2 方案二:按用户ID取模分片——分布式友好的方案

核心思路:将用户均匀分散到多个分片中,每个分片独立处理

  • 分片策略:hash(uid) % N 决定用户属于哪个分片
  • Key设计:sign:20240501:{shard_id}

分片数量如何选择?

  • 分片过多:BITCOUNT聚合慢、key管理复杂
  • 分片过少:单key过大、内存碎片和网络序列化开销高
  • 经验值:16~64分片是比较稳的选择
    代码示例
public class SignHashShardExample {
    // 经验值:固定 32 个分片(16~64 之间最稳)
    private static final int SHARD_TOTAL = 32;

    /**
     * 根据用户ID,计算他属于哪个分片(hash取模)
     */
    public static int getShardId(long userId) {
        // 核心分片策略:hash(uid) % N
        // 这里直接用 userId 本身做 hash(用户ID天然均匀)
        return (int) (userId % SHARD_TOTAL);
    }

    /**
     * 生成 Redis Key:sign:日期:分片ID
     */
    public static String getSignKey(long userId, String date) {
        int shardId = getShardId(userId);
        // Key 设计:sign:20240501:{shard_id}
        return "sign:" + date + ":" + shardId;
    }

    // 测试
    public static void main(String[] args) {
        long uid = 10086L;
        String date = "20240501";

        String key = getSignKey(uid, date);
        int shardId = getShardId(uid);

        System.out.println("用户ID:" + uid);
        System.out.println("所属分片:" + shardId);
        System.out.println("最终签到Key:" + key);
    }
}

4.3 方案三:按时间维度拆分

核心思路:按年/月/周拆分位图,自然形成了“时间维度的分片”

  • Key设计:user:sign:{userId}:2025sign:2025:03:{userId}
  • 优势:天然解决数据清理问题、查询语义清晰、不同时间段独立管理

例如:某刷题系统用Key user:signins:{年份}:{userId},用一年中的第几天作为偏移量,既满足了刷题签到需求,又保持了数据管理的灵活性

public class TimeBitmapShard {

    /**
     * 生成【按时间分片】的签到 Key
     * @param userId 用户ID
     * @param year 年份 如 2025
     * @param month 月份 如 03
     * @return redis key
     */
    public static String getSignKeyByMonth(long userId, int year, int month) {
        // 格式:sign:202503:10086
        // 按月拆分 → 每个月一张独立位图
        return String.format("sign:%d%02d:%d", year, month, userId);
    }

    /**
     * 按年分片(一年一个位图,用 年积日 做偏移)
     */
    public static String getSignKeyByYear(long userId, int year) {
        // 格式:user:sign:10086:2025
        return String.format("user:sign:%d:%d", userId, year);
    }

    // 测试
    public static void main(String[] args) {
        long uid = 10086L;

        // 按月分片
        String monthKey = getSignKeyByMonth(uid, 2025, 3);
        System.out.println("按月Key:" + monthKey); // sign:202503:10086

        // 按年分片
        String yearKey = getSignKeyByYear(uid, 2025);
        System.out.println("按年Key:" + yearKey); // user:sign:10086:2025
    }
}

4.4 方案四:Roaring Bitmap——工业级的分桶位图

Roaring Bitmap 是位图分片思想的标准工业级落地实现,不再手动切分片,而是内置自动分片 + 自适应存储,专门解决传统位图稀疏浪费内存、密集查询慢两大痛点

Roaring Bitmap(咆哮位图)是位图分片的工业级实现,核心设计:

  • 16位分桶机制:将32位整数的高16位作为桶ID,低16位作为桶内偏移
  • 相当于系统自动完成位图分片,把超大值域均匀切成无数个独立小桶,和你手写的userId/分片大小区间分片思路完全一致,只是封装更极致。
  • 动态容器选择
    • 桶内数据量 < 4096 → 使用array容器(存储元素列表)
    • 桶内数据量 ≥ 4096 → 使用bitmap容器(连续空间)
    • 每个分片桶不会固定只用一种存储结构,根据数据疏密自动切换,完美适配稀疏 / 密集混合场景:
      1. Array 数组容器(稀疏场景)桶内元素数量 小于 4096 时启用不存全量二进制位,只存储存在的数值列表优势:极度省内存,空白位置完全不占用空间,完美适配稀疏数据
      2. Bitmap 位图容器(密集场景)桶内元素数量 大于等于 4096 时自动切换转为传统紧凑位图存储,按位标记状态优势:位运算、交集、并集、差集速度极快,批量统计效率拉满
        切换意义
        数据少用列表省空间,数据多用位图提性能,兼顾稀疏内存优势与密集计算优势。
  • 这样的好处
  • 自动分片拆分大值域,无数据的桶直接不创建、不占用内存
    稀疏桶直接用数组存储,摒弃传统位图大量空占位浪费
    仅对有数据的分片做内存分配,海量空白值域零开销
    相比原生 Redis Bitmap、手写分片位图,内存压缩率提升数倍

与Redis Bitmap对比

维度Redis BitmapRoaring Bitmap
数据类型Bit位数组分桶+动态容器
稀疏场景空间浪费极致压缩
跨位运算支持完整BITOP命令集部分需封装处理

五、Redis中的位图分片实战

5.1 Redis Bitmap本质——基于String的位操作

  • Bitmap不是独立数据类型,本质是string类型的位级操作
  • 单个String最大512MB → 最多2^32个bit位(约42.9亿)
  • 最佳实践:约定命名规范避免混淆、合理控制offset上限

Redis 数据类型

String 字符串类型

普通字符读写

位级别操作 = Bitmap

SETBIT 设位

GETBIT 查位

BITCOUNT 统计

偏移映射

Redis String 底层字节数组

第1字节
8个bit

第2字节
8个bit

第3字节
8个bit

...

offset 0~7

offset 8~15

offset 16~23

映射offset

底层存储

限制512MB上限

用户ID/业务偏移

Redis Bitmap位

String二进制数据

大Key隐患

解决方案:位图分片

5.2 用户签到系统完整实战代码

设计思路(参考):

  • 每人每月一条位图,Key:sign:user:{userId}:{yyyyMM}
  • 每天对应一个bit位,1表示签到,0表示未签
  • 单用户1个月仅需4字节,1亿用户年存储约12.5MB(对比传统表结构1亿行数据)

核心命令

  • SETBIT key offset 1 → 设置签到
  • GETBIT key offset → 查询某天是否签到
  • BITCOUNT key → 统计当月总签到天数
  • BITOP → 合并多日位图计算连续签到等
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDate;

/**
 * 用户签到系统 - Redis Bitmap 实战
 * 设计:每人每月一个位图,每天占用1bit
 */
@Component
public class UserSignService {

    private final StringRedisTemplate redisTemplate;

    // 构造注入(SpringBoot 环境)
    public UserSignService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 生成签到 Key:sign:user:{userId}:202503
     */
    private String buildSignKey(long userId) {
        LocalDate now = LocalDate.now();
        String yearMonth = String.format("%d%02d", now.getYear(), now.getMonthValue());
        return "sign:user:" + userId + ":" + yearMonth;
    }

    /**
     * 1. 用户签到(核心:SETBIT)
     */
    public void doSign(long userId) {
        String key = buildSignKey(userId);
        // 日期作为偏移量 1~31
        int offset = LocalDate.now().getDayOfMonth();
        // SETBIT key offset 1
        redisTemplate.opsForValue().setBit(key, offset, true);
    }

    /**
     * 2. 查询今天是否已签到(核心:GETBIT)
     */
    public boolean isTodaySigned(long userId) {
        String key = buildSignKey(userId);
        int offset = LocalDate.now().getDayOfMonth();
        // GETBIT key offset
        return Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, offset));
    }

    /**
     * 3. 统计当月累计签到天数(核心:BITCOUNT)
     */
    public long countMonthSign(long userId) {
        String key = buildSignKey(userId);
        // BITCOUNT key
        return redisTemplate.execute(
            connection -> connection.bitCount(key.getBytes()),
            true
        );
    }
}

5.3 Redis Cluster下的位图分片

  • Redis 集群没有使用一致性哈希,而是采用哈希槽(Hash Slot) 机制:
    集群默认有 16384 个哈希槽
    数据写入时,通过公式计算槽位:
    CRC16(key) % 16384 = 槽位编号
    每个主节点负责一部分槽位,key 固定落在某个节点上

位图分片对集群的影响

  • 按用户取模分片 → 不同用户的位图落在不同节点,负载相对均衡
  • 按时间+用户范围分片 → 需要维护用户ID区间映射表,大ID用户可能集中落在几个节点

5.4 高并发场景下的分片策略

  • 读写分离:分片节点可配置read replica分担查询压力
  • 冷热分离:近期数据放在高性能内存节点,历史数据降级
  • 预计算与缓存:高频组合(如「北京 + 25-30岁+近7日签到」)预合并缓存新key

六、大厂面试题串讲

⭐表示难度星级,★为基础,★★★★★为变态级

面试题一:40亿个QQ号如何去重?

| 出现频率 ★★★★★(大厂Top10高频题)

  • 题干:40亿个32位无符号整数,1GB内存,如何找出重复的数?
  • 核心思路:32 位无符号整数的取值范围:0 ~ 2^32 - 1,一共 2^32 个不同数字。我们用1 个 bit 表示 1 个数字是否出现过:
    bit = 0:未出现
    bit = 1:已出现
    遍历所有数字:
    第一次遍历:遇到数字,把对应 bit 置 1
    第二次遍历:遇到 bit 已经是 1 的数字 → 就是重复数
  1. 关键空间计算
    2^32 bit = 2^32 / 8 Byte = 512MB完全 ≤ 1GB 内存限制!
  • 关键计算:40亿×4字节=14.9GB vs Bitmap 512MB,节省30倍空间
  • 延伸考点:
    • 如果内存连512MB都没有怎么办?(改用布隆过滤器或外排序)
    • 如果需求是“找出只出现一次的数”?(2-bit位图,00=不存在、01=出现1次、10=出现≥2次)

面试题二:阿里——20亿个整数,判断X是否存在

| 出现频率 ★★★★

  • 题干:32位操作系统,4G内存,20亿整数,如何高效查找某个数是否存在?
  • 核心思路:位图本质是哈希思想的高效应用——20亿个数字映射到20亿bit位,只需0.25GB内存
  • 延伸考点:对比HashSet的优缺点?位图只适合整数,字符串怎么处理?

面试题三:位图稀疏问题时如何处理?

| 出现频率 ★★★★(常见于高级工程师岗)

  • 题干:用户ID分布极稀疏(最大值2亿,活跃用户仅50万),直接用ID做offset,内存浪费怎么办?
  • 核心方案:
    • 分段存储(分片):按固定段长拆分大位图
    • 取模分片:uid % N 均匀分散到多个小位图
    • Roaring Bitmap:工业级分桶解决方案
  • 延伸考点:Redis为什么不用稀疏存储?SETBIT按字节对齐整块分配的设计权衡

面试题四:Redis位图实现用户连续签到

| 出现频率 ★★★★★(业务场景真题)

  • 题干:亿级用户,如何高效存储签到记录并快速判断用户是否连续签到7天?
  • 方案:BitMap按用户+月份分片 + BITCOUNT + BITFIELD组合应用
  • 延伸考点:
    • BITOP OR计算连续7天签到出现频率
    • BITFIELD原子读写多天的具体实现
    • 如果用户量过亿(10亿+)如何分片?(按用户ID取模16/64份)

面试题五:Redis位图如何存储UV(独立访客数)?

| 出现频率 ★★★★

  • 题干:记录每日用户访问情况,如何高效统计UV?
  • 方案:BitMap + 取模分片 或 Roaring Bitmap
  • 延伸对比:HashSet vs Bitmap vs HyperLogLog(误差率<1%)在内存、准确性、复杂度三个维度的取舍

面试题六(进阶):Redis Bitmap不支持分区怎么办?

| 出现频率 ★★★(面试者主动秀技术深度的机会)

  • 题干:Redis单个Bitmap超过GB级,READ/WRITE慢,如何设计分片架构?
  • 核心分析:
    • Redis Cluster自动槽分配只能按key分片,无法细粒度管理Bitmap内部位
    • 解决方案:应用层做分片——通过分段Key/取模分片在业务层路由
  • 延伸思考:分片的代价——路由逻辑复杂度、跨段BITOP需要应用层聚合

面试题七(进阶):Redis位图在热门活动中的高并发写入问题

| 出现频率 ★★(社招P6+专属)

  • 题干:双11热门活动中,某个单个Bitmap key被上万并发写入,如何防止热点问题?
  • 方案:
    • 热点再拆分:将单key数据再分片(按用户/按时间段)
    • Pipeline批量提交减少RTT
    • 本地缓存+异步提交
    • 按业务语义拆分:签到场景中按「最近N天」和「历史记录」拆分位图
  • 进阶考点:Redis Cluster + Sentinel高可用架构下位图分片的一致性、故障转移策略

面试题八(进阶):Roaring Bitmap面试考点

| 出现频率 ★★(经常被资深面试者反客为主)

  • 题干:听说过Roaring Bitmap吗?它为什么比传统位图更好?
  • 核心解读:16位分桶 + 动态容器切换(<4096用Array,≥4096用Bitmap)
  • 进阶对比:
    • 稀疏优化能力:RoaringBitmap可按需加载分桶,Bitmap必须整体分配
    • 内存效率比较:64位整数极稀疏场景下RoaringBitmap可节省99%以上空间

面试题九(高阶):双BitMap实现补签逻辑

| 出现频率 ★(旗舰级别,应对阿里/字节TL面试)

  • 题干:大量用户偶尔会补签历史记录,如何避免改动主签到位图导致的性能抖动?
  • 方案设计:真实签到占1个BitMap,补签历史占另一个BitMap,查询时合并两个位图做或运算,应用场景包括会员系统、打卡活动等

面试题十(高阶):海量URL去重,Redis Bitmap不够用怎么办?

| 出现频率 ★(资深架构师级难题)

  • 题干:几十亿条URL去重,位图无法直接支持字符串,怎么解决?
  • 方案分解:
    • 哈希映射:将URL通过MurmurHash映射到Bitmap位(概率型,有极小冲突)
    • 多哈希+布隆过滤器:用多个哈希函数交叉验证,以极小误差率换取内存优势
    • 确定性方案:双层分片+外排序(精确但稍慢)
  • 对比分析:时间空间权衡(Trade-off)在实际工程中的决策

面试题十一(高阶):海量中位数问题——位图分片如何参与?

| 出现频率 ★★(阿里/字节P7+独家题)

  • 题干:100亿个整数中,如何以最快速度找到中位数?
  • 解法精要:
    • 分桶思路:将数据按值域拆成多个桶,统计每个桶的元素数量
    • 定位中位数桶:累加桶中元素个数直到跨过半数的桶
    • 该桶内部用BitMap排序并提取中位数
  • 深度看点:位图分片思想与“分治+统计”策略的结合,展示数据结构的组合思维能力

面试题十二(高阶):2-bit位图进阶应用——海量数据中找出现1次和多次的元素

| 出现频率 ★★(腾讯SP专场题)

  • 题干:给定100亿个整数(最大约42亿),找出所有仅出现1次的数,内存1GB
  • 2-bit位图方案:用2个bit位标记一个数的状态:
    • 00:未出现
    • 01:出现1次
    • 10:出现2次及以上
  • 实现方法:两个标准bitset复用,根据当前状态位组合更新
  • 扩展考点:
    • 出现不超过2次 —— 用上述2-bit框架
    • 求A和B两个大文件交集 —— 位图的AND操作思想

面试题十三(高阶):Redis BITOP在分片场景下的局限性

| 出现频率 ★★(面试官期待你反向提问的题目)

  • 题干:Redis Cluster + 分片位图场景下,需要计算用户近30天连续签到次数,直接调用BITOP会有什么问题?
  • 核心痛点:不同分片的key位于不同Redis节点,BITOP无法跨节点运算
  • 解决策略(4层进阶方案):
    • 应用层聚合:拉取各分片位图 → 应用内存合并 → 计算 → 返回结果
    • LUA脚本聚合并行下发
    • 离线预聚合:定时任务将分片位图预合并到汇总key
    • 架构升级:TiKV、ScyllaDB等支持分布式位图运算的数据库
  • 进阶讨论:并发预聚合的性能优化和一致性问题

面试题十四(高阶):KV存储分片 + 位图的公司级架构设计方案

| 出现频率 ★(系统设计压轴题,公司内部晋升专用)

  • 题干:已知某公司用户量超过10亿,要设计一个覆盖日活/周活/月活统计、用户画像、签到系统的底层架构,纯Redis Bitmap能支撑吗?
  • 架构设计:
    • 明确分层:热数据层(Redis分片位图)、温数据层(ClickHouse RoaringBitmap)、冷数据层(对象存储归档)
    • 分片策略:三层分片(租户ID取模 + 时间维度 + 数据分区)
    • 系统组件:统一路由层、读写分离、数据降级策略、预聚合服务
  • 绩效考核型考点:多维度过滤的组合优化、计算和存储的分离与协同

最后

推荐阅读

7.3 最后的面试技巧提醒

  • 位图的面试题往往陷阱在“数据稀疏性”和“分片策略”上
  • 回答时主动提到Roaring Bitmap和分片设计会大幅加分
  • 如能结合公司实际场景举例(比如你所在项目中的签到或UV统计),可实现降维打击
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值