一、引言:一个看似不可能的需求
来看一个经典场景:给你40亿个QQ号(无符号整数),如何用1GB内存做去重?
- 方案A(存数据库):40亿条 × 20+字节 ≈ 几百GB → 直接否决
- 方案B(存HashSet):40亿个int × 4字节 ≈ 15GB → 内存爆炸
- 看似不可能的任务,为什么大厂面试官却要求你1GB内存搞定?
答案就是今天的主角——位图(Bitmap),而海量数据下真正的挑战是 位图分片。
二、位图基础:用1个bit干1个int的活
看对比
2.1 什么是位图?——不存数据,只存“有没有”
- 位图是一种极致轻量化的二进制数据结构,它不存储具体业务数据本身,只记录元素的存在状态。核心逻辑十分简单:用单个比特位(bit)一一映射目标数据,约定二进制 0 代表该数据不存在,二进制 1 代表该数据已存在。不同于普通集合需要完整存入数据内容,位图直接以比特位的下标作为数据唯一标识,依靠位的位置完成数据映射,仅用极小空间就能完成海量元素的存在性标记,彻底摒弃冗余存储,实现空间利用率最大化。
2.2 直观对比:存3个数,差距有多大?
| 存储方案 | 实现逻辑 | 占用空间 |
|---|---|---|
| 传统整数数组 | 存储3个4字节的整数 | 12字节 |
| 位图方案(比特数组) | 用1个字节(8bit),将对应位置设为1 | 1字节 |
整整节省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}:2025或sign: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容器(连续空间)
- 每个分片桶不会固定只用一种存储结构,根据数据疏密自动切换,完美适配稀疏 / 密集混合场景:
- Array 数组容器(稀疏场景)桶内元素数量 小于 4096 时启用不存全量二进制位,只存储存在的数值列表优势:极度省内存,空白位置完全不占用空间,完美适配稀疏数据
- Bitmap 位图容器(密集场景)桶内元素数量 大于等于 4096 时自动切换转为传统紧凑位图存储,按位标记状态优势:位运算、交集、并集、差集速度极快,批量统计效率拉满
切换意义
数据少用列表省空间,数据多用位图提性能,兼顾稀疏内存优势与密集计算优势。
- 这样的好处
- 自动分片拆分大值域,无数据的桶直接不创建、不占用内存
稀疏桶直接用数组存储,摒弃传统位图大量空占位浪费
仅对有数据的分片做内存分配,海量空白值域零开销
相比原生 Redis Bitmap、手写分片位图,内存压缩率提升数倍
与Redis Bitmap对比:
| 维度 | Redis Bitmap | Roaring Bitmap |
|---|---|---|
| 数据类型 | Bit位数组 | 分桶+动态容器 |
| 稀疏场景 | 空间浪费 | 极致压缩 |
| 跨位运算 | 支持完整BITOP命令集 | 部分需封装处理 |
五、Redis中的位图分片实战
5.1 Redis Bitmap本质——基于String的位操作
- Bitmap不是独立数据类型,本质是string类型的位级操作
- 单个String最大512MB → 最多2^32个bit位(约42.9亿)
- 最佳实践:约定命名规范避免混淆、合理控制offset上限
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 的数字 → 就是重复数
- 关键空间计算
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取模 + 时间维度 + 数据分区)
- 系统组件:统一路由层、读写分离、数据降级策略、预聚合服务
- 绩效考核型考点:多维度过滤的组合优化、计算和存储的分离与协同
最后
推荐阅读:
- 《Redis Deep Dive》相关章节
- Roaring Bitmap论文:Consistently faster and smaller compressed bitmaps
- 《数据密集型应用系统设计》——分布式数据分片章节
7.3 最后的面试技巧提醒
- 位图的面试题往往陷阱在“数据稀疏性”和“分片策略”上
- 回答时主动提到Roaring Bitmap和分片设计会大幅加分
- 如能结合公司实际场景举例(比如你所在项目中的签到或UV统计),可实现降维打击

638

被折叠的 条评论
为什么被折叠?



