上个月帮朋友排查一个签到活动的数据统计问题,运营要一份"连续签到7天用户名单",他当时的方案是每天往Redis的Set里塞用户ID,活动才跑了半天,内存涨了好几个GB。我扫了一眼代码,问他为什么不用Bitmap,他第一反应是:Bitmap不是图片格式吗?这个对话我碰到过不止一次。很多人一听到Redis Bitmap,脑子里先冒出的是图像处理里的位图,或者文件系统里的分配位图,实际上它远没这么玄——Redis的Bitmap就是String类型的一种位操作视图,底层是一段连续的二进制位,每一位只能存0或1。不管你是刚装好Redis、在Desktop Manager里点来点去的新手,还是已经在生产环境做缓存治理的老手,这篇文章想帮你把Bitmap的原理、命令、场景、容量计算和常见坑一次讲透,让你看完就能直接抄作业。
1. 从一次签到活动说起:Bitmap到底是"图片"还是"位图"
1.1 位数组的底层单位:bit、byte与Offset
Redis Bitmap之所以能让很多人困惑,是因为它不是一个独立的数据结构,而是构建在String之上的视图。String在Redis里是二进制安全的,也就是说它可以保存任意字节序列,而Bitmap就是把这串字节拆成一个个bit来操作。1个字节等于8个bit,这是所有计算机基础知识里最不起眼、也最容易被忽略的事实。
在Redis里,每个bit通过Offset(偏移量)来定位,Offset从0开始计数。最关键的细节是:偏移0对应的是第一个字节的最高位,而不是最低位。也就是说,执行SETBIT key 0 1之后,用GET key拿到的字符串是\x80,因为第一个字节变成了二进制10000000。这个位序问题在跨语言、跨客户端读取时特别容易踩坑,后面我会专门讲。
Bitmap能够表达的最大位偏移是2^32-1,换算过来就是512MB的String。这个上限意味着:如果你的业务ID超过40亿,单个Bitmap key就装不下了。一般情况下,几十亿的用户量级完全够用。
1.2 跟Set相比,Bitmap省在了哪里
为什么签到类场景用Set存用户ID会内存暴涨?因为Set的底层要么是哈希表,要么是整数集合,每个元素不仅要存ID本身,还要存指针、元数据等额外开销。假设一个用户ID在Redis内部平均占用40字节,1000万个用户就是400MB,这还没算内存碎片。而Bitmap的思路完全不同——用ID本身作为位偏移,在位数组里标记0或1。
同样是1000万用户,如果用Bitmap表达从0开始的连续偏移,内存占用是10000000 / 8 / 1024 / 1024 ≈ 1.19MB。看到差距了吗?是几百倍的差距。这不是Bitmap比Set高级,而是两者解决的问题不同:Set适合存"集合元素"这种任意字符串,Bitmap适合存"某个数字ID是否存在"这种标记型需求。
我把这两个方案的差异整理一下:
| 维度 | Set存储 | Bitmap存储 |
|---|---|---|
| 1000万连续ID的内存 | 数百MB(视ID长度) | 约1.19MB |
| 判断ID是否存在 | SISMEMBER,O(1) | GETBIT,O(1) |
| 统计元素数量 | SCARD,O(1) | BITCOUNT,O(N)但位数组很小 |
| 多集合交集/并集 | SINTERSTORE/SUNIONSTORE,生成新Set | BITOP AND/OR,按位运算极快 |
| 适用前提 | 任意字符串元素 | ID必须能映射为紧凑数字偏移 |
这个表基本回答了一个问题:什么时候该用Bitmap——当你的ID本身可以看作连续或接近连续的整数时,用位偏移来标记存在性就是极致省内存的方案。反过来,如果你的用户ID是UUID或者随机字符串,Bitmap就完全用不了。
2. 核心命令逐个拆:SETBIT/GETBIT/BITCOUNT/BITOP/BITFIELD的边界条件
2.1 写入、读取与位查找:SETBIT、GETBIT、BITPOS
Bitmap的常用核心命令不多,但每一条都有容易忽略的边界条件。先说写入命令SETBIT,语法是SETBIT key offset value,Offset必须是0到2^32-1之间的整数,value只能是0或1,传了其他值Redis会直接报错。这个命令的返回值是该Offset位置的旧值,所以你可以利用返回值做"第一次标记"的判断。
读取命令GETBIT同样是O(1),语法是GETBIT key offset,如果key不存在或者offset超出当前字符串长度,统一返回0。这个"越界返回0"的行为很关键,意味着你不需要先判断key是否存在,直接GETBIT就行,逻辑上非常干净。
BITPOS key bit用来查找第一个出现指定bit的偏移位置,比如BITPOS key 1返回第一个1的位置,找不到返回-1。它比遍历整个位数组然后逐个判断要高效得多,常用于找出第一个未标记/已标记的位置。
2.2 聚合运算与批量操作:BITCOUNT、BITOP、BITFIELD
BITCOUNT key [start end]用来统计位数组里有多少个1,也就是统计"被标记的数量"。它支持start和end参数,但这俩参数的单位是字节,不是位!这是高频踩坑点。很多人想统计"前7天签到是否连续",如果直接写BITCOUNT key 0 6,本意是前7位,实际上统计的是前7个字节(56位),结果完全不对。正确做法是把单位换算到字节,或者用BITFIELD取位段。
BITOP是Bitmap的聚合命令,支持AND、OR、XOR、NOT四种位运算,语法是BITOP OP destkey key [key...]。它的典型用途是把多个日活key合并成月活key。执行BITOP OR mau:202501 dau:20250101 dau:20250102 ...,结果是一个新的位数组,每个位只要任何一个源key里有1就是1。
BITFIELD是这几个命令里最强大也最容易被忽略的,它可以在一个命令里对位数组做多个子操作,比如BITFIELD key GET u8 0表示从偏移0开始读取8位,按无符号整数解析;SET u8 8 5表示在偏移8处写入一个8位无符号整数5;INCRBY u16 16 1表示对偏移16开始的16位无符号整数做自增。这个命令相当于把位数组变成了一个可以按位寻址的整数数组,在实现计数器、状态压缩时非常有用。
2.3 命令细节对照表
| 命令 | 语法 | 作用 | 关键注意点 |
|---|---|---|---|
| SETBIT | SETBIT key offset value | 将指定偏移的位设为0或1 | offset上限2^32-1,value非0/1报错 |
| GETBIT | GETBIT key offset | 返回指定偏移的位值 | 越界返回0,不报错 |
| BITPOS | BITPOS key bit | 查找第一个指定bit位置 | 找不到返回-1 |
| BITCOUNT | BITCOUNT key [start end] | 统计1的数量 | start/end单位是字节,不是位 |
| BITOP | BITOP OP destkey key... | 位运算后写入目标key | NOT只支持单key |
| BITFIELD | BITFIELD key GET/SET/INCRBY | 批量位操作 | 注意有符号/无符号与溢出策略 |
说到这里,我想强调一件事:命令本身不难理解,难的是把命令边界条件和业务语义结合起来。比如BITCOUNT的字节单位问题,如果你在面试或者实际开发中说"我想统计前100位里有多少个1",第一反应不该是BITCOUNT,而应该想:要先把这100位截出来。后面场景部分我会演示正确的组合用法。
3. 三类业务场景的落地写法:签到、活跃统计、在线状态
3.1 签到日历:按用户建Key的读写套路
签到场景有两种常见Key设计。第一种是按用户建Key,每个用户每月一个Key,Offset表示日期偏移,比如sign:{userId}:202502,Offset 0表示2月1日,Offset 4表示2月5日。每天用户签到时执行:
# 用户1001在2025年2月5日签到,offset为4 SETBIT sign:1001:202502 4 1 # 判断用户2月5日是否签到 GETBIT sign:1001:202502 4 # 统计该用户本月签到总天数 BITCOUNT sign:1001:202502这个方案的好处是单个Key很小,一个月最多31位,4个字节,Redis会为它分配一个很小的字符串。连续签到判断可以用BITFIELD:
# 读取从offset 3开始,连续7天(2月4日到2月10日)的签到情况 BITFIELD sign:1001:202502 GET u7 3如果返回值是127(二进制1111111),说明这7天全部签到。这里强调一下:这个查询只能判断"这7天是否全签",不能判断"历史最长连续签到",后者需要扫描整月的位并在应用层做一次简单的二进制遍历。
第二种是按天建Key,Offset为用户ID,适合"当天哪些人签到"这种倒排查询。这种方案天然支持集合运算:想找出同时签到多天的人,直接对多天Key做AND。
3.2 日活/月活:按天建Key + BITOP OR的合并思路
日活统计是Bitmap最经典的落地场景之一。每天一个Key,命名为dau:20250205,用户产生活跃行为时以用户ID为Offset写入1:
SETBIT dau:20250205 10001 1 SETBIT dau:20250206 10001 1然后统计当天日活就是一条BITCOUNT:
# 2025年2月5日活跃用户数 BITCOUNT dau:20250205月活怎么算?最直观的做法是把整月的日活Key做OR合并,合并后的Key上每一位就是"这个用户本月是否活跃过":
# 先合并前两个日活key BITOP OR mau:202502 dau:20250201 dau:20250202 # 再把结果与第三天合并,依次类推 BITOP OR mau:202502 mau:202502 dau:20250203最终用BITCOUNT mau:202502得到当月活跃用户数。我在生产环境实测过,一个月31天的数据,一条条合并下来耗时也就几十毫秒级别,比在关系型数据库里SELECT COUNT(DISTINCT user_id) FROM xxx WHERE date BETWEEN ...快了几个数量级。留存分析同理,想算"第1天和第7天都活跃的用户",只需要BITOP AND retain:7 dau:20250201 dau:20250207,然后BITCOUNT。
3.3 在线状态与快速判断:GETBIT的O(1)价值
在线状态看起来很简单,但传统方案往往都有问题。用String存在线状态,每个用户一个Key会给Redis造成巨大的Key数量压力;用Set存在线用户ID,又没法给单个用户设置独立的过期时间。
Bitmap的思路是:只维护一个online:{date}Key,用户ID为Offset,上线时SETBIT置1,下线时SETBIT置0,或者干脆让Key按固定周期过期:
SETBIT online:20250205 10001 1 # 给这个Key设置一个合理的过期时间,比如当天结束 EXPIRE online:20250205 3600判断用户是否在线只需一条GETBIT,O(1)复杂度,不管在线用户量是1000还是1亿,性能都稳定。注意,每次EXPIRE都会刷新过期时间,所以在线心跳只需要重复执行SETBIT即可,不用额外维护心跳表。这个方案的边界在于:如果你需要精确到秒级的在线时长统计,Bitmap不合适,那应该记录事件日志而不是做位标记。
4. 内存账本与容量规划:别让一个Offset毁掉整个Key
4.1 内存计算公式和一次实测数据
Bitmap的内存占用不是由"已标记的位数量"决定的,而是由最大Offset决定的。计算方式特别简单:
分配字节数 = 最大Offset / 8 + 1
结合几个真实数字来看。1000万用户,如果用户ID从0开始且连续:10000000 / 8 / 1024 / 1024 ≈ 1.19MB。但如果某个用户的ID是1亿:100000000 / 8 / 1024 / 1024 ≈ 11.92MB。只写了一个位,就占掉接近12MB,这就是"稀疏偏移的隐形膨胀"。
我在本地环境实测过这个现象。先记录used_memory,连续写入1万个位,内存增量只有KB级别;再执行一条SETBIT sparse:test 100000000 1,内存瞬间涨了约12MB。所有初学者一开始看到这个结果都会愣住,它说明了一个铁律:Bitmap的空间效率强依赖ID的连续性。
如果你想自己复现,可以跑这段命令:
# 记录初始内存 redis-cli info memory | grep used_memory # 连续写入1万个位 for i in $(seq 0 9999); do redis-cli setbit dense:test $i 1; done # 记录第二次内存 redis-cli info memory | grep used_memory # 写入一个超大偏移 redis-cli setbit sparse:test 100000000 1 # 再次查看内存 redis-cli info memory | grep used_memory4.2 稀疏偏移的隐形膨胀:为什么不能随便拿大ID当offset
很多系统里的用户ID是自增主键,但因为历史原因或者多库合并,ID并不连续,可能从100万开始,中间还有大量空洞。这时候如果你直接拿用户ID当Offset,Bitmap的内存会迅速失控。
举个例子,业务里有1000万注册用户,但用户ID分布从0到1亿。如果直接映射,单个日活Key就要约12MB,30天就是360MB,这还没算索引和其他Key。更麻烦的是,这种膨胀是隐性的,因为SETBIT命令不会报错,内存却一直在涨,等到发现时Redis可能已经很危险了。
应对方法有三个。第一,在接入层做一次ID映射,把业务用户ID映射成连续的seq,用一个Hash或String维护映射关系,Bitmap里的Offset就是seq。第二,如果ID号段整体偏移不大,对所有ID减掉一个固定基数后再作为Offset。第三,把用户按uid % N分桶到多个Bitmap Key,每个Key只负责一段范围的Offset,避免单个Key过大。这三个方案没有绝对优劣,取决于你手里ID分布的实际情况。
4.3 512MB上限与分段策略
单个Bitmap Key最大512MB,对应2^32个位。实际生产里我不建议用到接近512MB,因为大Key在Redis里是毒药:主从复制的网络开销、集群槽迁移的耗时、AOF重写的压力都会随着Key体积增长而指数恶化。
我的经验是,单个Bitmap Key降到几十MB以内比较安全。如果确实需要表达超过这个体量的位域,分桶是唯一的出路。比如按uid % 100拆成100个Key,每个Key只有总量的1%。查询时先算模再GETBIT;统计时对100个Key分别BITCOUNT后求和。这个方案可以轻松扩展到百亿级位域,代价是代码和运维上多了几行逻辑。
5. 实战踩坑与面试高频点:大Key、序列化、类型混淆
5.1 大Key排查链路:--bigkeys、阻塞与拆分
Bitmap一旦没做好容量规划,很容易演变成大Key。排查大Key我最常用的工具是redis-cli --bigkeys,它会扫描整个实例并列出体积最大的几个Key。但要注意,--bigkeys是采样式的,不一定能抓到所有大Key,生产环境建议在低峰期跑一遍。
大Key的危害体现在几个方面:BITCOUNT、BITOP这类命令要对整个Key做遍历,在单线程模型下会阻塞其他命令;主从复制时大Key的全量同步会占用巨大带宽;在Redis Cluster里做扩缩容或槽迁移时,大Key的迁移耗时很长,容易触发客户端超时。
如果已经存在大Key,最稳妥的办法是分桶重写:按时间拆Key,比如一天一个日活Key而不是一年一个;或者按ID模数拆Key。拆完之后,应用层需要同步修改读写路径。这里没有银弹,越早拆越省事。
5.2 位序、字节边界与跨端读取:BITFIELD的type设计
位序问题我在开头提过,Redis的Offset 0对应字节的最高位,这跟大多数人的直觉相反。如果你用Java或Python客户端直接GET原始字节,自己动手解析位,极容易搞错顺序。我的建议是:跨语言场景下不要手工解析,统一用BITFIELD ... GET/GETBITS这类Redis服务端命令读取,让Redis把位解析成整数返回。
另一个高频坑是BITCOUNT的start/end按字节定位。假设你有一个120位的Bitmap,想统计"从第37位到第64位之间有多少个1",如果直接写BITCOUNT key 4 8,你会统计到第4字节到第8字节(即第32位到第71位),结果不可控。
正确姿势是用BITFIELD把目标位段截出来,或者先用BITPOS定位边界,让它对齐到字节后再BITCOUNT。我在实际项目里甚至见过有人因为这个问题算错了整整一个月的日活,排查了两天才发现是字节边界没对齐。
| 操作目标 | 错误写法 | 正确写法 |
|---|---|---|
| 统计前7位中的1 | BITCOUNT key 0 6 | BITFIELD key GET u7 0,再统计返回值里的1 |
| 找第1位到第8位是否是11111111 | BITCOUNT key 0 0 | BITFIELD key GET u8 0 判断是否等于255 |
| 找第一个1的位置 | 自定义遍历 | BITPOS key 1 |
5.3 别再和布隆过滤器/文件系统位图搞混
热搜词里有"bitmap中有标记为已使用的未用簇"、"inkscape 的 trace bitmap",这些都不是Redis的Bitmap。文件系统里的位图是磁盘块分配表,Inkscape的Trace Bitmap是图像矢量化功能,它们和Redis Bitmap只是名字里都带着"位图"两个字而已。
真正容易和Bitmap混淆的是布隆过滤器。Bitmap是精确的,每个位对应一个确定的元素;布隆过滤器是近似的,用多个哈希函数映射到同一个位数组,允许误判。面试时如果被问到"统计UV用什么",要从精确和近似两个维度回答:要求精确且ID连续,用Bitmap;量级极大且允许0.81%误差,用HyperLogLog;判断元素是否存在、允许少量误判,用布隆过滤器。
5.4 为什么不建议用Bitmap做分布式锁
有人看到SETBIT key 1 1就以为能做分布式锁,这个思路相当危险。分布式锁的核心语义是"原子地占位",而且要有过期时间兜底。SETBIT没有"只有当前值为0时才写入"的原子条件,两个客户端同时执行SETBIT,互相之间完全无感知。更麻烦的是,锁过期后如何区分到底是"当前持有者释放"还是"锁自然过期",Bitmap无法表达这些状态。
正确做法是用SET key value NX EX seconds,靠Redis单线程命令的原子性保证同一时刻只有一个客户端成功。这里多说一句,Redis分布式锁本身也不是银弹,复杂场景还需要引入Redlock或者更上层的一致性协调方案,但无论如何都不该用Bitmap去实现。
6. 周边生态协同:缓存治理、类型选型与落地建议
6.1 类型选型决策表:String、Set、Bitmap、HyperLogLog、布隆过滤器
日常做技术方案时,我习惯先列一张决策表,把需求、类型、理由写清楚,再动手写代码。BitMap只是Redis工具箱里的一件工具,不是万能钥匙。
| 需求 | 推荐类型 | 理由 |
|---|---|---|
| ID连续的精确去重统计(日活/签到) | Bitmap | 省内存、可BITCOUNT、可位运算 |
| ID稀疏但量级大,要求精确判断 | String/Set | 避免Bitmap超大Offset膨胀 |
| 亿级UV近似统计,允许误差 | HyperLogLog | 固定占用约12KB,PFCOUNT秒出结果 |
| 存在性判断,允许少量误判 | 布隆过滤器 | 比Set省内存,比HLL更适合"是否存在" |
| 需要去重后的集合做交并差 | Set | 提供完整集合语义 |
| 在线状态高频查询 | Bitmap | 一位一个用户,GETBIT O(1) |
这张表的判断顺序通常是:先看是否要求精确,再看ID是否连续,再看是否需要对结果做集合运算。每一步都帮你排除一批选项。
6.2 缓存治理中的TTL、命名与归档策略
在HoRain云上跑过几个项目之后,我发现Bitmap类型的Key在缓存治理里经常被忽略。因为写入太方便了,业务方容易每天生成大量Key而忘记设置过期时间,最终堆积成内存黑洞。
我给团队定的规范是,所有Bitmap Key必须在命名里带上业务域和时间维度,比如online:{yyyyMMdd}、dau:{yyyyMMdd},并明确写入时的过期策略。通过EXPIRE给Key设置合理的TTL,日活Key通常保留30天,月活Key保留13个月,签到Key按用户维度保留一年。同时,用定时任务定期扫描dau:*、online:*这类前缀,把过期Key清掉,或者把超过保留期的Key归档到冷存储后再删除。
6.3 最后的工程建议:先算账再上线
回顾这么多Bitmap的生产实践,我最想强调的经验是:上线前先算账。打开一个Excel,填上你的用户ID最大偏移、日增量、保留天数,用内存公式算出单Key体积、每天新增多少MB、一个月后总量多少。如果发现单Key要超过几十MB,或者整体内存增速超过你能接受的阈值,那就提前设计分桶和ID映射,不要等内存告警了再救火。
另一个经验是,最好在代码里把"ID如何映射到Offset"写清楚,做成一个独立函数,注释里写明距离基数和分桶规则。Bitmap的语义非常不明显,三个月后回看代码,如果不留注释,你很难想起这个Offset到底是几号ID。我在方案评审时见过太多次因为Offset语义不清导致的脏数据,这类问题比内存膨胀更隐蔽、更难排查。
最后一个小技巧:批量写入时可以用Pipeline或Lua脚本,把大量SETBIT合并到一次网络请求里,写入性能会有数量级提升。我之前压测过,单条SETBIT的吞吐有限,但Pipeline一开,十万级别的位写入也就一两秒的事。这个细节在日活类场景刷历史数据时特别管用,省下的时间足够你多跑几轮容量验证。