为什么 Redis BitMap 既省内存又跑得快?深入源码看透本质

0 阅读10分钟

 先说结论

写这篇文章之前,我仔细研究了 Redis BitMap 的源码实现和底层存储结构。

我发现,BitMap 之所以在特定场景下表现惊人,不是因为什么黑魔法。

原因只有三个。

  1. 极致的空间利用:用 SDS 结构按字节存储,每个 bit 就是一个独立状态。1 亿用户的在线状态,只占用约 12MB 内存。
  2. O(1) 的操作复杂度:位运算寻址极其简单。一次除法和一次取模,就能精确定位到目标 bit。
  3. 底层的运算优化: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 位整数中,一次操作处理多个字节,而不是逐字节查表。

具体做法是:

  1. 将 32 位数据分成多个小组
  2. 用掩码和移位,让每个小组独立累加
  3. 最后合并所有小组的结果

这样,一次循环就能处理 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 会:

  1. 找到所有 key 中最长的那个(作为结果的长度)
  2. 逐字节执行位运算
  3. 将结果写入 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…