两年前我做了一个面向几十万用户的社区App,第一个版本上线后运营提了个“简单”需求:记录用户每天是否签到,还要能拿来做连续签到统计。当时我第一反应是用字符串缓存每个用户的签到记录,比如 key 用sign:1024:20241201,value 直接存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:基础的存和读
先说最常用的两个命令。
# 给用户ID=1001 的位,设置成 1(表示已签到/已登录) SETBIT sign:20241201 1001 1 # 查看用户ID=1001 在这一天的状态,返回 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(host='127.0.0.1', port=6379, db=0) def sign_in(user_id: int, date_str: str): key = f"sign:{date_str}" r.setbit(key, user_id, 1) return True def has_signed(user_id: int, date_str: str) -> bool: key = f"sign:{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 或 1
BITPOS 用来查找一个 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 为一个用户组,把用户ID=10 所在位置的值加 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 最经典的场景,也是我最早落地的场景。
设计思路很简单:按日期维度建 key,sign:{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 日仍然活跃”,就是两个位图做 AND:
BITOP 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(host='127.0.0.1', port=6379, db=0) 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(transaction=False) for user_id in batch_user_ids: pipe.setbit(f"sign:{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(host='127.0.0.1', port=6379, db=0) 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,大概率能从上面这些细节里少走很多弯路,落地时心里更有底。