资讯详情

Redis位图实战:从日活统计到状态标记的内存优化方案

📅 2026/9/29 17:55:05 | 华诺云谱 👁 阅读
Redis位图实战:从日活统计到状态标记的内存优化方案
如果你做过用户行为统计、日活之类的需求大概率会遇到这么一个问题想标记几十万个用户“今天是否点了某个按钮”用 Set 存用户ID千万级用户就是一坨内存用 String 拼 JSON存取都麻烦还吃力不讨好。我第一次认真用 Redis 位图就是被一个日活统计的需求逼的系统每天上亿次访问要按天算活跃用户还要支持按周、按月做集合运算。翻来翻去最后在 Redis 位图这组命令上找到了答案——一个用户的一个状态只占一个 bit8 个用户的标记合起来才 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 原封不动。如果你设置了一个当前字符串长度之外的 offsetRedis 会先把中间缺失的部分全部填充为 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 或 1BITCOUNT key [start end]统计指定字节区间内为 1 的 bit 数O(N)start/end 是字节位置不传则统计全 keyBITPOS key bit [start [end]]查找第一个为 0/1 的 bit 偏移O(N)常用于找下界、切割点、判断连续性BITOP op destkey key [key...]对多个 key 做 AND/OR/XOR/NOTO(N)结果写入 destkeyNOT 只能操作一个 keyBITFIELD 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) 1u2表示一个无符号 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:1001offset 直接用日期序号用一年的第 N 天作为 offsetN 最大 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 天”对不上。我的建议是能按周切就按周切每个用户每周一个 key7 天刚好在一个字节里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 MB1000 万1,250,000 B1.19 MB1 亿12,500,000 B11.92 MB10 亿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 在不在名单里”并且还想要完整 IDSet 才是正确选择。我给你的建议是状态、标记、去重计数优先想位图元素存在性判断且能容忍误差用布隆过滤器必须精确保存元素本身用 Set。5. 生产环境踩坑记录字节区间、大Key阻塞与二进制乱码5.1 最隐蔽的坑BITCOUNT的区间是字节不是位这是位图新手最容易踩的坑而且踩了之后数据错得很隐蔽。BITCOUNT 里写start end这个区间是字节区间不是 bit 区间。比如BITCOUNT key 0 0统计的是第 0 个字节也就是 offset 0 到 7 这 8 个 bitBITCOUNT 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[])把二进制数据解析成 BitSetPython 可以用int.from_bytes()转成大整数然后按位移动判断import redis r redis.Redis(host127.0.0.1, port6379, db0) 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 这几个命令在命令行里跑熟再上生产你会觉得顺手很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑