一位一位抠出来的极致节省:彻底搞懂 Redis Bitmap 与 String 的底层真相
一位一位抠出来的极致节省:彻底搞懂 Redis Bitmap 与 String 的底层真相
在 Redis 的世界里,很多人会用 Bitmap,但真正理解「为什么它能做到 1 bit 表示 1 个状态」的人并不多。
本文从 内存 bit → Redis String → SDS → Bitmap 一层一层拆解,带你洞察 Redis 是如何把内存压榨到极限的,并探讨在大规模工程实践中,如何通过「分片」解决热点问题。
一、 重新定义 Bitmap:它不是新东西
首先要纠正一个误区:Redis 并没有一种叫做 Bitmap 的独立底层数据结构。
Bitmap 的本质是:把 Redis 的 String 当成「按 bit 访问的数组」来使用。
当你拨开 SETBIT、GETBIT 这些命令的外壳,你会发现:
- Key:普通的 Redis key。
- Value:普通的 String(底层是 SDS 结构)。
- 操作逻辑:Redis 只是通过位运算,直接定位并修改了这个字符串对应二进制位上的值。
也就是说,如果你对一个 key 执行了 SETBIT,你完全可以用 GET 命令把它的原始字符串读出来;反之,你用 SET 存入一个字符串,也可以用 GETBIT 去读取它内部的每一个比特位。
二、 极致的数学:一个 Bitmap 能存多少数据?
1. 理论上限与物理边界
Redis 对单个 String 有明确的限制:最大长度 512 MB。
既然 Bitmap 寄生于 String,它的上限也就被锁死在了:
一个 Bitmap 最多可以表示约 42.9 亿个布尔状态。 对于大多数业务场景(如全中国移动用户量、全球社交平台日活),这已经绰绰有余。
2. 偏移量(Offset)的逻辑映射
Bitmap 的 offset 是按 bit 编号的(从 0 开始)。这种从逻辑偏移量到物理内存地址的转换,是纯粹的数学映射:
- byte_index =
offset / 8(确定在第几个字节) - bit_index =
offset % 8(确定在字节内的哪一位)
只要内存是连续的、且遵循 1 Byte = 8 Bits 的工业标准,这个映射就天然成立,没有任何中间寻址开销。
3.Bitmap 和普通 String 在「内存语义」上的核心差异。
简单直接的回答是:不是因为 Bitmap 里面“存的是 0 或 1”,而是因为 Bitmap 把 8 个逻辑状态“塞进”了 1 个字节里,而普通 String 一个字符就占用了至少 1 个字节。
3.1. 根本区别:位(Bit) vs 字节(Byte)
在计算机底层,1 个字节(Byte)= 8 个位(Bit)。
3.1.1. 普通 String 的逻辑
当你用 String 存状态,比如 SET user_1 "1"(表示在线):
- 你存储的是字符 “1”。
- 在 ASCII/UTF-8 编码中,字符 “1” 对应二进制是
00110001。 - 代价: 为了表示“1”这一个状态,你消耗了 8 个 bit。
3.1.2. Bitmap 的逻辑
当你用 Bitmap 存状态,比如 SETBIT user_statuses 1 1:
- 你是直接操作该 Key 对应 String 内存空间中的第 2 个 bit(索引从 0 开始)。
- 代价: 为了表示“1”这一个状态,你只消耗了 1 个 bit。
3.2. 内存利用率:8 倍的物理差距
假设你有 8 个用户的在线状态(1 表示在线,0 表示离线):
场景 A:使用普通 String 存储
如果你存成 "10110100" 这样的字符串:
- 每一个字符都是一个独立的 Byte。
- 物理占用: 8 Bytes = 64 bits。
场景 B:使用 Bitmap 存储
如果你使用 SETBIT 操作:
- 这 8 个状态会被挤在同一个字节里:
10110100。 - 物理占用: 1 Byte = 8 bits。
底层真相:
它们底层确实都用了 SDS(简单动态字符串)。但 Bitmap 是“像素级”操作,它把每一个 bit 都当成一个独立的存贮单元;而普通 String 是“单词级”操作,最小单位是 Byte。Bitmap 的内存利用率理论上是普通 String 存状态的 8 倍。
3.3. 结构开销:SDS 的“公摊面积”
SDS 结构确实是共用的。但在实际工程中,两者的“公摊”占比完全不同:
- SDS 的 Header(len, alloc, flags) 就像是房子的公摊面积。
- 如果你为一个用户开一个 String Key,你为了 1 bit 的数据,付出了整个 SDS 结构体 +
redisObject的代价(约 30 字节)。 - 如果你用 Bitmap,你是在一个大 SDS 里面连续挤进几亿个 bit。这几亿个 bit 共同分担同一个 SDS Header 的开销。
这就好比:
- 普通 String: 每个人住一栋别墅(独立 SDS),公摊巨大。
- Bitmap: 几亿人住一栋摩天大楼(共享一个大 SDS),公摊几乎可以忽略不计。
3.4. 总结:区别在于“解释权”
这两者的区别不在于底层代码写得不一样,而在于 Redis 命令如何解释这块内存:
- String 命令(GET/SET):把内存看作是 字符序列。它认为最小单位是 8 位的字节。
- Bitmap 命令(GETBIT/SETBIT):把内存看作是 位数组。它认为最小单位是 1 位的比特。
并不是 Bitmap 字符串里只有 0 或 1,而是 Bitmap 命令能让你跳过字节的限制,直接去抠那 1 个 bit 的 0 或 1。
三、 内存保卫战:为什么说它省到了极致?
为了理解 Bitmap 的伟大,我们需要对比传统存储方式在内存上的“奢侈”。
1. 传统数据结构的“原罪”
在 Java 或 Python 等高级语言中,即使是一个简单的布尔值,其内存占用也远超 1 bit。
| 数据类型 | 实际占用 (64位系统) | 空间浪费率 |
|---|---|---|
| Boolean | (8 bits) | 87.5% (7/8 都是空的) |
| Int (Integer) | (32 bits) | 96.8% (只存 0/1 时) |
2. Redis String (SDS) 的包裹成本
如果你尝试用 SET user:1:status "1" 来存状态,代价是惊人的。Redis 的 String 并不是 C 语言原生的字符数组,而是 SDS (Simple Dynamic String)。
存一个简单的字符串 "abc",Redis 实际消耗的内存包括:
- SDS Header: 约 3-9 字节(包含
len,alloc,flags等元数据)。 - redisObject: 约 16 字节(包含类型、编码、引用计数、LRU 信息等)。
- Memory Allocator Overheads: 内存分配器(如 jemalloc)为了对齐内存,往往会多分配一点。
最终结论: 你以为只存了 3 字节,实际可能占用了 30 字节以上。而 Bitmap 存储一个状态,真的只用 1 bit。
四、 进阶:大厂为何要搞「位图分片」?
当用户量达到亿级时,直接使用一个超大 Bitmap 会引发严重的工程灾难:单键超大 与 热点冲突。
1. 什么是单键超大?
如果一个 key 存储了 1 亿用户的状态,它的大小约为 12.5 MB。
- 扩容风险:Redis 字符串采用预分配机制,当 Bitmap 需要增长且触发扩容时,涉及大块内存的重新申请与数据拷贝,这会引起 Redis 瞬时卡顿。
- 同步压力:Redis 的主从同步(Replication)和持久化(RDB)都是以 key 为最小单位的。一个 12.5 MB 的 key 在网络上传输或写入磁盘,远比几千个小 key 沉重得多。
2. 什么是热点冲突?
Redis 是单线程模型。如果全平台的用户点赞、签到都打在 action:like:article:1001 这一个 key 上:
- 并发瓶颈:所有对该文章的点赞请求必须在 Redis 队列中排队顺序执行。
- RT 抖动:当请求量达到每秒数万次时,单线程处理能力的上限会导致响应时间(RT)大幅度增加。
3. 位图分片(Sharding):化整为零
解决办法是将一个巨大的 Bitmap 拆分成多个小 Bitmap key。
工程实现逻辑:
- 确定分片大小:推荐 32,768 bits(即 4KB),因为 4KB 是大多数内存页的大小,对操作系统和 Redis 都非常友好。
- 计算坐标:
chunk_id = user_id / 32768bit_offset = user_id % 32768key = "action:like:article:1001:chunk:" + chunk_id
- 效果:原本压在一个 key 上的压力,现在均匀分布在了几千个分片 key 上。
五、 避坑指南:Bitmap 适合你吗?
推荐场景
- 连续状态统计:如用户 365 天的签到、视频播放的完成进度。
- 大规模布尔标记:是否领取过奖励、是否是黑名单用户。
- 实时聚合运算:利用
BITOP(与、或、非)在服务端快速计算“既买了 A 又买了 B 的用户”。
慎用场景
- Offset 极其稀疏:如果你只有两个用户,ID 分别是
1和1,000,000,000。Bitmap 会为了第 10 亿个用户强制开辟 125MB 的空间,中间全是 0。此时用 Set 或 Roaring Bitmap 更划算。 - 非连续 ID:使用 UUID 或雪花 ID 作为偏移量。Bitmap 会因为 ID 过大导致内存瞬间爆炸。
六、 总结:一句话认知
Bitmap 不是黑魔法,而是你终于开始直接使用内存里的 bit。
- String 适合存「内容」(具有语义的数据)。
- Bitmap 适合存「状态」(纯粹的开关)。
- 分片 则是为了在追求极致节省的同时,保住系统的稳定性与扩展性。
更多推荐




所有评论(0)