文章目录
- 一、Bitmap 到底是什么
- 二、SETBIT:检票员点亮一盏灯
- 熄灯也很简单
- offset 从 0 开始,而且按高位到低位排列
- 三、GETBIT:42 号观众到底来没来
- 四、BITCOUNT:今天到底亮了多少盏灯
- BITCOUNT 不是 O(1)
- 范围默认按字节,不是按 bit
- 五、BITPOS:第一盏亮灯在哪里
- 六、BITOP:把两天的座位灯板叠起来
- AND:两天都来的人
- OR:至少来过一天的人
- XOR:两天状态不同的人
- DIFF:昨天来了、今天没来的人
- BITOP 会把结果写进目标 Key
- Cluster 中要放在同一个 Slot
- 七、BITFIELD:一块灯板还能放多个小计数器
- 溢出策略:计数器爆表怎么办
- 八、Bitmap 为什么省内存,又为什么可能突然很大
- 内存估算公式
- 哪些 ID 不适合直接当 offset
- 九、每天一块灯板,TTL 怎么设计
- 十、并发安全吗
- 十一、Bitmap、Set、Sorted Set 怎么选
- 十二、生产环境最常见的坑
- 1. 把稀疏大 ID 直接当 offset
- 2. 以为 Bitmap 是独立类型
- 3. 忘记 BITCOUNT 范围默认按字节
- 4. 高频扫描超大 Bitmap
- 5. 在 Cluster 中让 BITOP 跨 Slot
- 6. 需要列出所有用户,却选了 Bitmap
- 7. 每天一个 Key,却忘记估算总量
- 十三、完整 redis-cli 实验
- 十四、快速判断口诀
- 参考资料
晚上七点五十九分,万人体育场即将开场。
检票口不断有人刷票入场,运营大屏需要实时回答几个问题:
- 42 号观众进场了吗?
- 今天一共来了多少人?
- 昨天和今天都来的人有多少?
- 哪些人昨天来了、今天却没来?
- 四个入口分别进了多少人?
最直接的办法,是准备一张名单,把每个观众的 ID 存进去。但如果系统只关心“来过”或“没来过”,给每个人保存一个完整数字,就像为了记录座位是否有人,给每把椅子配一名拿着纸笔的工作人员——能用,但有点奢侈。
体育场想到了一种更轻的办法:每个座位上只装一盏灯。
- 灯灭,表示这个人没来;
- 灯亮,表示这个人来了;
- 第 42 盏灯,对应用户 42;
- 数一遍亮灯数量,就是到场人数;
- 把两天的灯板叠起来,还能算出连续到场、任意一天到场和流失观众。
这块由 0 和 1 组成的座位灯板,就是 Redis Bitmap。
图 1:一位观众对应一盏灯,亮与灭分别表示两种状态。
本文所有命令都在 Redis 8.6.1 的独立临时实例中验证。公开语义以 Redis 官方文档为准;Redis 8.2 新增的位运算会单独标注版本。
一、Bitmap 到底是什么
先纠正一个常见说法:Bitmap 并不是 Redis 新增的一种独立数据类型,它本质上仍然是 String。
Redis String 是二进制安全的字节序列。一个字节有 8 个 bit,Redis 只是提供了一组命令,让我们能把整段 String 当成一排开关来操作。
座位编号 0 1 2 3 4 5 6 7 | 8 9 10 11 12 13 14 15 座位灯状态 0 0 1 0 0 1 0 0 | 0 0 0 0 1 0 0 0 字节编号 0 | 1现实世界和 Redis 可以这样对应:
| 体育场 | Redis Bitmap | 含义 |
|---|---|---|
| 一整块座位灯板 | 一个 String Key | 保存大量二值状态 |
| 座位编号 | bit offset | 从 0 开始的位置 |
| 灯亮 | bit 为 1 | 已签到、在线、拥有权限等 |
| 灯灭 | bit 为 0 | 未签到、离线、无权限等 |
| 检票员开灯 | SETBIT | 修改一个状态 |
| 工作人员看灯 | GETBIT | 查询一个状态 |
| 数亮灯 | BITCOUNT | 统计 1 的数量 |
| 叠加两块灯板 | BITOP | 求交集、并集、差集 |
图 2:Bitmap 不是一排 Boolean 对象,而是紧凑排列在 String 中的一串 bit。
因为一个 bit 只有 0 和 1,所以它特别适合记录二值状态:
- 用户今天是否登录;
- 学生今天是否签到;
- 设备这一小时是否上报;
- 商品是否参加活动;
- 用户是否拥有某项权限;
- 某天是否完成打卡。
如果状态不是二选一,例如还要保存进场时间、票价、入口和座位区域,那么 Bitmap 就不能独自完成任务,需要 Hash、Sorted Set 或数据库配合。
二、SETBIT:检票员点亮一盏灯
假设 Keystadium:entry:2026-08-13表示 2026 年 8 月 13 日的进场灯板,用户 ID 直接作为 offset:
SETBIT stadium:entry:2026-08-1371SETBIT stadium:entry:2026-08-13421SETBIT stadium:entry:2026-08-1310011SETBIT key offset value的 value 只能是0或1。命令返回的不是新值,而是修改前的旧值。
127.0.0.1:6404> SETBIT stadium:entry:2026-08-13 42 1 (integer) 0 127.0.0.1:6404> SETBIT stadium:entry:2026-08-13 42 1 (integer) 1第一次返回 0,说明原来灯是灭的,这次是真正的首次签到;第二次返回 1,说明用户已经签到过。
这个返回值很有用。比如业务要维护“今日签到人数”,只有SETBIT返回 0 时才应该增加计数,避免用户重复请求造成重复统计。
不过要注意:
SETBIT bitmap userId 1 INCR daily_count这是两条命令,不会自动组成一个原子动作。若必须同时更新 Bitmap 和计数器,可以用 Lua 把“检查旧值、点灯、增加计数”放进一次执行中。相关原理可以参考博客里的 Redis 事务与 Lua 文章。
熄灯也很简单
观众退场或取消状态时,把 bit 设回 0:
SETBIT stadium:entry:2026-08-13420offset 从 0 开始,而且按高位到低位排列
Redis 把第一个字节的最高有效位看作 offset 0,然后向右依次编号:
offset 0 1 2 3 4 5 6 7 bit 权重 7 6 5 4 3 2 1 0因此:
SETBIT demo01得到的第一个字节是二进制10000000,而不是00000001。平时使用用户 ID 作为 offset 时不太需要手算字节,但在排查原始二进制数据时,这个方向很容易让人看反。
三、GETBIT:42 号观众到底来没来
查询某个用户是否签到:
GETBIT stadium:entry:2026-08-1342返回结果:
1查询一个没有被设置过的位置:
GETBIT stadium:entry:2026-08-1399返回:
0不存在的 Key 也会被当成全 0 的空字符串,因此GETBIT不会因为 Key 不存在而报错。
GETBIT和SETBIT都是 O(1),就像工作人员知道座位编号后直接看对应灯,不需要从第一排一路找过去。
图 3:用户 ID 映射为 offset,检票只修改一盏灯,查询也只查看一盏灯。
四、BITCOUNT:今天到底亮了多少盏灯
统计整块灯板中 1 的数量:
BITCOUNT stadium:entry:2026-08-13本文实验设置了 7、42、1001 三个位置,返回:
3这就是今日签到人数。
BITCOUNT 不是 O(1)
BITCOUNT需要检查指定范围内的字节,复杂度是 O(N)。体育场很小时数灯很快,但一块几百 MB 的灯板反复全量统计,仍会占用 Redis 主线程。
工程上可以这样处理:
- 控制单个 Bitmap 的大小,按天、小时或用户区间拆分;
- 不要在高峰期对超大 Bitmap 高频全量
BITCOUNT; - 对固定结果做短期缓存;
- 真正需要实时高频计数时,维护额外计数器,并用 Lua 保证更新原子性。
范围默认按字节,不是按 bit
BITCOUNT key00默认统计的是第 0 个字节,也就是 offset 0~7,而不是只统计第 0 个 bit。
Redis 7.0 起可以用BIT显式指定 bit 范围:
BITCOUNT key03BIT这才表示统计 offset 0~3。start和end都包含在范围内。
本文用两个字节11110000 00001111验证:
BITCOUNT key -> 8 BITCOUNT key 0 0 BYTE -> 4 BITCOUNT key 0 3 BIT -> 4看到0 0时千万不要条件反射地理解成“一个位置”,先确认单位是 BYTE 还是 BIT。
五、BITPOS:第一盏亮灯在哪里
BITPOS可以找到第一个值为 1 或 0 的 bit:
BITPOS stadium:entry:2026-08-131实验中最小的签到用户 ID 是 7,所以返回:
7它适合寻找:
- 第一个已占用座位;
- 第一个空闲槽位;
- 数据流里第一次出现某种状态的位置。
和BITCOUNT一样,BITPOS最坏情况下需要扫描数据,复杂度是 O(N)。范围参数默认也是按字节,Redis 7.0 起可以追加BIT改为按 bit 解释。
六、BITOP:把两天的座位灯板叠起来
Bitmap 最有意思的地方,不只是省内存,而是可以像叠透明灯板一样做位运算。
假设两天的活跃用户如下:
8 月 12 日:1、3、5、8 8 月 13 日:3、5、6、8先建两块灯板:
SETBIT stadium:{active}:2026-08-1211SETBIT stadium:{active}:2026-08-1231SETBIT stadium:{active}:2026-08-1251SETBIT stadium:{active}:2026-08-1281SETBIT stadium:{active}:2026-08-1331SETBIT stadium:{active}:2026-08-1351SETBIT stadium:{active}:2026-08-1361SETBIT stadium:{active}:2026-08-1381AND:两天都来的人
BITOP AND stadium:{active}:both\stadium:{active}:2026-08-12\stadium:{active}:2026-08-13 BITCOUNT stadium:{active}:both结果是 3,对应用户 3、5、8。
OR:至少来过一天的人
BITOP OR stadium:{active}:either\stadium:{active}:2026-08-12\stadium:{active}:2026-08-13 BITCOUNT stadium:{active}:either结果是 5,对应用户 1、3、5、6、8。
XOR:两天状态不同的人
BITOP XOR stadium:{active}:changed\stadium:{active}:2026-08-12\stadium:{active}:2026-08-13结果中用户 1 和 6 为 1:一个只在第一天出现,一个只在第二天出现。
DIFF:昨天来了、今天没来的人
Redis 8.2 为BITOP增加了DIFF:
BITOP DIFF stadium:{active}:lost\stadium:{active}:2026-08-12\stadium:{active}:2026-08-13 BITCOUNT stadium:{active}:lost结果是 1,对应用户 1。
Redis 8.2 同期还加入了:
DIFF1:在后面的灯板中出现、但不在第一块灯板中;ANDOR:在第一块灯板中,同时也在后续至少一块灯板中;ONE:在所有输入灯板中恰好只亮过一次。
这些运算让留存、流失、独占人群分析更直接。但如果生产环境版本低于 8.2,就不能使用这些新操作。
图 4:把两天的灯板重叠,交集、并集和流失用户一眼就能算出来。
BITOP 会把结果写进目标 Key
BITOP不是只返回临时结果,而是覆盖写入destkey。目标 Key 要使用独立名称,避免把原始日数据覆盖掉。
BITOP复杂度是 O(N),N 与最长输入字符串有关。长度不同的输入会把短的部分视作 0,结果长度等于最长输入。
Cluster 中要放在同一个 Slot
BITOP是多 Key 命令。在 Redis Cluster 中,输入 Key 和目标 Key 必须属于同一个 Hash Slot。所以上面的 Key 都使用了相同 Hash Tag:
stadium:{active}:2026-08-12 stadium:{active}:2026-08-13 stadium:{active}:both花括号中的active相同,这些 Key 才能落到同一 Slot。若直接使用不同槽位,会收到CROSSSLOT。
七、BITFIELD:一块灯板还能放多个小计数器
普通 Bitmap 把每个位置看成 1 bit,只能表达开和关。BITFIELD则可以把连续多个 bit 解释成一个有符号或无符号整数。
体育场有四个入口,希望用一个 String 保存每个入口的客流数。每个入口分配 8 bit,也就是一个u8无符号整数,范围 0~255:
| 入口 1:8 bit | 入口 2:8 bit | 入口 3:8 bit | 入口 4:8 bit |一次写入四个计数:
BITFIELD stadium:gates\SET u8#0 120 \SET u8#1 45 \SET u8#2 200 \SET u8#3 0#0表示第 0 个 u8 字段,#1表示第 1 个 u8 字段,比手算 offset 更直观。
让入口 2 增加 5 人,再读取前三个入口:
BITFIELD stadium:gates\INCRBY u8#1 5 \GET u8#0 \GET u8#1 \GET u8#2返回:
50 120 50 200第一项 50 是自增后的入口 2,后面三项依次是入口 1~3。一个BITFIELD命令里的多个子操作按顺序执行,整个命令由 Redis 串行处理。
溢出策略:计数器爆表怎么办
u8 最大只能保存 255。BITFIELD提供三种溢出策略:
| 策略 | 体育场类比 | 行为 |
|---|---|---|
WRAP | 计数器转一圈 | 默认,超过最大值从头绕回 |
SAT | 指针顶在最大刻度 | 钳制在最大或最小值 |
FAIL | 拒绝继续登记 | 返回 nil,不修改字段 |
BITFIELD stadium:gates OVERFLOW SAT INCRBY u8#2 100入口 3 原来是 200,加 100 后不会变成 300,而是停在 255。
只读场景可以使用BITFIELD_RO,让命令意图更明确。
图 5:普通 Bitmap 是一座一盏灯,BITFIELD 则把若干连续灯位解释成小整数。
八、Bitmap 为什么省内存,又为什么可能突然很大
如果要记录一亿个用户是否活跃,理论上的原始位图大小是:
100,000,000 bit ÷ 8 ≈ 12.5 MB这通常比把一亿个整数成员放进 Set 紧凑得多。
但 Bitmap 的空间取决于最大的 offset,而不是亮灯数量。
本文实验只设置 offset 1,000,000:
SETBIT stadium:sparse10000001STRLEN stadium:sparse即使只有一盏灯亮,String 仍然增长到:
125001 bytes因为 Redis 必须把中间空缺位置全部补成 0。就像体育场只有第 100 万号座位有人,你仍然得先修出前面所有座位。
SETBIT的 offset 必须小于 (2^{32}),所以单个 Bitmap 最多 512 MB。第一次直接设置极大的 offset,Redis 需要一次性分配并清零中间空间,可能阻塞服务。官方文档特别提醒,触碰接近上限的位置时要谨慎。
内存估算公式
忽略 Redis 对象头、分配器和碎片时:
字节数 ≈ floor(maxOffset / 8) + 1实际值可用以下命令观察:
STRLEN stadium:sparse MEMORY USAGE stadium:sparseSTRLEN是 String 载荷字节数;MEMORY USAGE还包含对象与内存分配开销,因此通常更大。
哪些 ID 不适合直接当 offset
- UUID;
- 雪花 ID;
- 手机号;
- 跨业务拼接后的超大数字;
- 非常稀疏且跨度巨大的数据库主键。
这些 ID 即使只有几个,也可能把灯板撑得非常大。常见解决方案是:
- 为目标人群建立连续的小编号;
- 按用户区间分段,例如每 100 万用户一个 Key;
- Key 里带日期或业务维度,控制生命周期;
- 稀疏集合直接使用 Set,不要为了用 Bitmap 而硬用 Bitmap。
九、每天一块灯板,TTL 怎么设计
签到或日活常按天建 Key:
stadium:active:2026-08-13 stadium:active:2026-08-14写入后设置保留期限:
EXPIRE stadium:active:2026-08-137776000这里保留 90 天。TTL 作用于整个 String,而不是某一个 bit,Redis 没有“让第 42 个 bit 单独过期”的能力。
如果每个用户的状态需要独立过期,Bitmap 往往不是合适模型。可以考虑 Sorted Set 用时间戳做 Score,或者将过期逻辑放到业务层。
另外,不要只在“Key 第一次创建时”凭感觉设置 TTL,却没有检查异常路径。比较稳妥的做法是把 Key 命名、创建和过期策略封装在同一个业务入口,并监控没有 TTL 的历史 Key。
十、并发安全吗
单条SETBIT、GETBIT、BITCOUNT和BITFIELD命令由 Redis 原子执行,不会出现两个客户端把同一个字节写坏的问题。
但“多条命令组成的业务动作”仍可能有竞态:
SETBIT 返回旧值 0 ↓ 应用准备 INCR 计数器 ↓ 应用在 INCR 前崩溃最后 Bitmap 已经亮灯,计数器却少了一人。Redis 的单命令原子性不等于整个业务流程原子性。
解决办法取决于要求:
- 只需要最终统计:直接对 Bitmap 做
BITCOUNT; - Bitmap 与计数器必须一起改:使用 Lua;
- 涉及数据库、消息队列和 Redis:要做幂等、补偿或事件驱动,不能只靠 Redis 单机事务幻想跨系统原子性。
十一、Bitmap、Set、Sorted Set 怎么选
| 需求 | 更适合的结构 | 原因 |
|---|---|---|
| 连续整数 ID 的是否状态 | Bitmap | 每个成员只占 1 bit,交并差快 |
| 稀疏 ID、UUID、需要枚举成员 | Set | 成员独立保存,模型更自然 |
| 需要时间、分数、排名 | Sorted Set | Score 可排序、可按范围查询 |
| 只要近似去重数量 | HyperLogLog | 极省空间,但不能判断具体成员 |
| 每个对象有多个字段 | Hash | 能保存完整属性 |
| 多个小整数紧凑存储 | BITFIELD | 可指定位宽并原子增减 |
Bitmap 很像体育场的座位灯:特别擅长回答“这个位置亮没亮”和“亮了多少盏”,却不擅长保存观众姓名、票价和入场时间。
十二、生产环境最常见的坑
1. 把稀疏大 ID 直接当 offset
只有一名用户,也可能申请几百 MB。上线前一定要统计最大 ID、密度和增长速度。
2. 以为 Bitmap 是独立类型
TYPE stadium:entry:2026-08-13返回仍然是:
string它可以被普通 String 命令覆盖。若误执行SET key hello,原位图就被替换了。
3. 忘记 BITCOUNT 范围默认按字节
BITCOUNT key 0 3默认是前 4 个字节,即 32 个 bit;想统计前 4 个 bit,要写BITCOUNT key 0 3 BIT。
4. 高频扫描超大 Bitmap
GETBIT是 O(1),但BITCOUNT、BITPOS和BITOP是 O(N)。不要看到“位图”两个字就默认所有操作都恒定时间。
5. 在 Cluster 中让 BITOP 跨 Slot
提前使用统一 Hash Tag,并把目标 Key 也放进同一槽位。
6. 需要列出所有用户,却选了 Bitmap
Bitmap 没有直接返回所有置位 offset 的高层命令。逐位扫描会很笨重;如果核心需求是频繁枚举成员,Set 往往更合适。
7. 每天一个 Key,却忘记估算总量
一亿用户的一天原始位图约 12.5 MB,保存 365 天大约 4.56 GB,尚未计算副本、持久化、对象开销和内存碎片。单 Key 很省,不代表乘以时间后仍然小。
图 6:先看 ID 是否连续,再看最大 offset、命令复杂度、TTL 和 Cluster 槽位。
十三、完整 redis-cli 实验
下面这组命令可以直接复制执行。为了避免污染业务数据,建议在测试 Redis 中使用独立前缀。
# 1. 三名观众进场SETBIT lab:stadium:entry71SETBIT lab:stadium:entry421SETBIT lab:stadium:entry10011# 2. 查询与统计GETBIT lab:stadium:entry42GETBIT lab:stadium:entry99BITCOUNT lab:stadium:entry BITPOS lab:stadium:entry1STRLEN lab:stadium:entry MEMORY USAGE lab:stadium:entry# 3. 两天活跃用户SETBIT lab:stadium:{active}:day111SETBIT lab:stadium:{active}:day131SETBIT lab:stadium:{active}:day151SETBIT lab:stadium:{active}:day181SETBIT lab:stadium:{active}:day231SETBIT lab:stadium:{active}:day251SETBIT lab:stadium:{active}:day261SETBIT lab:stadium:{active}:day281# 4. 留存、累计、变化和流失BITOP AND lab:stadium:{active}:both\lab:stadium:{active}:day1 lab:stadium:{active}:day2 BITCOUNT lab:stadium:{active}:both BITOP OR lab:stadium:{active}:either\lab:stadium:{active}:day1 lab:stadium:{active}:day2 BITCOUNT lab:stadium:{active}:either BITOP XOR lab:stadium:{active}:changed\lab:stadium:{active}:day1 lab:stadium:{active}:day2 BITCOUNT lab:stadium:{active}:changed# Redis 8.2+BITOP DIFF lab:stadium:{active}:lost\lab:stadium:{active}:day1 lab:stadium:{active}:day2 BITCOUNT lab:stadium:{active}:lost# 5. 四个入口的 u8 计数器BITFIELD lab:stadium:gates\SET u8#0 120 SET u8 #1 45 SET u8 #2 200 SET u8 #3 0BITFIELD lab:stadium:gates\INCRBY u8#1 5 GET u8 #0 GET u8 #1 GET u8 #2# 6. 溢出保护BITFIELD lab:stadium:gates OVERFLOW SAT INCRBY u8#2 100BITFIELD_RO lab:stadium:gates GET u8#2本文的隔离实验得到:
checked-in visitors: 3 first lit seat: 7 string bytes: 126 both days: 3 either day: 5 changed membership: 2 day1 but not day2: 1 offset 1,000,000 -> string bytes: 125001 TYPE: string ENCODING: raw ALL ASSERTIONS PASSED这些数字是当前 Redis 8.6.1 实例的实测结果。MEMORY USAGE会受 Redis 版本、分配器和对象大小影响,不应该当成所有机器通用的固定值。
十四、快速判断口诀
最后用一段体育场口诀收尾:
状态只有零和一,ID 连续才适宜;
单点读写看座位,人数统计数亮灯;
两块灯板做运算,留存流失很清晰;
最大编号定空间,稀疏大号要警惕;
单条命令虽原子,跨步业务还得治;
Cluster 多 Key 同槽,TTL 别忘按期清。
Bitmap 的优势不是“任何场景都省内存”,而是在状态简单、编号稠密、统计与集合运算频繁时,用极紧凑的方式解决问题。
选对场景,它是一座高效的电子体育场;选错场景,它也可能只亮了一盏灯,却先修出了几亿个空座位。
参考资料
- Redis Bitmaps 官方文档
- SETBIT 命令
- GETBIT 命令
- BITCOUNT 命令
- BITPOS 命令
- BITOP 命令
- BITFIELD 命令
- Redis Cluster 规范
- 个人小游戏