一位一位抠出来的极致节省:彻底搞懂 Redis Bitmap 与 String 的底层真相

在 Redis 的世界里,很多人会用 Bitmap,但真正理解「为什么它能做到 1 bit 表示 1 个状态」的人并不多。
本文从 内存 bit → Redis String → SDS → Bitmap 一层一层拆解,带你洞察 Redis 是如何把内存压榨到极限的,并探讨在大规模工程实践中,如何通过「分片」解决热点问题。


一、 重新定义 Bitmap:它不是新东西

首先要纠正一个误区:Redis 并没有一种叫做 Bitmap 的独立底层数据结构。

Bitmap 的本质是:把 Redis 的 String 当成「按 bit 访问的数组」来使用。

当你拨开 SETBITGETBIT 这些命令的外壳,你会发现:

  • 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 命令如何解释这块内存:

  1. String 命令(GET/SET):把内存看作是 字符序列。它认为最小单位是 8 位的字节。
  2. 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。

工程实现逻辑:

  1. 确定分片大小:推荐 32,768 bits(即 4KB),因为 4KB 是大多数内存页的大小,对操作系统和 Redis 都非常友好。
  2. 计算坐标
  • chunk_id = user_id / 32768
  • bit_offset = user_id % 32768
  • key = "action:like:article:1001:chunk:" + chunk_id
  1. 效果:原本压在一个 key 上的压力,现在均匀分布在了几千个分片 key 上。

五、 避坑指南:Bitmap 适合你吗?

推荐场景

  • 连续状态统计:如用户 365 天的签到、视频播放的完成进度。
  • 大规模布尔标记:是否领取过奖励、是否是黑名单用户。
  • 实时聚合运算:利用 BITOP(与、或、非)在服务端快速计算“既买了 A 又买了 B 的用户”。

慎用场景

  • Offset 极其稀疏:如果你只有两个用户,ID 分别是 11,000,000,000。Bitmap 会为了第 10 亿个用户强制开辟 125MB 的空间,中间全是 0。此时用 SetRoaring Bitmap 更划算。
  • 非连续 ID:使用 UUID 或雪花 ID 作为偏移量。Bitmap 会因为 ID 过大导致内存瞬间爆炸。

六、 总结:一句话认知

Bitmap 不是黑魔法,而是你终于开始直接使用内存里的 bit。

  • String 适合存「内容」(具有语义的数据)。
  • Bitmap 适合存「状态」(纯粹的开关)。
  • 分片 则是为了在追求极致节省的同时,保住系统的稳定性与扩展性。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐