☰
Redis位图实战:从日活统计到状态标记的内存优化方案
2026/9/29 17:55:02 网站建设 项目流程

如果你做过用户行为统计、日活之类的需求,大概率会遇到这么一个问题:想标记几十万个用户“今天是否点了某个按钮”,用 Set 存用户ID?千万级用户就是一坨内存;用 String 拼 JSON?存取都麻烦,还吃力不讨好。我第一次认真用 Redis 位图,就是被一个日活统计的需求逼的:系统每天上亿次访问,要按天算活跃用户,还要支持按周、按月做集合运算。翻来翻去,最后在 Redis 位图这组命令上找到了答案——一个用户的一个状态只占一个 bit,8 个用户的标记合起来才 1 个字节,存一亿个状态也只要十几 MB。这篇文章把我实际使用 Redis 位图的经验、踩过的坑、内存计算方式和代码示例都整理出来,希望对正在做状态统计、签到、在线标记这类需求的你有帮助。

1. 位图到底是什么:String包装下的二进制数组

1.1 一条SETBIT命令,Redis在背后做了什么

很多人第一次接触位图是从 SETBIT 开始的,看上去很简单:

> SETBIT user_active:20250101 10086 1 (integer) 0 > GETBIT user_active:20250101 10086 (integer) 1 > STRLEN user_active:20250101 (integer) 1261

先别急着往下看,这里有个细节值得停下来想一想:我明明只是把第 10086 个“位”的值改成了 1,为什么 STRLEN 返回了 1261 个字节?

原因在于,10086 是比特位的偏移量,不是字节偏移。8 个 bit 组成一个字节,Redis 要能访问第 10086 个 bit,就必须把底层字符串扩容到至少10086 / 8 + 1 = 1261字节。前面 SETBIT 返回的(integer) 0也不是什么“设置成功”的标记,而是这个 bit 在设置之前的旧值。这一点在写业务代码时很容易被忽略,如果你依赖返回值做判断,一定要搞清楚它的语义是“旧值”。

Redis 位图表面上看起来像一种独立的数据类型,实际上完全不是。它的存储载体就是普通的 Redis String,底层是 SDS(简单动态字符串),你可以直接把它当成一个二进制安全的字节数组来看。SETBIT 做的事情,就是在这个字节数组里定位到指定 offset 对应的那个 bit,然后把它改成 0 或 1,其他 bit 原封不动。如果你设置了一个当前字符串长度之外的 offset,Redis 会先把中间缺失的部分全部填充为 0,然后再设置目标位,这就是为什么 STRLEN 会随着 offset 的增大而自动扩展。

也正因为底层是 String,位图天然继承了 String 的很多特性:可以用 EXPIRE 设置过期时间,可以用 SETEX 在写入时直接带上 TTL,甚至可以塞进管道、事务、Lua 脚本里处理。很多没读过源码的同学会以为位图是单独的一种数据结构,其实你把它理解为“String 上的一组位操作 API”会更准确。

1.2 位图为什么不是独立的数据类型

Redis 官方的数据类型列表里,通常只列 String、Hash、List、Set、ZSet 这五个,位图往往被归到 String 的进阶用法里。这有它的历史原因,也因为位图并没有引入新的底层存储结构,所有操作都是在字符串的二进制位上展开的。

不过“不是独立类型”这件事,既是便利也是坑。便利在于,凡是处理 String 的通用命令,大概率也能用在位图上,比如 TYPE、STRLEN、DEL、EXPIRE、DUMP、RESTORE;坑也在这——你直接GET一个位图 key 的时候,拿到的是一坨二进制乱码,控制台还会警告可能有不可见字符。这是完全正常的行为,不是数据损坏,只要你别用普通字符串的逻辑去解析它,它就没有任何问题。

还有一个比较容易误解的点:位图虽然按“位”来读写,但命令参数里的区间范围往往按“字节”计算。比如 BITCOUNT 和 BITPOS,它们的 start 和 end 参数默认是字节位置,不是 bit 位置。这个细节我在后面专门用一个章节讲,因为这几乎是生产环境里最容易翻车的地方,很多团队写统计脚本统计错了数据,最后追查下来都是这个原因。

2. 高频命令速查与实测:六个命令一个都不能少

2.1 常用命令表与实测示例

我先把位图相关的高频命令整理成一个表格,方便你快速查阅。这里没有把每个命令的全部参数都列出来,只列生产环境最常用的部分。

命令作用复杂度说明
SETBIT key offset value设置指定 bit 为 0 或 1O(1)offset 是 bit 偏移,value 只能是 0 或 1,返回旧值
GETBIT key offset读取指定 bit 的值O(1)返回 0 或 1
BITCOUNT key [start end]统计指定字节区间内为 1 的 bit 数O(N)start/end 是字节位置,不传则统计全 key
BITPOS key bit [start [end]]查找第一个为 0/1 的 bit 偏移O(N)常用于找下界、切割点、判断连续性
BITOP op destkey key [key...]对多个 key 做 AND/OR/XOR/NOTO(N)结果写入 destkey,NOT 只能操作一个 key
BITFIELD key [GET/SET/INCRBY...]按任意长度读写多个 bitO(1) 单操作可以当小型整数数组用,支持溢出控制

光看表格不够,我贴一组实测命令,带上真实输出,这样你下次在命令行里操作时心里有数:

> SETBIT user_active:20250101 10086 1 (integer) 0 > GETBIT user_active:20250101 10086 (integer) 1 > GETBIT user_active:20250101 10087 (integer) 0 # 没设置过的位,默认就是0 > BITCOUNT user_active:20250101 (integer) 1 # 全key范围内只有1个bit是1 > BITCOUNT user_active:20250101 0 0 (integer) 0 # 第0个字节(bit 0~7)里没有任何1 > BITCOUNT user_active:20250101 1260 1260 (integer) 1 # 第1260个字节(bit 10080~10087)里有1

注意上面最后一个命令:10086 落在第 1260 个字节里,所以想要精确统计它,必须写对字节区间。BITCOUNT 不会根据“第几天”“第几个用户”这种业务概念自动对齐,所有区间都是纯字节层面的操作,这是必须刻在脑子里的底层逻辑。

2.2 BITOP:做集合运算,但有前置条件

BITOP 是位图最好用的地方之一,它可以对多个位图 key 做逻辑运算,把结果写到一个新的 destkey。比如做 7 天日活的周活跃用户,思路就是把这 7 天的 key 用 OR 合并到一起:

> BITOP OR user_active:week1 user_active:20250101 user_active:20250102 user_active:20250103 user_active:20250104 user_active:20250105 user_active:20250106 user_active:20250107 (integer) 1261 > BITCOUNT user_active:week1 (integer) 8

这里 OR 的结果是所有 key 里“任意一天活跃过”的用户标记,BITCOUNT 之后就是周活跃人数。要做“连续 7 天都有登录”的留存分析,就把 OR 换成 AND,取交集。

但 BITOP 有两个前置条件需要提前了解。第一,它一次遍历的内存大小取决于参与运算的所有 key 中最大的那个字符串的长度,如果有人在里面塞了一个几百万字节的稀疏位图,一次 BITOP 就可能让 Redis 卡顿几百毫秒甚至更久。第二,destkey 如果已经存在,会被直接覆盖,不会报错提醒你。曾经有人不小心把原 key 和 destkey 写到同一个名字,跑完一看源数据已经被运算结果覆盖了,这个事故我在生产环境真见过。所以每次写 BITOP,先把 destkey 的名字和源 key 区分清楚。

2.3 BITFIELD:把位图当小型整数数组用

BITFIELD 是我个人很推荐的一个命令,很多业务里它比 SETBIT 更贴合需求。举个例子,如果用户在线状态想存四种:离线、在线、忙碌、勿扰,一个用户用 2 个 bit 就能表示。用 BITFIELD 可以这样操作:

> BITFIELD online_status:user:20001 SET u2 0 1 1) (integer) 0 > BITFIELD online_status:user:20001 GET u2 0 1) (integer) 1

u2表示一个无符号 2 位整数,0是 bit 偏移量。SET 操作把第 0~1 两个 bit 设为 1,即二进制01,十进制就是 1。GET 再读出来,就是 1。如果你要表示状态 3(二进制11),直接 SET u2 0 3 即可。

BITFIELD 还能做 INCRBY 原子自增,这在很多计数场景很实用,比如固定位数计数器、状态机上翻:

> BITFIELD counter:user:20001 INCRBY u4 0 1 1) (integer) 1 > BITFIELD counter:user:20001 INCRBY u4 0 1 1) (integer) 2

默认情况下溢出策略是 WRAP,也就是达到最大值后绕回 0,你还可以通过 OVERFLOW SAT 让它饱和在最大值。这一点在抢购、限流、签到连续天数判断里都很有用,因为整个操作是原子的,不需要自己做 GET + SET 的复合操作。唯一需要注意的是 BITFIELD 的 offset 也是 bit 偏移,不是 byte 偏移,多个字段之间要自己算清楚位置,别把第二个字段写到第一个字段的偏移上。

3. 三个真实场景拆解:日活统计、签到判定与在线状态

3.1 日活与留存:把所有用户压进一个bit序列

日活统计是位图最经典的场景。做法是给每一天建一个 key,比如user_active:20250101,用户 ID 直接作为 offset,当天活跃就把对应位移成 1:

> SETBIT user_active:20250101 1001 1 > SETBIT user_active:20250101 1002 1 > SETBIT user_active:20250101 1003 1

当日活跃用户数:

> BITCOUNT user_active:20250101 (integer) 3

如果要算“1 号活跃过,又持续到第 7 天还活跃”的留存用户,用 AND 取交集:

> BITOP AND retain_0107 user_active:20250101 user_active:20250107 (integer) 1261 > BITCOUNT retain_0107 (integer) 1

这段逻辑足够简单,但真正决定它能不能落地的不是命令,而是你的用户 ID 是否适合直接做 offset。如果用户 ID 是连续的、从 0 或 1 开始的自增整数,那直接映射没问题;如果用户 ID 是雪花算法生成的长整数,或者干脆是 UUID,千万别直接塞进去当 offset。否则 Redis 为了支持这个巨大的 offset,会先把中间一大段空间全部填 0,内存直接爆炸。

实际业务里,我习惯的做法是在应用层维护一张uid -> seqId的自增映射表,这个表本身可以放在 Redis 的 Hash 或者持久化数据库里,先把用户 ID 映射成紧凑编号,再用编号作为位图 offset。虽然多了一步映射查询,但换来的是内存量级从“天文数字”降到“用户总量/8 字节”。如果实在不想维护映射表,也可以用分段方案:取用户 ID 的某个哈希值做分片,把用户散到不同的位图 key 上,每个 key 内部用较小的偏移量,牺牲一点统计复杂度,换内存可控。

3.2 连续签到:把时间轴做成位图

日活是“一个用户对应一个状态位”,反过来做签到,可以把“一个用户对应一整条时间轴位图”。比如 key 设计成sign:user:1001,offset 直接用日期序号:用一年的第 N 天作为 offset,N 最大 365,一年只需要365 / 8 ≈ 46字节。

> SETBIT sign:user:1001 50 1 # 第50天签到 > SETBIT sign:user:1001 51 1 # 第51天签到 > GETBIT sign:user:1001 50 (integer) 1

这种设计最大的优势是查询高效:判断某一天是否签到,GETBIT 是 O(1);统计某段时间签了多少天,BITCOUNT 走一遍也很快。真正的坑在于,日期序号天然不是字节对齐的。比如你想统计“本月前 7 天签到了几天”,BITCOUNT key 0 0 统计的是前 8 个 bit,也就是第 1 天到第 8 天,跟业务上的“前 7 天”对不上。

我的建议是能按周切就按周切:每个用户每周一个 key,7 天刚好在一个字节里,BITCOUNT 的 start/end 就很好写。如果不想拆 key,判断连续签到可以用 BITPOS 找最近的一个 0 位:

> BITPOS sign:user:1001 0 0 6 (integer) 52 # 在前7个字节范围内,第一个0出现在第52位

拿到第一个 0 的位置,再对比当前日期偏移,就能算出连续签到的天数。这个方法比循环 GETBIT 高效得多,也是我在线上实际用的方案。

3.3 在线状态:用两个bit存出四种状态

在线状态这类需求,比起日活统计,更适合用 BITFIELD 来做。如果只存“在线/离线”两种状态,1 个 bit 就够了;要支持“在线、离线、忙碌、勿扰”四种状态,2 个 bit 就可以表示。

关键设计是一个在线状态位图 key,用户 ID 作为起始 offset,每个用户占用固定 bit 长度。比如用户 20001 的状态从 offset 0 开始,用户 20002 的状态从 offset 2 开始,以此类推。用 BITFIELD 批量更新一批用户状态时,命令可以一次带上多个子命令:

> BITFIELD online_status:all \ SET u2 0 1 \ SET u2 2 3 \ SET u2 4 2 1) (integer) 0 2) (integer) 0 3) (integer) 0

要注意,这里的第二组 SET 的 offset 是 2,不是 1,因为每个用户占了 2 个 bit。批量操作时最稳妥的办法是让每个用户有一个基础位偏移userId * 2,这样在代码里写循环生成命令时不容易算错。我见过有人用userId * userId这种排列方式做 offset,结果内存和定位都失控,最后改成线性映射才恢复。

一千个用户在线状态只有1000 * 2 / 8 = 250字节,一千万用户也只有 2.5MB 左右,完全可以常驻内存,实时统计在线人数只需要一个全量 BITCOUNT 或者按分片 key 累加,比遍历 Hash、维护 Set 的代价小一个数量级。

4. 内存账本:位图到底省多少,什么时候反而更费

4.1 容量计算公式与一张实测表

位图的内存占用有一个非常简单的公式:如果你最大的 offset 是maxOffset,那么底层字符串长度大约是maxOffset / 8 + 1字节。因为 Redis 会为覆盖到最高位而扩展整个字符串,中间的空白位全部填 0。

按这个公式,我整理了一张表,方便你估算自己业务要多少内存:

用户规模(bit数)占用字节数换算后约等于
100 万125,000 B0.12 MB
1000 万1,250,000 B1.19 MB
1 亿12,500,000 B11.92 MB
10 亿125,000,000 B119 MB

这个量级和 Set 存用户 ID 相比完全是两个世界。Set 里存一个用户 ID 至少几十字节,一亿用户往少了说也是几个 GB,位图只要 12MB 左右。这就是为什么日活统计这种高频、量大、状态简单的场景,位图几乎是标准答案。

4.2 稀疏位图:你把ID当offset的那一刻已经埋雷

位图省内存的前提是 offset 紧凑。一旦 offset 不连续,比如用户 ID 是雪花算法生成的 64 位整数,直接拿它当 offset,问题就来了:就算系统里只有 100 个用户,如果这些用户的 ID 分散在 2^60 以上的高位区间,Redis 为了设置第 100 个用户的 bit,会把整个字符串撑到天文数字般的长度,一个 key 就能让你内存爆掉。

这种“用户很多但 offset 跨度极大”的位图,就是所谓的稀疏位图,它对内存极不友好。解决办法前面提到过,要么维护 uid 到 seqId 的映射,要么把用户按分片规则切到不同 key 上,让每个 key 内部的 offset 保持在一个可控范围内。我自己的习惯是:在方案评审阶段,先拿出计算器算一下“用户最大 ID 除以 8 再除以 1024 再除以 1024”,如果结果超过单 key 的安全范围,就不允许研发直接用原始 ID 写 offset。这个习惯帮我挡掉过好几次线上事故。

4.3 位图、布隆过滤器、Set:什么时候该用谁

不少同学会把位图和布隆过滤器搞混,甚至以为它们可以互相替代。严格说,位图是一个精确的、每个用户对应一个固定 bit 的存储结构,适合状态标记;布隆过滤器则用多个哈希函数把元素映射到多个 bit 上,适合“判断某个元素是否存在”,允许一定误判率。它们底层都是 bit,但设计目标和适用场景完全不同。

Set 则适合真的需要保存完整的用户 ID 列表、需要遍历元素、需要做随机取样的场景,比如给一批用户发券时要从 Set 里遍历出所有 ID。如果你只是统计状态、做集合运算,Set 在千万级以上就很不划算;但如果你要的只是“这个用户 ID 在不在名单里”并且还想要完整 ID,Set 才是正确选择。我给你的建议是:状态、标记、去重计数,优先想位图;元素存在性判断且能容忍误差,用布隆过滤器;必须精确保存元素本身,用 Set。

5. 生产环境踩坑记录:字节区间、大Key阻塞与二进制乱码

5.1 最隐蔽的坑:BITCOUNT的区间是字节不是位

这是位图新手最容易踩的坑,而且踩了之后数据错得很隐蔽。BITCOUNT 里写start end,这个区间是字节区间,不是 bit 区间。比如BITCOUNT key 0 0统计的是第 0 个字节,也就是 offset 0 到 7 这 8 个 bit;BITCOUNT key 0 1统计的是第 0~1 个字节,也就是 offset 0 到 15 这 16 个 bit。

线上真实发生的例子是:有人做签到系统,想说“统计最近一个月签到天数”,直接写BITCOUNT sign:user:1001 0 30,结果数字明显偏大。原因就是他以为 0 到 30 是 31 天,实际上这是 31 个字节也就是 248 个 bit,把两个月的数据都统计进去了。这种事很难通过肉眼发现,因为数字“看起来合理”。

我的解决办法是:所有用到 BITCOUNT 带区间的代码,都必须先在注释里写出“start 字节编号 = 起始天数 / 8”这样的换算过程,并且写单元测试用固定数据验证。如果实在嫌麻烦,就改用 BITFIELD 分段读,或者按周拆 key,让字节区间天然对齐业务区间。

5.2 BITOP千万别在高峰期跑

位图命令里,SETBIT 和 GETBIT 都是 O(1),非常快,但 BITCOUNT、BITPOS、BITOP 都是 O(N) 的,N 是字符串长度。BITOP 尤其危险,因为它要一次读取多个完整字符串做逻辑运算。

举一个我踩过的例子:某个业务存了 5000 万用户的日活跃位图,一个 key 就是 6MB 左右。日活统计用 BITCOUNT 还好,但做周活合并时用了 BITOP OR,需要遍历 7 个 key 各 6MB 数据,一次操作要处理 42MB 数据量。在 Redis 单线程模型下,这个操作执行期间,其他所有命令都得排队。我当时是上午 10 点跑这个任务,结果线上 GET 请求全部被阻塞,持续了好几秒。

后来把方案改成:周活合并放到凌晨低峰期跑,并且把大位图拆成按用户段分片的多个小 key,分别 BITCOUNT 后再在应用层累加,不再依赖单条 BITOP 处理超大 key。如果你想在高峰期做多个 key 的合并,我强烈建议先测试一遍目标数据量下 BITOP 的耗时,再决定能不能接受阻塞风险。

5.3 读取位图变成乱码,别慌

很多同学第一次在客户端里GET一个位图 key,看到返回值是一堆不可打印字符,第一反应是数据写坏了。其实这只是因为位图的字节内容超出了 ASCII 可打印范围,Redis 客户端原样输出了二进制数据,完全正常,数据没有损坏。

关键是要用对 API。如果你用 Java 的 Jedis 或 Lettuce,直接get再new String(bytes)是不可靠的,正确做法是用位图专用命令:

// Jedis jedis.setbit("user_active:20250101", 10086, true); boolean active = jedis.getbit("user_active:20250101", 10086); // RedisTemplate redisTemplate.opsForValue().setBit("user_active:20250101", 10086, true); Boolean active = redisTemplate.opsForValue().getBit("user_active:20250101", 10086);

如果确实需要把整个位图取回来在应用层做复杂的位运算,Java 可以用BitSet.valueOf(byte[])把二进制数据解析成 BitSet,Python 可以用int.from_bytes()转成大整数然后按位移动判断:

import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0) r.setbit("user_active:20250101", 10086, 1) print(r.getbit("user_active:20250101", 10086)) # 1

这里要特别提醒一点:BitSet.valueOf默认按 little-endian 字节序解析,而位图命令的 offset 是从字节的高位开始算还是低位开始算,不同客户端实现可能有差异。最安全的做法是:全程使用 Redis 位图命令读写,不要自己在应用层解析原始字节,避免字节序不一致带来的隐蔽 bug。

5.4 TTL与key维度设计

位图非常节省空间,但也要考虑 key 的生命周期。日活位图这种按天拆分的 key,一定要设置过期时间,否则历史数据会永续堆积。我常用的做法是在写入当天 key 时用EXPIRE user_active:20250101 90让它 90 天后自动清理,或者用定时任务统一删除超过保留窗口的 key。

另外一个设计维度是 key 的粒度。按天拆 key 是做时间维度的统计;按用户段拆 key 是解决超大 key 和内存扩展的问题;按用户维度拆 key 适合签到这种“以用户为查询主体”的场景。拆 key 的粒度直接决定了你后续写统计逻辑的复杂度,我建议一开始就画出所有要查的指标,再反推 key 的设计,而不是等线上数据量起来之后再重构。

顺带提一句,如果你平时在本地用 Docker 跑 Redis 做验证,单机模式足够测试大多数位图功能。需要模拟主从或者集群时再起多节点,开发阶段不必把环境搞得太复杂,重点是先把命令和行为验证清楚。

我个人在实际项目里最深的体会是:位图不是一个可以“大概用用”的数据结构,它的内存效率和 offset 设计强相关,用对了是神器,用错了可能就是事故。每次接到状态统计类需求,我会先花十分钟在纸上画清楚:用户 ID 是什么格式,能不能紧凑映射,单 key 最大 offset 是多少,要不要分片,然后才写第一行 SETBIT 代码。这个习惯帮我避免了好几次“上线前一天才发现内存不够”的尴尬。如果你也想把这套方案落地,可以拿一个小的日活统计场景先练手,把 BITCOUNT、BITPOS、BITFIELD 这几个命令在命令行里跑熟,再上生产你会觉得顺手很多。

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

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

立即咨询