Redis Bitmaps实战指南:位图原理、核心命令与高并发场景应用 📅 发布时间:2026/9/18 18:25:13 👁 浏览次数: 两年前我做了一个面向几十万用户的社区App第一个版本上线后运营提了个“简单”需求记录用户每天是否签到还要能拿来做连续签到统计。当时我第一反应是用字符串缓存每个用户的签到记录比如 key 用sign:1024:20241201value 直接存1。结果没几天数据量就让人有点头疼了。后来我翻 Redis 文档开始认真用 Bitmaps 处理这一类位标记型需求内存开销几乎可以忽略不计速度也稳定维持在高位。如果你也需要做签到、在线状态、日活统计这类“每个用户一个布尔值”的事Bitmaps 基本是最优解之一。这篇内容我会把原理、命令、场景和踩过的坑一次性讲透尽量让小白也能照着落地。1. Bitmaps 到底是什么为什么做统计就绕不开它1.1 先算一笔账1000 万用户的存储量到底多大很多人第一次听到 Bitmaps 会被“位图”这个词吓到觉得是什么高深算法。其实它底层就是 Redis 的字符串类型String只是我们对字符串里的每一个二进制位bit做操作。每个 bit 只有 0 或 1 两种状态正好可以表达“是/否”“打卡/未打卡”“登录/未登录”这类布尔数据。怎么算内存一个字节Byte有 8 个比特bit所以 N 个用户对应N / 8个字节。比如 1000 万用户10000000 / 8 1250000字节约等于 1.19MB。再夸张一点1 亿注册用户做日活统计全量位图也只要 12.5MB 左右。这个数量级放在 Redis 里毫无压力。对比一下其他方案如果把每个用户状态存成一条单独的 key-value哪怕 value 只写 “1” 这个字符一个 key 本身就有几十字节的元数据开销1000 万用户就是几百 MB 甚至上 GB。而 Bitmaps 是把 1000 万个状态压缩到了一个连续空间里用偏移量定位用户省下来的内存非常直观。1.2 位运算的基础常识用生活类比快速上手如果你以前没接触过二进制运算可以这样想我们把用户 ID 当作“门牌号”把一条极长的二进制串当作“一排开关”每个开关只有开和关两种状态。Bitmaps 要做的就是在指定门牌号的位置上把开关拨到开或者关然后随时查看某个门牌号的状态或者统计一整排有多少个开关是开着的。一个常见疑问是Bitmaps 能表示多大的偏移量Redis 中字符串最大是 512MB也就是最多有512 * 1024 * 1024 * 8 2^32个 bit。所以 offset 的范围是 0 到 2^32 - 1约 42.9 亿。这意味着理论上你可以给几十亿个用户都分配一个独立的 bit 位。绝大多数业务根本用不到上限但知道这个边界有助于你判断方案是否可行。2. 必须掌握的 6 个核心命令覆盖 90% 的 Bitmaps 场景2.1 SETBIT GETBIT基础的存和读先说最常用的两个命令。# 给用户ID1001 的位设置成 1表示已签到/已登录 SETBIT sign:20241201 1001 1 # 查看用户ID1001 在这一天的状态返回 1 或 0 GETBIT sign:20241201 1001SETBIT 和 GETBIT 的时间复杂度都是 O(1)。要注意偏移量的起点是 0不是 1。也就是说如果用户 ID 是 1存储位置应该是SETBIT key 1 1而不是SETBIT key 1 0这种把用户 ID 当成偏移量时容易产生的混淆。实际业务里我习惯让用户 ID 直接等于 offset这样最简单最多在存储前确认 ID 在业务上是从 1 还是从 0 开始。在写业务代码的时候我会封装一个签到函数伪代码如下import redis r redis.Redis(host127.0.0.1, port6379, db0) def sign_in(user_id: int, date_str: str): key fsign:{date_str} r.setbit(key, user_id, 1) return True def has_signed(user_id: int, date_str: str) - bool: key fsign:{date_str} return bool(r.getbit(key, user_id))这段代码简单直接在小流量验证阶段完全够用。如果 QPS 很高再考虑用 pipeline 批量提交多天的签到状态。2.2 BITCOUNT统计这一批开关有多少个亮着能存数据还不够我们更关心今天有多少人签到了这就是 BITCOUNT 的活。# 统计 key 中位为 1 的数量 BITCOUNT sign:20241201这里有个很隐蔽的坑BITCOUNT 支持 start 和 end 参数但这两个参数是“字节”下标不是 bit 下标。# 统计前 10 个字节中的 1 的数量 BITCOUNT sign:20241201 0 10如果你以为0 10是“只看前 10 个 bit”那统计结果一定会和你预期差很多。因为 10 个字节对应的是 80 个 bit覆盖的用户范围比你想象大得多。所以日常统计整个 key 直接不传 start/end 最稳妥只有当你想按用户段分组统计时才需要精确换算字节位置。2.3 BITOP把多个位图变成交集、并集连续签到、周活跃这类场景往往要聚合多天的数据。BITOP 支持 AND交集、OR并集、XOR异或、NOT非四种逻辑操作。# 计算本周内活跃过的用户7 天并集 BITOP OR weekly:active sign:20241201 sign:20241202 sign:20241203 sign:20241204 sign:20241205 sign:20241206 sign:20241207 # 统计本周活跃用户总数 BITCOUNT weekly:active这里要注意BITOP 的返回结果是结果字符串的长度字节数不是 1 的数量。要求活跃用户数还需要再执行一次 BITCOUNT。AND 操作可以用来找“同时满足多个条件的用户”比如“连续 7 天签到”就是 7 个位图做 AND结果里那些还是 1 的位置就是中奖用户。BITOP AND seven:active sign:20241201 ... sign:20241207 BITCOUNT seven:active2.4 BITPOS快速找到第一个 0 或 1BITPOS 用来查找一个 bit 串中第一个出现指定值的位置。这个命令在业务里比想象中好用。比如社区产品要做“用户日常任务连续完成 3 天发奖励”你可以判断这 3 天并集结果里是否有某个用户没有签到。如果我们要找“今天第一个签到的人”可以这样# 找到第一个 1 的位置 BITPOS sign:20241201 1返回值是偏移量也就是用户 ID。如果没有找到返回 -1。这个特性也可以用来做简单的人员筛选比如找到第一个未签到的用户做定向提醒# 找到第一个 0通常要指定范围 BITPOS sign:20241201 0注意当位图全为 1 时找第一个 0 会返回 -1这是正常现象不代表报错。部分 Redis 版本对 BITPOS 的边界行为有细微差异建议在目标版本上做个小验证。2.5 BITFIELD一次操作多个子段的进阶玩法BITFIELD 是更进阶的命令可以把一个字符串拆成多个任意长度的子段sub-bit然后同时做增减操作。比如我们要给每个用户分配一个 4 bit 的小计数器表示“本月签到次数”可以用 BITFIELD 来批量处理。# 从偏移量 0 开始每 4 bit 为一个用户组把用户ID10 所在位置的值加 1 BITFIELD sign:count:20241201 INCRBY u4 10 1这里的u4表示无符号 4-bit 整数。返回结果是操作后的新值。如果只是想读取可以用 GET 子命令BITFIELD sign:count:20241201 GET u4 10BITFIELD 优点是一条命令完成多个操作减少网络往返缺点是理解和排错成本高业务里很容易把子段的边界算错。我的建议是常规布尔状态场景完全不需要它但如果你要做一个内存极度敏感的计数字段比如限制每用户某类操作次数BITFIELD 是非常值得研究的方向。2.6 这些命令在 Redis 客户端里怎么选型实际开发中不同语言客户端的命令名基本保持一致但有个细节值得注意部分老版本客户端把 bit 操作封装成set_bit、get_bit、bit_count、bit_op命名方式略有不同。以 Java 的 Redisson 和 Lettuce 为例Lettuce 的 API 更贴近原生命令Redisson 做了很多高级封装但偏重量级。我个人的习惯是用 Spring Data Redis 的话直接看RedisTemplate里opsForValue().setBit()这种 API简单灵活如果对性能有极致要求用 Lettuce 原生异步接口会更直接。选型时不用纠结“哪个更好”关键是确认客户端版本和 Redis 服务端版本兼容避免新命令在老客户端里报ERR unknown command。3. 实战场景逐个拆解从签到到日活再到布隆过滤器3.1 场景一用户签到与连续签到统计签到是 Bitmaps 最经典的场景也是我最早落地的场景。设计思路很简单按日期维度建 keysign:{yyyyMMdd}用户 ID 作为 offset签到即置 1。这样单个 key 就是一个日签到位图每月只需要 31 个 key。# 用户 1001 在 2024-12-01 签到 SETBIT sign:20241201 1001 1 # 用户 1001 在 2024-12-02 签到 SETBIT sign:20241202 1001 1 # 判断该用户某天是否签到 GETBIT sign:20241202 1001 # 今天有多少人签到 BITCOUNT sign:20241202 # 本周连续签到 7 天的用户 BITOP AND week:full:2024W49 sign:20241201 ... sign:20241207这里有个关键点连续签到和“某天活跃”完全是两个概念。连续签到的判断不能简单在某天的 bit 里查而是要看连续 N 天的交集结果。如果你想判断“某个用户最近是否连续签到 N 天”你可以在代码里循环调 GETBIT也可以用 BITOP 提前算好。大多数业务场景连续天数不会太大7 天、30 天循环 GETBIT 的性能完全可以接受。实际操作中我推荐签到 key 按自然月拆比如sign:202412然后offset 用户ID * 100 dayOfMonth。这样一条字符串能存一整月签到状态查询某个人某月签了多少天也可以直接 BITCOUNT 做范围统计。当然这种设计对 offset 空间的利用率偏低但在千万级用户以内毫无问题换来的是代码简单、可读性好。3.2 场景二亿级用户日活与留存分析日活DAU统计有几种常见做法用 Set 去重、用 HyperLogLog 近似去重、用 Bitmaps 精确去重。Set 最直观但内存占用高HyperLogLog 省内存但有标准误差约 0.81%不适合需要精确数据的场景。Bitmaps 是三者中“精确省内存”的平衡点。思路是这样的# 每次用户登录在当天的在线位图里将该用户位置置 1 SETBIT online:20241201 1001 1 SETBIT online:20241201 1002 1 # 当天日活 BITCOUNT online:20241201 # 周活7 天做 OR再 BITCOUNT BITOP OR online:week:2024W49 online:20241201 online:20241202 ... online:20241207 BITCOUNT online:week:2024W49 # 月活同理 BITOP OR online:month:202412 online:20241201 ... online:20241231 BITCOUNT online:month:202412留存分析也可以用位图做。比如要分析“12 月 1 日活跃用户中有多少人在 12 月 2 日仍然活跃”就是两个位图做 ANDBITOP AND retention:20241201-20241202 online:20241201 online:20241202 BITCOUNT retention:20241201-20241202算出来的值除以 12 月 1 日的 BITCOUNT就是次日留存率。这个思路可以扩展成 7 日留存、30 日留存核心都是 BITOP 组合多个位图。这套方案最大的优势是时间维度扩展容易。每天一个 key一年也才 365 个 key内存占用远低于存用户明细。唯一的短板是如果要做“每个用户的首次活跃时间”“最近活跃时间”这类个性化查询Bitmaps 不够直接需要辅助别的存储方案。3.3 场景三用 Bitmaps 做一个轻量布隆过滤器布隆过滤器Bloom Filter是一种概率型数据结构它的核心思想是用多个哈希函数把元素映射到位图的不同位置所有位置都是 1 就认为元素“可能存在”只要有任意一个位置是 0 就认为元素“绝对不存在”。这种结构特别适合做缓存穿透防护和黑名单过滤。Redis 官方在 4.0 之后提供了模块化的布隆过滤器但很多老环境并没用上。如果你不想引入新模块用 Bitmaps 手写一个简单版本也不难。Python 示例import redis import hashlib r redis.Redis(host127.0.0.1, port6379, db0) BITS 1000000 # 位图总长度 SEEDS [5, 7, 11, 13] def _hash(item: str, seed: int) - int: # 每次用不同 seed 参与哈希模拟多个哈希函数 h hashlib.md5((str(seed) item).encode()).hexdigest() return int(h, 16) % BITS def bloom_add(key: str, item: str): for seed in SEEDS: r.setbit(key, _hash(item, seed), 1) def bloom_contains(key: str, item: str) - bool: for seed in SEEDS: if not r.getbit(key, _hash(item, seed)): return False return True这里需要小心几个参数位图长度、哈希函数个数、预期元素数量。如果位图太短而数据量太大误判率会迅速上升。一个经验值是预期存储 n 个元素位图长度 m 至少是 n 的 10 到 20 倍哈希函数数量在 4 到 8 个。误判率不能完全消除只能通过参数调低所以不适合对数据一致性要求极高的业务比如“用户账号是否被冻结”不能用误判率方案。布隆过滤器我还用过另一个场景防止重复推送。运营活动每天推送一批消息为了避免同一用户收到重复推送可以把当天已推送的用户 ID 写入一个位图每次发送前查一下 GETBIT。数据量小时用 Set 也行但数据量大后位图的优势会越来越明显。3.4 场景四功能开关、会员状态、黑名单名单除了上亿级的统计Bitmaps 在轻量权限校验里也很有用。你可以把所有用户按 ID 映射到位图某个功能是否开放给某个用户直接看对应位是 0 还是 1。# 灰度发布把用户 1001、1002、1003 加入内测名单 SETBIT feature:new_home_page 1001 1 SETBIT feature:new_home_page 1002 1 SETBIT feature:new_home_page 1003 1 # 用户请求进来判断是否命中灰度 GETBIT feature:new_home_page {user_id} # 运营封禁一批恶意用户 SETBIT blacklist:2024 9527 1 # 判断用户是否被拉黑 GETBIT blacklist:2024 9527这种用法的好处是空间几乎可以忽略而且判断是常数时间适合放在接口入口处做高性能校验。坏处是需要维护“用户 ID 到 offset”的映射关系如果用户 ID 不是连续整数比如用了 UUID这套方案就得额外维护一张映射表反而复杂了。所以这个场景适合用户 ID 是整数自增型的情况。3.5 什么时候不该用 Bitmaps虽然 Bitmaps 很好用但有三类情况我会主动避开。第一数据本身需要存储业务属性的场景。比如签到要记录用户签到地点的经纬度、签到时的身体状态这些不是一个 bit 能表达的应该配合 Hash 或 JSON 字段存储。第二需要频繁做范围查询和复杂条件过滤的场景。比如“找出 30 天没登录的用户然后推送召回”Bitmaps 只能告诉你哪些位置的 bit 是 0但没法直接告诉你这个用户是谁、手机号是什么你需要先拿到位置 ID 再去用户表回查。如果回查一次要 JOIN 多张表性能反而不如直接扫库。第三元素总量很小且增长极慢的场景。比如几百个管理员的功能权限用 Set 或者普通字段足以完全没必要引入 Bitmaps 的复杂度。4. 实战中踩过的坑与排查指南4.1 偏移量从 0 开始还是从 1 开始很多人第一次用 SETBIT 会下意识觉得第一个用户应该是SETBIT key 1 1然后第二个用户SETBIT key 2 1逻辑上没错。但如果你从网上拷了一段代码代码里又把 offset 做了- 1处理两套逻辑一旦混用统计结果就完全对不上了。我的建议是全项目统一“用户 ID 直接作为 offset”并且在代码注释里写清楚“offset user_id”避免后人改动时产生误解。如果你的用户 ID 是从 1 开始的自增主键且你想追求每个用户占一个明确位置也可以统一用user_id - 1作为 offset。关键不是哪一种更好而是必须全链路一致包括写入、读取、统计、BITOP 组合时都不能混。4.2 BITCOUNT 的 start 和 end 是按字节算的这是我第二次强调但确实值得再拿出来说。如果只是统计整个 key不传参数就完事。一旦你想只统计某个 ID 段比如只看 PID 1 到 10000 的用户你可能会写BITCOUNT online:20241201 1 10000这绝对不对。start 和 end 是字节下标不是用户 ID。1 10000实际覆盖的是第 2 个字节到第 10001 个字节也就是 8 到 80000 的 bit 范围和你预想的用户段差很远。正确做法是先算出用户 ID 对应的字节位置比如想只看 0 - 9999 的用户假设 offset userId对应 bit 范围是 0 - 9999字节范围就是0到9999 / 8 1249.875向上取整即 1250所以应该写BITCOUNT key 0 1250。这种换算很容易算错所以我建议普通统计直接不带参数需要按段统计时封装一个工具函数来做换算。4.3 大 key 问题与拆分方案虽然 Bitmaps 单个 key 最大能到 512MB但在 Redis 里维护一个几百 MB 的 key 并不是好主意。大 key 有两个主要风险一是持久化和主从同步时耗时明显拉长二是执行 BITOP 时可能阻塞 Redis 服务。所以我建议把位图按业务维度拆小。以日活为例不要一整年用一个 key。每天一个 key每个 key 只有几十万到几百万 bit内存也就几十 KB 到几 MB非常健康。如果要查月活就用 BITOP OR 把当月每天的 key 合并这种操作放进凌晨的定时任务里跑就行不会影响线上请求。我在项目里是用定时任务提前算好周活、月活的聚合 key然后业务查询直接读聚合结果避免线上高并发时临时做 BITOP。4.4 BITOP 在低版本 Redis 上的阻塞风险BITOP 在 Redis 6.0 之前是非异步的面对大 key 时可能阻塞整个 Redis 实例。如果你的 Redis 版本偏低比如 4.x执行BITOP OR处理几百 MB 的大 key会看到明显延迟甚至触发慢查询日志。应对办法有三个一是升级 Redis 版本二是拆 key 后分批次做 BITOP每次只处理小块然后循环合并三是把聚合计算放到业务低峰期并且用单独的 Redis 实例跑避免影响线上核心链路。4.5 字节顺序与二进制查看工具如果你用 Redis Desktop Manager 这类可视化工具直接看 Bitmaps 的值看到的往往是一串乱码或者十六进制字符串这是正常的。因为一个字节的二进制 01000001 对应 ASCII 表的 “A”8 个 bit 才组成一个字符所以直接按字符读肯定会晕。排查时我习惯用redis-cli配合BITFIELD或者GETBIT逐步检查某个具体 offset 的值基本不用可视化工具直接看位图内容。这里也算一个调试心得出问题时先确认“命令用法有没有问题”再怀疑数据本身最后再怀疑通信链路。5. 我的使用心得什么时候换 HyperLogLog什么时候坚持 Bitmaps很多同学会把 Bitmaps 和 HyperLogLog 搞混它们确实都适合做基数统计但定位完全不同。HyperLogLog 每个 key 固定占用约 12KB无论里面塞多少元素内存都恒定。这个特性决定了它适合“用户量极大、能接受误差”的场景比如统计一个页面的 UV访问用户数标准误差 0.81% 完全在可接受范围内。但 HyperLogLog 不存储具体元素你无法回答“用户 1001 是否访问过”而 Bitmaps 用 GETBIT 可以精确回答这类问题。所以我的选择逻辑很清晰只要统计结果需要反查到具体用户就选 Bitmaps如果只关心总量误差也接受就选 HyperLogLog如果用户量很小百万以内Set 也没有什么问题。另外如果你要做的不是“用户计数”而是“状态标记”比如已经保存了每个用户是否领取过优惠券那就只能是 Bitmaps因为 HyperLogLog 根本不适合存储这种具体布尔值。5.1 内存规划的一个实用公式新接一个需求时我会先预估位图占用内存。公式如下内存(MB) ≈ 用户数 / 8 / 1024 / 1024假设你有 5000 万用户5000万 / 8 / 1024 / 1024 ≈ 59.6MB也就是不到 60MB。如果每天一个 key存一年的日活位图总内存约60MB * 365 ≈ 21.9GB这个量级单靠 Redis 硬扛有点心痛。这时候就要考虑位图压缩Redis 的 String 本身不支持在位级别压缩或者只保留最近 90 天的在线状态更早的数据迁移到冷存储。经验法则是热数据控制在 Redis 内存的 10% 以内否则线上其他业务的缓存空间会被挤压。5.2 用 pipeline 批量写入能省不少时间签到高峰通常在每天早上 8 点到 10 点用户集中打卡。如果在循环里逐条 SETBIT哪怕每条命令只要 0.1ms几千个用户打过来依然会累积成明显的耗时。我习惯用 pipeline 把同一个 key 的多个 SETBIT 合并发送pipe r.pipeline(transactionFalse) for user_id in batch_user_ids: pipe.setbit(fsign:{today}, user_id, 1) pipe.execute()一次 pipeline 可以塞几百条命令网络往返从 N 次变成 1 次延迟改善非常明显。注意 pipeline 不是事务如果中间有失败需要自己做好部分成功的判断。5.3 别把位图当成唯一的永久存储有时候我们会觉得位图内存小、查询快就像把所有用户状态都塞进去。但你别忘了Redis 默认是内存存储虽然有 RDB 和 AOF 持久化但如果你需要长期保留用户行为明细还是建议在底层数据库或数据仓库里留一份原始记录。位图适合做“快速判断”和“实时统计”但它不是一个适合做复杂分析和主数据存储的地方。我见过有团队把所有签到数据只存 Redis结果 Redis 集群迁移或数据丢失后历史签到记录完全无法恢复这种情况真的很被动。折中方案是实时层用 Bitmaps 扛高并发离线层每天定时把位图快照导出到数据仓库两边各司其职。最后分享一个调试小技巧从我个人的踩坑经验来看Bitmaps 遇到问题十有八九不是命令不支持而是对“位”和“字节”的换算懵了。调试时我喜欢用 Python 写一个十几行的自检脚本先往空 key 里 SETBIT 几个已知位置再用 GETBIT 读回来跟预期对比然后用 BITCOUNT 和 BITPOS 验证统计结果。自检脚本跑通了再接业务逻辑能省下很多线上排查时间。import redis r redis.Redis(host127.0.0.1, port6379, db0) key test:bitmap r.delete(key) r.setbit(key, 0, 1) # 第 1 个用户 - offset 0 r.setbit(key, 1, 1) # 第 2 个用户 - offset 1 r.setbit(key, 8, 1) # 第 9 个用户 - offset 8, 会落在第 2 个字节 assert r.getbit(key, 0) 1 assert r.getbit(key, 1) 1 assert r.getbit(key, 8) 1 assert r.bitcount(key) 3, r.bitcount(key) print(bitmap self-check passed)这种自检脚本我也会顺手放进项目的 tests 目录下次 Redis 升级或者换客户端库时跑一遍能及时发现兼容性问题。如果你也正准备把签到、日活、在线状态这类功能从 Set 迁移到 Bitmaps大概率能从上面这些细节里少走很多弯路落地时心里更有底。