一文搞懂 Redis Bitmap:把它想成一座万人体育场的座位灯
2026/8/24 11:58:11 网站建设 项目流程

文章目录

    • 一、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-1310011

SETBIT key offset value的 value 只能是01。命令返回的不是新值,而是修改前的旧值

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-13420

offset 从 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 不存在而报错。

GETBITSETBIT都是 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。startend都包含在范围内。

本文用两个字节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-1381

AND:两天都来的人

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:sparse

STRLEN是 String 载荷字节数;MEMORY USAGE还包含对象与内存分配开销,因此通常更大。

哪些 ID 不适合直接当 offset

  • UUID;
  • 雪花 ID;
  • 手机号;
  • 跨业务拼接后的超大数字;
  • 非常稀疏且跨度巨大的数据库主键。

这些 ID 即使只有几个,也可能把灯板撑得非常大。常见解决方案是:

  1. 为目标人群建立连续的小编号;
  2. 按用户区间分段,例如每 100 万用户一个 Key;
  3. Key 里带日期或业务维度,控制生命周期;
  4. 稀疏集合直接使用 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。

十、并发安全吗

单条SETBITGETBITBITCOUNTBITFIELD命令由 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 SetScore 可排序、可按范围查询
只要近似去重数量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),但BITCOUNTBITPOSBITOP是 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 规范

  • 个人小游戏

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询