直接聊 Redis Bitmaps。很多同学第一次听到 Bitmaps 的时候,下意识以为它是 Redis 里的一种特殊数据结构,搞了半天才发现底层就是一个普通的 String。但就是这层“位图”封装,让它在处理大量布尔状态、用户行为统计的时候,比用 Set、String 都要省内存得多。这篇文章就把 Bitmaps 的原理、命令、内存测算和实战场景一次讲透,适合用过 Redis 但没深入研究过位图的人,也适合想用 Bitmaps 做活跃统计、签到功能的同学直接参考。
1. Bitmaps 到底是什么,原理先讲清楚
1.1 它不是独立结构,而是 String 上的位视图
先说结论:Redis 中的 Bitmaps 并不是一种新的数据结构,它还是字符串(String),只是把这串字节当成“位数组”来用。字符串底层是字节数组,每个字节有 8 个 bit,Bitmaps 就是按位去读或写这些 bit。
比如一个空的 key,你执行 SETBIT user:sign:20250101 100 1,Redis 会把这个 key 当成二进制串,确保第 101 个 bit 的位置能存下,然后把那一位设为 1。如果字符串长度不够,它会自动扩容,中间补 0。这个“自动扩容”对应的就是字符串最大 512MB 的限制,所以 Bitmaps 的最大偏移量是 2^32-1,也就是 42 亿多位。
理解这一点很重要。很多人面试被问“Bitmaps 到底存在哪”,如果回答是“一种位图数据类型”,那就错了。它存的还是字符串,SETBIT、GETBIT 这些命令只是在你和底层字节数组之间加了个“位”的读写视角。
1.2 为什么用位能省内存
一个位只有 0 和 1 两种状态,正好能表达“有没有”“是否”“开关”这类布尔值。如果用 Set 存用户 ID,比如一天活跃用户 1000 万,至少得存 1000 万个 ID;但用 Bitmaps 存,不管当天活跃了多少人,只要用户 ID 的最大值确定,占用的内存就是固定的。
假设用户 ID 从 1 到 1 亿,那一个 Bitmaps 只需要 1 亿个 bit,也就是 100000000 / 8 / 1024 / 1024 ≈ 11.92MB。哪怕当天只有 1 个用户活跃,也是 12MB 左右;哪怕 1 亿个用户全部活跃,还是 12MB 左右。这在统计类场景里简直是无解的存在。
当然,这个特点也带来了一个大坑:如果用户 ID 不是从 1 开始的连续数值,或者最大 ID 特别大,比如用户 ID 是雪花算法生成的 64 位长整型,直接拿这个 ID 当偏移量,位图会被拉得极其夸张,甚至直接超过 512MB 上限,瞬间变成一个巨大的 big key。这点后面我会专门讲。
1.3 字节序和位的排布方式
很多人第一次用 Bitmaps 会栽在“第几位”这个概念上。Redis 的位偏移是从 0 开始的,偏移量 0 对应第一个字节的最高位。什么意思呢?
你执行 SETBIT test 7 1,然后 GET test,返回的是"\x01";执行 SETBIT test2 0 1,GET test2 返回的是"\x80"。也就是说,偏移量 7 落在第一个字节的最低位,偏移量 0 落在第一个字节的最高位。
所以在设计日维度 key 的时候,如果你用“第几天”作为偏移量,第 1 天对应 offset 0,第 31 天对应 offset 30,千万别用第 1 天对应 offset 1,那样会让第一天和第二天的位位置错开。看起来只差一位,落地统计时就会乱。
2. 核心命令详解与实操记录
2.1 SETBIT 和 GETBIT,最基础的读写
SETBIT 格式:SETBIT key offset value,value 只能是 0 或 1。如果需要设置多个位,建议用后面的 BITFIELD 一次性提交,否则每个位一条命令,网络开销比较大。
简单演示一下:
127.0.0.1:6379> SETBIT user:sign:20250101 0 1 (integer) 0 127.0.0.1:6379> SETBIT user:sign:20250101 2 1 (integer) 0 127.0.0.1:6379> GETBIT user:sign:20250101 0 (integer) 1 127.0.0.1:6379> GETBIT user:sign:20250101 1 (integer) 0GETBIT 返回的是某个位上的值,0 或 1。如果 key 不存在,Redis 会把它当成一个“全 0 的字符串”来读,所以返回 0 并不是说这位一定被设置过,也可能是 key 根本不存在。这个语义在判断“用户是否活跃”时没问题,因为默认就是不活跃,但如果要区分“从没出现过”和“出现过但值为 0”,Bitmaps 并不适合。
2.2 BITCOUNT 和 BITPOS,统计与定位
BITCOUNT 用来统计整个 key 里值为 1 的位数,也就是“有多少个用户满足条件”。
127.0.0.1:6379> SETBIT online:user 10 1 127.0.0.1:6379> SETBIT online:user 100 1 127.0.0.1:6379> BITCOUNT online:user (integer) 2它支持按字节范围统计,BITCOUNT key start end,注意 start 和 end 是字节偏移,不是位偏移。比如一个位图有 1000 个字节,你想统计第 100 到第 200 字节之间有多少个 1,就写BITCOUNT key 100 200。这个范围统计在做分片统计、跨天合并时很常用。
BITPOS 则是“找到第一个值为 0 或 1 的位置”。
127.0.0.1:6379> SETBIT sign:user:1001 0 1 127.0.0.1:6379> SETBIT sign:user:1001 1 1 127.0.0.1:6379> SETBIT sign:user:1001 3 1 127.0.0.1:6379> BITPOS sign:user:1001 0 (integer) 2这个命令做“连续签到检测”特别有用。比如用户从 1 号开始逐天签到,我可以从今天往前找到第一个值为 0 的位置,就能算出连续签到了多少天。但要注意 BITPOS 在没有 0 时会返回 -1,在有 1 但没找到 0 时也可能返回最后一个字节后的位置,处理边界条件时要小心。
2.3 BITOP 和 BITFIELD,进阶玩法
BITOP 支持 AND、OR、XOR、NOT 四种位运算,结果保存到新的 key。
127.0.0.1:6379> BITOP OR dest:monthly:202501 user:sign:20250101 user:sign:20250102 ... user:sign:20250131这条命令把一个月 31 天的签到位图合并成一个“月签到位图”,只要某一天用户签到了,合并结果里对应位就是 1。配合 BITCOUNT 可以得到这个月至少签到过一天的用户数,也就是月活跃概念。
BITFIELD 是更强大的命令,它支持一次性多个子命令,比如批量设置位、批量获取位,还支持对一段整数进行加减运算。
127.0.0.1:6379> BITFIELD online:user SET u1 10 1 SET u1 100 1 GET u1 10 GET u1 100 1) (integer) 0 2) (integer) 0 3) (integer) 1 4) (integer) 1BITFIELD 里的u1表示无符号 1 位,i8表示有符号 8 位。如果只是做 Bitmaps 的 0/1 操作,u1就够了。但 BITFIELD 的真正价值是可以把多个用户的 0/1 状态塞到同一段位图里,用一次网络请求批量处理,可以明显降低 RTT。
提示:BITFIELD 的返回顺序和子命令顺序一致,但要注意,如果对一个不存在的 key 执行 GET,结果会是 0;执行 SET / INCRBY 才会触发 key 的创建。
3. 内存模型与参数测算,怎么设计才合理
3.1 一个具体的容量计算案例
假设要统计 1000 万用户的日活跃,用户 ID 是连续的、从 1 到 1000 万。那每天的日活 key 占用内存就是:
10000000 bit = 1250000 byte = 1.19MB
是不是很夸张?如果改用 Set 存活跃用户 ID,假设每个 ID 用 8 字节字符串存储,1000 万用户就是 80MB 左右,再加上 Set 本身的结构开销,实际可能超过 100MB。Bitmaps 直接把内存压缩了两三个数量级。
但这里有个前提:用户 ID 必须尽量连续且不大。如果用户 ID 离散且最大 ID 到了 1 亿,那一个日活 key 就是 12MB,虽然还行,但已经比 1.19MB 大了十倍。如果最大 ID 到了 10 亿,就是 119MB 一个 key,这已经是很危险的 big key 了。所以,用 Bitmaps 前一定要做 ID 映射,把长 ID 折算成连续自增 ID,或者用份数分片。
3.2 key 的设计模式:一维还是二维
Bitmaps 的经典设计有两种。
第一种,以用户为主维度,偏移量是天数:
key = sign:user:10001 offset = 第几天 - 1 value = 是否签到这种适合“个人维度的状态记录”,比如用户连续签到 30 天、会员每日领取奖励这种场景。一个用户一个 key,key 的数量等于用户数,但每个 key 很小。
第二种,以日期为主维度,偏移量是用户 ID:
key = active:20250101 offset = userId - 1 value = 是否活跃这种适合“全体用户在某一天的行为统计”,比如日活、月活、在线人数。一个日期一个 key,key 的数量很少,每个 key 大小取决于用户 ID 最大值。
两种模式不能混。我之前遇到过一个需求:既要查“用户最近 30 天签到情况”,又要统计“每天的签到人数”。同事用日期维度存,结果查用户个人连续签到时要循环 30 个 key 再逐位 BITPOS,代码写得很绕。后来改成双写:用户维度 key 给个人查询用,日期维度 key 给运营统计用。多写一次带来的收益非常直观。
3.3 与 Set、HyperLogLog 怎么选
Bitmaps 并不是万能的,选型时要看重三个指标:内存、准确率、操作灵活性。
- 内存:Bitmaps 固定成本由最大偏移量决定;Set 成本由实际元素数量决定;HyperLogLog 固定大约 12KB。
- 准确率:Bitmaps 精确;HyperLogLog 有约 0.81% 的误差。
- 灵活性:Bitmaps 可以做 AND、OR 等位运算,能算“同时活跃”“月活跃”等组合指标;HyperLogLog 只能做 PFCOUNT、PFMERGE,无法精确求交集;Set 可以做 SINTER、SUNION,但内存大。
如果只是统计“日活量级”,HyperLogLog 是偷懒方案;如果还要做留存分析、漏斗分析、组合筛选,那 Bitmaps 明显更强。比如算“今天和昨天都活跃的用户数”,Set 需要两个大 Set 做交集,Bitmaps 只需要两个日活 key 做 AND,然后 BITCOUNT,性能差距非常大。
4. 实战场景落地,从签到到活跃统计
4.1 用户签到与连续签到计算
签到是最经典的 Bitmaps 场景。假设以年为单位设计:
key = sign:2025:10001 offset = 第几天 - 1 value = 1第 1 天是 1 月 1 日,第 365 天是 12 月 31 日。一个用户一年的签到记录最多 365 个 bit,也就是 46 字节左右,非常轻量。
判断用户是否在某天签到:
GETBIT sign:2025:10001 100计算用户从今天往回连续签到了多少天:
# 先把从1号到今天的区间查出来,找到第一个0 BITPOS sign:2025:10001 0这里要注意:如果从 1 号到今天全部为 1,BITPOS 找不到 0,会返回一个不小于当前位图位数的位置,或者 -1。我在代码里一般是这样处理的:
Long zeroPos = redis.bitPos(key, 0); int today = getDayOfYear(LocalDate.now()); int continuousDays = (zeroPos == null || zeroPos >= today) ? today : zeroPos;不过更稳的做法是从今天往回循环 GETBIT,连续签到的天数通常不会太大,循环几十次完全没问题,逻辑也更直观。
4.2 日活、月活与留存分析
日活统计是 Bitmaps 最擅长的场景之一。每次用户产生一次有效访问时,执行:
SETBIT active:20250101 10001 1当天实时日活:
BITCOUNT active:20250101算月活,比如 1 月 1 日到 1 月 31 日之间活跃过的用户:
BITOP OR active:monthly:202501 active:20250101 ... active:20250131 BITCOUNT active:monthly:202501算两天都活跃的用户(留存):
BITOP AND active:retain:20250101_20250102 active:20250101 active:20250102 BITCOUNT active:retain:20250101_20250102用位运算做留存分析,比写复杂的 SQL 或者遍历 Set 高效得多。实际项目中我会把每天跑批合并的任务放到凌晨低峰期做,然后用定时任务生成月活 key、周活 key,运营直接查结果 key,避免实时 BITOP 对多个大 key 造成性能压力。
注意:BITOP 是阻塞命令,如果 key 很大,多个 BITOP 同时执行会导致 Redis 单线程被卡住。我建议控制在几个 GB 以下,并且用异步任务在低峰期执行。
4.3 在线状态统计与心跳续期
在线状态也可以直接用 Bitmaps 表达。以 uid 为偏移量:
SETBIT online 10001 1统计当前在线人数:
BITCOUNT online用户下线或心跳超时后:
SETBIT online 10001 0但这里有一个实际问题:如果一个用户断开连接,你不会立刻收到离线通知,必须有心跳机制。通常做法是用户每隔 5 分钟上报心跳,最后一次心跳超过 15 分钟就认为离线。这个逻辑如果用 Bitmaps 做,有两种方案:
方案一:每次心跳时对某个“最近活跃 key”设置位 1,后台任务扫描偏移量并清位。但扫描所有用户位成本高。
方案二:用 redis 的 key 过期辅助。比如在线状态 key 按 5 分钟一个槽位来存,分散时间维度,过期时间设置半小时。不过这样统计复杂度上来了。
我的个人经验是:如果是百万级以下用户,用 Bitmaps 做在线状态是够的;但到了千万级,频繁 SETBIT/清理位会比较吃力,不如用 Redis Hash 存心跳时间,后台惰性判断,或者用 Redis 的 Stream。在线状态这种实时性要求高的场景,不要为了炫技强行用 Bitmaps。
4.4 功能开关与权限控制
Bitmaps 还能做功能开关。比如一个 SaaS 系统要给某些租户开启某个新功能,传统做法是存白名单列表,或者给每个租户加一个布尔字段。租户数量多、字段多的时候,位图更简洁。
假设租户 ID 映射为 1 到 1000,功能点有三级:
key = feature:newDashboard offset = tenantId value = 1 或 0查询某个租户是否可见:
GETBIT feature:newDashboard 888批量设置多个租户:
BITFIELD feature:newDashboard SET u1 1 1 SET u1 2 1 SET u1 3 0如果功能判断放在接口调用链路的非常热的位置,你可以把整个位图加载到本地缓存,比如用 Java 的 BitSet 解析 Redis 返回的字节数组,这样本地内存判断极快,不会每次请求都打 Redis。这个思路本质上是把 Redis 的 String 字节序列直接映射成 Java BitSet,在低延迟要求下非常实用。
4.5 用 Bitmaps 实现简易布隆过滤器
布隆过滤器也是位图的一种应用。常见的 RedisBloom 模块提供了 BF.ADD、BF.EXISTS 等命令,但在没有模块的情况下,可以用 Bitmaps 手写一个简易版。
思路是:
- 对一个输入元素做多次哈希,得到多个位偏移量。
- 写入时,把这些位都设为 1:
SETBIT bloom:key h1 1、SETBIT bloom:key h2 1。 - 判断时,检查这些位是否全部为 1,如果有一个是 0,则元素一定不存在;如果全是 1,则可能存在(有误判)。
我做过一个商品详情页防重复推送的场景,百万级商品量,用三个哈希函数,位图大小设置为元素数的 10 倍,误判率可以压到 1% 以下。不过如果项目里已经能装 RedisBloom,直接用模块更省事;手写位图适合“不想引入额外模块”或者“只需要一次性过滤”的场景。
// 伪代码 long hash1 = Math.abs(key.hashCode()); long hash2 = Math.abs(key.hashCode() * 31 + 7); long hash3 = Math.abs(key.hashCode() * 131 + 17); long offset1 = hash1 % bitmapSize; long offset2 = hash2 % bitmapSize; long offset3 = hash3 % bitmapSize; redis.setBit(bloomKey, offset1, true); redis.setBit(bloomKey, offset2, true); redis.setBit(bloomKey, offset3, true);要注意,手写布隆过滤器不支持删除,因为多位共用一个位,删掉一个元素可能会误删其他元素。如果需要删除能力,请用计数布隆过滤器,不要用普通位图硬做。
4.6 游戏签到、任务进度展示
游戏里也有大量 0/1 状态:今日是否签到、是否领取首充奖励、是否看过引导动画、是否通过某个主线关卡。这类状态用一堆字段存很浪费,用 Bitmaps 按配置表映射就很方便。
比如新手引导配置表有 20 个步骤,用户进度:
key = guide:10001 offset = 步骤ID value = 1表示已完成判断用户能否进入下一步,直接查当前步骤的 bit 即可。这种场景的好处是配置变更很灵活,新增一个引导步骤,不需要改表结构,只需要新分配一个 offset。
5. 容易踩的坑,以及问题排查实录
5.1 偏移量过大导致的 big key
这是 Bitmaps 实战里最坑的问题,没有之一。如果你的用户 ID 是一个 10 位以上的整数,甚至是一个雪花算法生成的 long,直接拿来做 offset,Redis 会立刻分配出巨大的字符串。
我曾经在一个活动里看到同事用用户手机号做偏移量,手机号是 11 位,最大偏移量 19999999999 左右,已经超过 Bitmaps 允许的 2^32-1,SETBIT 直接报错。就算不超,光一个 key 就可能接近 512MB,这就是灾难。
应对办法:
- 先用一个自增映射表,把业务 ID 映射成从 1 开始的连续 ID。映射关系可以放 Redis Hash,也可以放数据库。
- 如果业务上无法接受映射表的额外存储,考虑用日期维度而不是用户维度来切分,并按用户 ID 的模做分桶。比如
active:20250101:0、active:20250101:1,每个桶只负责一部分用户,控制单个 key 的大小。
5.2 BITCOUNT 和 BITPOS 的范围参数是字节
很多人在写命令时把 start、end 当成位偏移量,结果统计结果完全不对。Redis 文档里写得很清楚,这两个参数是字节下标。如果位图有 1000000 位,也就是 125000 字节,你想统计前 1000 位的 1 的数量,应该用:
BITCOUNT key 0 124因为 1000 位 = 125 字节。写代码时建议写个工具方法,把位区间换算成字节区间,避免手算出错。
5.3 BITFIELD 的溢出控制
BITFIELD 的 INCRBY 子命令默认使用 wrap 溢出模式,也就是溢出后重新循环。比如把 u8 位的 255 再加 1,会变成 0,而不是报错。在做计数器类操作时,这个默认行为可能让你困惑。如果希望溢出时报错,需要设置:
BITFIELD key OVERFLOW FAIL INCRBY u8 100 1虽然 Bitmaps 场景下大多数用的是 SET/GET,但一旦用了 INCRBY 做批量统计,还是建议显式声明 OVERFLOW 策略,避免隐蔽 bug。
5.4 序列化和客户端解析问题
Redis 的 Bitmaps 本质上是一个二进制字符串,用 Redis Desktop Manager 或者 Java 客户端查看时,可能显示成一串乱码。很多初学者会以为数据写坏了,其实只是客户端把二进制内容按文本渲染了。
如果要把整个 Bitmaps 读出来放到 Java 的 BitSet,需要注意编码。Redisson 的 RBitSet 封装了大部分逻辑,直接用 getBit/setBit 即可;如果用 Jedis,得到的是 byte[],可以这样转换:
byte[] raw = jedis.get(key.getBytes()); BitSet bits = BitSet.valueOf(raw);这个操作会把字节数组按小端序转成 BitSet,跟 Redis 内部的位布局不一定完全对应,自己要确认偏移方向。
5.5 持久化和主从环境下的注意事项
Bitmaps 底层是 String,所以 RDB、AOF 都照常支持。不过大位图在持久化时会有比较大的写放大,AOF 重写也会对二进制内容进行压缩,如果位图太大,建议开启 AOF rewrite 的自动触发阈值,并且监控 Redis 的 fork 耗时。
主从复制环境下,Bitmaps 的 key 如果很大,主从全量同步时会占用大量带宽。我的建议是:
- 对 Bitmaps 的 key 设置过期时间,比如日活 key 保留 60 天,月活 key 保留 13 个月。
- 不要把所有历史位图都堆在 Redis 里,长期不用的导到冷存储。
- 使用 docker 部署时,给 Redis 容器配置合理的内存上限,防止一个大位图把容器内存打满。
顺便说一句,如果要在本地复现本文的命令,用 Docker 是最快的:
docker pull redis:7.2 docker run -d --name redis-bitmap-demo -p 6379:6379 redis:7.2 redis-cli -h 127.0.0.1 -p 6379生产环境主从模式也可以直接拉镜像搭,但位图操作本身没有主从逻辑差异,重点还是容量规划和高可用。
5.6 判断“位为 0”和“key 不存在”时的认知陷阱
GETBIT 不存在的 key 会返回 0,BITCOUNT 不存在的 key 也会返回 0。这在统计上没问题,但如果你要做“今天未签到用户提醒”,你对未签到用户批量操作时,需要注意不能把 key 不存在等同于“全部未签到”就去 SETBIT 0 刷一遍。因为 SETBIT 一个偏移量为 0 的位,也会创建 key。大量这样的写入会导致很多无意义的空 key,占用 Redis 内存和 keyspace 遍历时间。
如果一定要区分,可以在写入位图时同时维护一个存在性标记,或者查询 key 是否存在:
EXISTS active:20250101在批量任务里,先 EXISTS 判断再 BITCOUNT,可以省下很多无效查询。
6. 实际项目里我的一些体会
从第一次在职级系统里用 Bitmaps 做用户权限开关,到后来做千万级日活跃统计,我的整体感受是:位图这东西,原理特别简单,但真正用好的关键不只是命令熟,而是数据建模。
建模时先问自己三个问题:我的业务 ID 是多少位的?我的查询维度是用户还是日期?我对精度要求是 100% 还是 99% 都行?如果三个答案分别是“长整型”“用户维度”“100%”,那 Bitmaps 未必是最优解,你可能需要换 Hash 或者普通 String 加字段。如果答案是“连续 ID”“日期维度”“100%”,那 Bitmaps 几乎就是最佳选择。
另外,生产环境不要直接在业务代码里堆一大堆 SETBIT、BITOP,可以封装一个 BitMapService,对外暴露 markActive、countActive、mergeActive 这类方法,内部统一处理 key 命名、ID 映射、过期时间。这样后续如果要把部分场景迁移到 HyperLogLog 或 RedisBloom,改动成本很低。
最后分享一个小技巧:排查 Bitmaps 问题时,不要只看 redis-cli 的 GET 结果,多用STRLEN key看字节长度,用DEBUG OBJECT key看 serializedlength。如果 serializedlength 比预期大很多,多半是存在高位偏移,及时用 BITPOS 定位到第一个 1 的位置,检查偏移量是否异常。这个习惯能帮你提前发现 big key 风险,避免等到线上告警才手忙脚乱。