先说结论
写这篇文章之前,我仔细研究了 Redis BitMap 的源码实现和底层存储结构。
我发现,BitMap 之所以在特定场景下表现惊人,不是因为什么黑魔法。
原因只有三个。
- 极致的空间利用:用 SDS 结构按字节存储,每个 bit 就是一个独立状态。1 亿用户的在线状态,只占用约 12MB 内存。
- O(1) 的操作复杂度:位运算寻址极其简单。一次除法和一次取模,就能精确定位到目标 bit。
- 底层的运算优化:BITCOUNT 用了查表法和 SWAR 算法加速;BITOP 直接调用 CPU 的位运算指令。
这三个原因分别对应存储层、操作层、运算层。三者叠加,共同造就了 BitMap 的超高效率。
下面,我逐一展开。
一、存储层:为什么 1 bit 就能存一个状态?
1.1 BitMap 不是独立的数据类型
很多开发者会误以为 Redis 有一个专门的“Bitmap 数据结构”。
其实没有。
Redis BitMap 本质是对 String 类型的位级操作封装。
你在 Redis 里执行 SETBIT online_users 10086 1,底层操作的是一个 SDS(简单动态字符串)字节数组。普通字符串操作按“字节”读写(1 byte = 8 bit),而 BitMap 按“位”读写。
操作的是同一块内存,只是粒度不同。
1.2 SDS 的二进制安全特性
SDS 是 Redis 自研的字符串结构。它有 3 个核心字段:
struct sdshdr {
int len; // 已使用字节数
int free; // 剩余可用字节数
char buf[];// 字节数组(核心)
};
这个设计带来的好处是 二进制安全:C 原生字符串遇到 \0 就终止,但 SDS 用 len 字段来判断结束。
这意味着,buf 数组里可以存任意二进制数据,不用担心被截断。
对于 BitMap 来说,这一点至关重要——位数组里天然会出现大量 0,如果按 C 字符串处理,早就被误截断了。
1.3 内存布局:普通字符串 vs BitMap
为了直观对比,我们看一个例子。存储 8 个布尔状态(1 0 1 1 0 0 1 0):
方式一:普通字符串
SET k "10110010"
内存中,每个字符存的是 ASCII 码:
- 字符
'1'→ ASCII 49 → 00110001(1 byte) - 字符
'0'→ ASCII 48 → 00110000(1 byte)
8 个字符 = 8 byte。
方式二:BitMap
SETBIT k 0 1
SETBIT k 1 0
每个 bit 直接存 0 或 1。8 个 bit 刚好拼成 1 个字节 = 1 byte。
差距是 8 倍。当数据量达到亿级别,这个差距是致命的。
1.4 逆序存储:一个巧妙的设计
Redis 在保存位数组时,采用逆序存储。
比如位数组 1111 0000 1100 0011 1010 0101,在 buf 数组中实际保存为:
buf[0] = 1010 0101
buf[1] = 1100 0011
buf[2] = 0000 1111
为什么这样设计?为了简化 SETBIT 命令的扩容逻辑。
当 SETBIT 的 offset 超过现有长度时,需要扩展字节数组。逆序存储使得新分配的字节可以直接追加到 buf 数组末尾,而不需要移动现有数据。
这个细节很小,但对性能的影响是实实在在的。
1.5 内存分配器:jemalloc 的加持
Redis 默认用 jemalloc 分配内存。
它的核心技巧是 分档管理:把内存请求按大小归入不同档次(8 字节、16 字节、32 字节……)。申请 20 字节,实际给 32 字节。
这减少了内存碎片,也让分配速度接近 O(1)。
BitMap 频繁扩容,依赖的就是 jemalloc 的高效分配。
二、操作层:SETBIT/GETBIT 如何做到 O(1)?
2.1 SETBIT 源码剖析
我们来看 setbitCommand 的核心逻辑:
void setbitCommand(client *c) {
// ...
// 1. 解析 offset
if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset,0,0) != C_OK)
return;
// 2. 解析 value(只能是 0 或 1)
if (getLongFromObjectOrReply(c,c->argv[3],&on,err) != C_OK)
return;
if (on & ~1) {
addReplyError(c,err);
return;
}
// 3. 查找或创建字符串对象(自动扩容)
if ((o = lookupStringForBitCommand(c,bitoffset)) == NULL) return;
// 4. 定位到目标字节:offset >> 3(相当于 / 8)
byte = bitoffset >> 3;
// 5. 获取该字节的值
byteval = ((uint8_t*)o->ptr)[byte];
// 6. 计算目标 bit 在该字节中的位置
bit = 7 - (bitoffset & 0x7); // offset % 8
// 7. 保存旧值用于返回
bitval = byteval & (1 << bit);
// 8. 更新 bit 值
byteval &= ~(1 << bit);
byteval |= ((on & 0x1) << bit);
((uint8_t*)o->ptr)[byte] = byteval;
}
这段代码非常精炼。核心就是两个位运算:
bitoffset >> 3:除以 8,定位到第几个字节bitoffset & 0x7:取模 8,定位到字节内的第几个 bit
没有任何循环,没有任何遍历。O(1) 就是这么来的。
2.2 自动扩容的陷阱
SETBIT 会自动扩容。如果 offset 超出当前字符串长度,Redis 会扩展 SDS 并将新增空间初始化为 0。
这带来一个需要注意的问题:如果直接 SETBIT 到一个极大的 offset,Redis 会立即分配所有中间内存。
比如你执行 SETBIT key 4294967295 1(2^32 - 1,约 512MB),Redis 会阻塞约 300ms。
最佳实践:偏移量尽量紧凑,避免空洞。
2.3 GETBIT 的执行流程
GETBIT 的执行流程几乎是 SETBIT 的子集:定位到字节 → 计算 bit 位置 → 返回 0 或 1。
这也是 O(1)。
2.4 操作流程总览
编辑
三、运算层:BITCOUNT 和 BITOP 的深度优化
如果说 SETBIT/GETBIT 的 O(1) 是“基本功”,那 BITCOUNT 和 BITOP 的优化就是“真功夫”。
3.1 BITCOUNT 的三层优化算法
BITCOUNT 命令用于统计位数组中值为 1 的二进制位的数量。
对于小位数组,直接遍历计数即可。但对于几百 MB 的大位数组,遍历的开销是不可接受的。
Redis 的 BITCOUNT 使用了三层优化策略:
第一层:查表法
预先计算好 0~255 每个字节的“1”的数量,存入一个 256 项的查找表:
static const unsigned char bitsinbyte[256] = {
0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,
1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,
// ... 共 256 项
};
对于小规模数据,直接用查表法,每字节查一次表,累加结果。
第二层:SWAR 算法
SWAR(SIMD Within A Register)是一种用通用寄存器模拟 SIMD(单指令多数据流)的算法。
核心思想:在一个 32 位或 64 位整数中,一次操作处理多个字节,而不是逐字节查表。
具体做法是:
- 将 32 位数据分成多个小组
- 用掩码和移位,让每个小组独立累加
- 最后合并所有小组的结果
这样,一次循环就能处理 4 个或 8 个字节,大幅减少循环次数。
算法演示:
public static int bitCount(int n) {
n = (n & 0x55555555) + ((n >>> 1) & 0x55555555);
n = (n & 0x33333333) + (((n >>> 2) & 0x33333333));
n = (n & 0x0F0F0F0F) + ((n >>> 4) & 0x0F0F0F0F);
n = (n & 0x00FF00FF) + ((n >>> 8) & 0x00FF00FF);
n = (n & 0x0000FFFF) + ((n >>> 16) & 0x0000FFFF);
return n & 0x3f;
}
第三层:CPU 硬件指令 POPCNT
这是最新加入的优化。2024 年 Redis 引入了对 POPCNT 指令的支持。
POPCNT(Population Count)是 x86 处理器的硬件指令,专门用来计算一个寄存器中“1”的数量。
__attribute__((target("popcnt")))
long long redisPopcount(void *s, long count) {
// ...
#if defined(__x86_64__)
int use_popcnt = __builtin_cpu_supports("popcnt");
#endif
// 如果 CPU 支持,就用硬件指令加速
}
在支持 POPCNT 的 CPU 上,BITCOUNT 的性能直接起飞。
3.2 算法选择策略
编辑
这个自适应策略,让 BITCOUNT 在任何规模下都能保持高性能。
3.3 BITOP 的底层实现
BITOP 命令支持 AND、OR、XOR、NOT 四种位运算。
这些操作全部使用 C 语言内置的位操作符(&、|、^、~)实现。
C 语言的位操作符直接对应 CPU 的位运算指令。换句话说,BITOP 的每一步都是在硬件层面完成的。
对于多个 key 的 AND/OR/XOR 运算,Redis 会:
- 找到所有 key 中最长的那个(作为结果的长度)
- 逐字节执行位运算
- 将结果写入 destkey
时间复杂度 O(N),N 是最长 key 的字节数。
四、串联:为什么三者叠加造就了极致性能?
现在,我们把三个层面串起来看。
存储层:SDS + 位级利用,让 1 亿用户的状态从 100MB 级降到 12MB 级。内存小了,CPU 缓存命中率自然更高。
操作层:位运算寻址 O(1),SETBIT/GETBIT 无论 offset 多大,都只需 2~3 次位运算。没有循环,没有遍历。
运算层:BITCOUNT 用查表+SWAR+POPCNT 三层加速,BITOP 直接调用 CPU 硬件指令。
编辑
这三个层面不是孤立的,而是层层递进的关系。
没有存储层的紧凑,内存占用爆炸,操作层再快也没意义。
没有操作层的 O(1) ,SETBIT/GETBIT 就成不了基础命令。
没有运算层的深度优化,BITCOUNT 面对几百 MB 数据就会成为瓶颈。
三者缺一不可。
五、实战场景:用 BitMap 统计 10 亿用户在线状态
理论讲完了,我们看一个真实的场景。
假设你需要统计 10 亿 QQ 用户的在线状态。
传统方案:数据库表,每上线/下线一次就 UPDATE 一行。10 亿用户频繁上下线,IO 压力直接爆炸。
用 Redis BitMap:
- 每个用户占 1 bit,10 亿 bit = 125,000,000 byte ≈ 119.2 MB
- 上线:
SETBIT online_users 10086 1 - 下线:
SETBIT online_users 10086 0 - 查询在线人数:
BITCOUNT online_users - 判断某用户是否在线:
GETBIT online_users 10086
所有操作都在内存中完成。一台普通的服务器,轻松撑住千万级 QPS。
类似场景还有:
- 用户签到(每天 1 个 bit,一年 365 bit ≈ 46 byte)
- 布隆过滤器(多个 BitMap 组合,解决缓存穿透)
- 实时去重统计(用 BITOP 做交集/并集/差集)
六、总结
Redis BitMap 的高效,可以归结为一句话:
在正确的层次上做正确的事。
存储层,用 SDS 做位级压缩,把空间利用率拉到极限。
操作层,用位运算寻址,把时间复杂度压到 O(1)。
运算层,用查表+SWAR+硬件指令,把批量计算的性能榨干。
理解这三点,你不仅知道 BitMap 怎么用,更知道它为什么这么用。
下次面试被问到“Redis BitMap 为什么快”,你就不只是回答“因为它是内存操作”了。
本文首发于:blog.csdn.net/emeson_ch/a…