做签到类需求,大家第一反应大概率是建一张sign_log表:user_id、sign_date、奖励批次,再加个唯一索引。真这么干也不是跑不通,就是性价比低得让人心疼。我在接手某个电商App的每日签到积分玩法时,日活几十万,一个月下来签到记录几千万行,连续签到计算在关系库里绕来绕去,最后直接换成 Redis Bitmaps,整套逻辑退化成一堆位运算命令,内存和响应时间都稳住了。
这篇文章不给你贴完整业务代码,而是把 Bitmaps 的底层原理、Key 设计、命令实操、连续签到算法、跨月边界、以及我踩过的坑一次说透。正在做签到、日活统计、灰度放量的同学可以直接拿去做参考。
1. Bitmaps 到底是什么:把二值状态压缩到极致的数据结构
1.1 先忘掉数据库表,从“一位就是一天”说起
签到这个业务,抽掉所有包装之后,本质是一个用户在某一天只有两种状态:签了(1)和没签(0)。一个用户一个月,最多 31 个二值状态。这种“是/否”标记,就是 Bitmaps 最典型的适用场景。
Redis 的 Bitmaps 并不是一个独立的数据结构,底层就是普通的字符串(String)。只不过 Redis 给字符串额外提供了一套按“位”读写的命令,让你可以精确操作某个比特位。一位只能存 0 或 1,8 位组成一个字节。你可以把它理解成一张只有 0 和 1 的细长纸条,每一位都有一个编号,这个编号叫 offset(偏移量)。
一个字节里有 8 个这样的“开关”,所以同一个用户一个月的签到状态,只需要 4 个字节就能存下:因为 31 位刚好不到 4 字节,Redis 字符串按字节分配,用 4 字节存储。这个压缩比例,任何关系型数据库都做不到。
1.2 必须打熟的 6 个位命令
在动手设计之前,先把 Redis 的位操作命令过一遍,后面所有代码都是这几个命令的组合:
SETBIT key offset value:把 key 对应的字符串在 offset 位置写入 1 或 0,返回该位置原来的值。GETBIT key offset:读出 offset 位置的位值,0 或 1。BITCOUNT key [start end [BIT]]:统计整个字符串里值为 1 的位一共有多少个。start/end 默认是字节下标,Redis 7.0 开始支持加BIT参数按位下标统计。BITOP operation destkey key [key ...]:对多个 key 做位运算,支持 AND、OR、XOR、NOT,结果写入 destkey。BITPOS key bit [start end [BIT]]:从前往后找第一个出现指定值(0 或 1)的位置。BITFIELD key GET/SET uX offset:一次读写连续的若干位,适合把一小段位当成整数一次性取出来。
这里有一个非常关键、也最容易出错的细节:Redis 的“位编号”和我们平时的字节习惯相反。bit 0是字符串第一个字节的最高位(最左边那位),bit 7才是第一个字节的最低位。举一个例子,执行SETBIT k 0 1后,GET k拿到的内容是二进制10000000,也就是0x80;再执行SETBIT k 7 1,这个字节才变成10000001,也就是0x81。很多坑都出在这个位序上,后面第 4 节我会专门展开。
1.3 先算一笔内存账:为什么这个方案便宜得离谱
我做需求前习惯先算账。假设全站 10 万用户,每人当月平均签到 15 天:
- 关系库方案:建一张
sign_log表,每行至少 user_id、sign_date、source 三个字段,算上行头、索引、页填充,单行实际占用 40~50 字节不是夸张。一个月就是 150 万行,一年 1800 万行,光原始数据就接近 1GB,这还没算连续签到查询要做的自关联和窗口函数开销。 - Redis Bitmaps 方案:每个用户每月一个 key,数据部分只有 4 字节。10 万个 key 的 Redis 字典开销按每个 100 字节估,整体也就在 10MB 上下浮动。查询一个用户当月签了多少天,一条
BITCOUNT命令就完事;查连续签到,31 次GETBIT撑死。
再看另一种常见对比:如果业务方要的是“某一天全站有多少人签到”,用 Set 存sadd sign:20260107 userId,1 万用户就是 1 万个成员,每个成员一套哈希表开销;而 Bitmaps 用用户 ID 充当偏移量,直接在sign:20260107这个 key 上用SETBIT sign:20260107 userId 1,1 万用户只占 1250 字节。两者相差几十倍。
| 方案 | 数据量(10万用户/月) | 存储估算 | 连续签到查询 |
|---|---|---|---|
| 关系库 sign_log 表 | 150万行 | 约 1GB+(含索引) | 窗口函数逐行算 |
| Redis Set | 10万成员/天 | 每天几十MB级 | 需额外聚合 |
| Redis Bitmaps | 10万 key x 4B | 约 10MB | GETBIT 循环 31 次即可 |
这个账算完,基本没有理由不用 Bitmaps。但前提是你把 Key 结构和偏移量约定设计对,否则省下来的内存会被各种错误操作加倍吐回去。
2. 签到场景的数据结构设计:Key 怎么定,偏移量怎么算
2.1 Key 命名:把用户 ID 和月份放进一个 Key
签到查询有两个固定维度:哪个用户、哪个月。所以最自然的 Key 结构是:
sign:{userId}:{yyyyMM}例如用户10001在 2026 年 1 月的签到记录,Key 就是sign:10001:202601。这个 Key 内部最多 31 个位,对应 1 月 1 日到 1 月 31 日。
这里有两个实践建议:
如果 Redis 部署的是集群模式,建议把用户 ID 用花括号包起来,写成sign:{10001}:202601。Redis Cluster 的哈希槽只对{}里面的内容做计算,这样同一个用户的所有月度 Key 会落在同一个节点,后续做 Lua 脚本、批量操作都能避免跨节点访问。
另一个建议是把月份固定成六位数字,不要用2026-01或者20260101这种带分隔符的格式。统一的固定位数能保证 Key 字符串长度稳定,更容易排查和扫描。
2.2 偏移量:第 1 天就是 bit 0,别搞混
这是整个方案最容易翻车的地方。我把偏移量约定为:
offset = 当月的第几天 - 1也就是说,1 月 1 日对应 offset 0,1 月 2 日对应 offset 1,1 月 31 日对应 offset 30。这样设计的原因很简单:GETBIT sign:10001:202601 0读出来如果是 1,语义就是“1 月 1 日已签到”,跟人类直觉完全一致。
有人会习惯用“第几天直接当偏移量”,也就是 1 号对应 offset 1,31 号对应 offset 31。这种写法不是不行,但月底那几天容易出边界问题,而且当你用BITCOUNT统计“本月已签天数”时,需要额外判断 31 日是否存在。更严重的是,如果两个开发同学一个用 day-1,一个用 day,整个月的记录都会错位一天,这种问题在联调阶段非常难查。
所以我强烈建议把偏移量计算封装成一个统一函数,比如getOffset(day)内部自动减 1,所有业务代码只传“第几天”,不要到处直接写day - 1。
2.3 为什么按月度拆 Key,而不是按年度或按天
有人会问:能不能一个用户一年一个 Key,365 个位一次性存完?好处是连续签到不用处理跨月,坏处也很明显:
- 按月拆 Key,每个 Key 最多 4 字节,Key 短、生命周期清楚,到期直接过期清理。
- 按年拆 Key,一个 Key 46 字节,看起来也不大,但你想统计“1 月签了多少天”,就必须做
BITCOUNT key 0 30 BIT这种位段统计。在 Redis 7.0 之前,位段统计非常别扭。 - 月度 Key 天然适合做 TTL。比如业务只要求保留最近 6 个月的签到记录,那生成 Key 时直接
EXPIRE key 180 天,完全不用写定时任务清理。
按天拆 Key 也不是不行,但那是“全站用户”维度,比如sign:all:20260107统计当天有多少人签到。个人维度的签到,还是月度 Key 最顺手。
2.4 过期时间到底设多久
月度 Key 的 TTL 设置是个业务决策。我只说一个容易踩的典型事故:有次我把 TTL 设成了 31 天,结果上个月前几天的签到数据在月底报表跑批前就过期了,运营拿到的数据少了一大截。
我的习惯是:业务要求“最近一个季度可查”,我就设 180 天 TTL,覆盖三个月之外还有富余;如果还要求年度累计,那就不要让 Bitmaps 承担历史归档职责,单独用一个哈希 Key 比如sign:stat:10001:2026每天累加一次,成本几乎可以忽略。
3. 实操落地:签到、统计、连续签到一步一步写
3.1 签到接口:一条 SETBIT 就够,还自带幂等
用户点签到,核心逻辑只有一行:
redis-cli SETBIT sign:10001:202601 0 1含义是:用户10001在 2026 年 1 月 1 日签到,偏移量 0 对应 1 号。真正要注意的是SETBIT的返回值——它返回该位原来的值。这个特性可以直接用来做“是否已签到”的幂等判断,省一次GETBIT:
import redis client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=False) def sign_in(user_id: int, year: int, month: int, day: int): key = f"sign:{user_id}:{year}{month:02d}" offset = day - 1 prev = client.setbit(key, offset, 1) if prev == 1: return "already_signed" # 到这里才说明这次签到真正生效,可以发积分、发奖励了 return "ok"这里还有一个细节:签到接口一定要校验 offset 不能是未来日期。如果运营配置错了活动规则,或者客户端传入了一个未来日期,SETBIT会把未来的位也置成 1,月度统计就会多出“未来签到”的脏数据。
3.2 月度日历渲染和签到天数统计
需要渲染整个月的签到日历,最朴素的办法是循环 31 次GETBIT。别觉得循环性能差,一个月最多 31 次位读取,而且可以用 pipeline 在客户端合并成一次网络往返:
import calendar def month_status(user_id: int, year: int, month: int): key = f"sign:{user_id}:{year}{month:02d}" days = calendar.monthrange(year, month)[1] pipe = client.pipeline(transaction=False) for day in range(1, days + 1): pipe.getbit(key, day - 1) bits = pipe.execute() return {day: bits[day - 1] for day in range(1, days + 1)}整月签到次数就更简单了,一条BITCOUNT:
redis-cli BITCOUNT sign:10001:202601返回 1 的位数就是这个月的签到天数。
如果想统计某一周、某一个连续段的签到数,Redis 7.0 之后的写法是:
# 统计 1 月 1 日到 1 月 7 日(offset 0~6)的签到数 redis-cli BITCOUNT sign:10001:202601 0 6 BIT注意末尾的BIT参数,它告诉 Redis start/end 这两个下标按“位”来算,而不是按“字节”。这个问题在旧版本里非常坑,我放到第 4 节详细说。
3.3 连续签到计算:循环倒查是最稳妥的写法
连续签到,尤其是“距离今天连续签了几天”,是签到系统里最常被问到的需求。我推荐的做法是从今天往回倒查,遇到第一个 0 就停:
def continuous_streak(user_id: int, year: int, month: int, day: int): key = f"sign:{user_id}:{year}{month:02d}" offset = day - 1 streak = 0 for i in range(offset, -1, -1): if client.getbit(key, i) == 1: streak += 1 else: break return streak这段逻辑很好懂:今天如果没签到,第一轮GETBIT就是 0,streak 直接返回 0;今天签了,再看昨天,一直往前推。月度内最多循环 31 次,加上 pipeline,对一个 Redis 实例来说几乎是零压力。
有人觉得循环不够“高级”,其实完全没必要担心。真正在性能上有质的区别的是另一种需求:判断“最近 7 天是否连续签到”。这种固定窗口判断,可以用掩码加位运算一次算完:
先构造一个 mask key,把窗口内的位全部置成 1:
# 假设今天是 1 月 10 日,要判断 1 月 4 日到 10 日(offset 3~9)是否都签到 redis-cli SETBIT mask:10001:w2 3 1 redis-cli SETBIT mask:10001:w2 4 1 redis-cli SETBIT mask:10001:w2 5 1 redis-cli SETBIT mask:10001:w2 6 1 redis-cli SETBIT mask:10001:w2 7 1 redis-cli SETBIT mask:10001:w2 8 1 redis-cli SETBIT mask:10001:w2 9 1然后对用户 Key 和 mask 做一次 AND,结果再数 1:
redis-cli BITOP AND week2:10001 sign:10001:202601 mask:10001:w2 redis-cli BITCOUNT week2:10001如果BITCOUNT结果是 7,说明窗口内 7 天全部签到;小于 7,说明中间有断档。这个思路的核心是“只有对方位都为 1,AND 之后才是 1”,相当于用一个外部模板去筛选。
还有一种更简洁的思路,用BITFIELD把连续位段一次性读成整数,再和“全 1”的数比较:
# 读取 offset 3 开始的 7 个位,如果全是 1,得到 127 redis-cli BITFIELD_RO sign:10001:202601 GET u7 3如果返回 127,这 7 位全是 1;返回 0,说明这 7 位全是 0。这个方案适合判断“一个固定窗口是否全勤”,但是不适合算“连续到今天是几天”,因为你需要知道断点在哪个位置。
3.4 跨月连续签到:把上个月尾巴接上
月度 Key 最大的边界问题就是跨月。比如用户 1 月 1 日、1 月 2 日都签了到,那他的连续签到可能是 2 天,但如果他 12 月 30 日、12 月 31 日也签了,加上 1 月 1 日、1 月 2 日,实际是 4 天。
我把跨月逻辑写进同一个连续签到函数里,原则是:当月从今天往前数,如果一直数到 1 号都是 1,再继续去上个月从月末往月初数:
def continuous_streak_cross_month(user_id: int, check_date): year = check_date.year month = check_date.month day = check_date.day key = f"sign:{user_id}:{year}{month:02d}" offset = day - 1 streak = 0 reached_month_start = True for i in range(offset, -1, -1): if client.getbit(key, i) == 1: streak += 1 else: reached_month_start = False break if not reached_month_start: return streak # 当月从 1 号到今天全部签到,继续查上个月 from datetime import date, timedelta last_month_end = date(year, month, 1) - timedelta(days=1) prev_key = f"sign:{user_id}:{last_month_end.year}{last_month_end.month:02d}" for i in range(last_month_end.day - 1, -1, -1): if client.getbit(prev_key, i) == 1: streak += 1 else: break return streak这个函数有一个好处:它不要求“今天必须是 1 号”。哪怕是 1 月 15 日,用户从 1 月 1 日到 15 日连续签满,也会自动去查 12 月的情况,把跨月连续天数接上。业务逻辑里最忌讳的就是“只有月初才处理跨月”这种特判,后面一定会漏场景。
3.5 用 Lua 保证签到和发奖的原子性
签到和“判断是否已签到、写入、发积分”是一个组合操作。如果全靠客户端多条命令,在并发场景下可能出现:两个请求同时读到未签到,都发积分,积分翻倍。
最稳妥的方法是把这个流程塞进一个 Lua 脚本,Redis 会保证脚本内部命令的原子性:
-- KEYS[1] = sign:{userId}:{yyyyMM} -- ARGV[1] = 当天是这个月的第几天(1-based) -- 返回 1 表示签到成功,-1 表示重复签到 local day = tonumber(ARGV[1]) local exists = redis.call('GETBIT', KEYS[1], day - 1) if exists == 1 then return -1 end redis.call('SETBIT', KEYS[1], day - 1, 1) return 1脚本执行成功之后,再根据返回值走客户端的发奖逻辑。即使发奖这一步不在 Redis 里,签到去重这一层已经在 Redis 侧闭环了,不会重复签到。
4. 实操中常见的坑与排查实录
4.1 位序搞错:为什么GET k看到的字节和你想象的不一样
这是一个让我吃过亏的细节。Redis 的位 0 在第一个字节的最高位,不是说“第一个字节的第 1 位”这种直觉。拿SETBIT k 7 1举例,你得到的是一个字节0x01,也就是二进制00000001;而SETBIT k 0 1得到的是0x80,二进制10000000。
这个特性在单独使用GETBIT/SETBIT时问题不大,因为偏移量直接映射到业务语义。最容易翻车的是你在客户端构造二进制字符串然后整体写入。比如想在 offset 7 的位置写入 1,如果你用struct.pack(">B", 1)构造一个大端字节,没问题;但如果你用struct.pack("<B", 1)再误以为“第一位”是 offset 0,最后会发现数据全拧了。
排查这类问题,我的习惯是先把一个已知的小字节写进去,比如SETBIT test 7 1,然后GET test看输出对应预期值,再用这个基准去校验代码里的打包逻辑。
4.2 偏移量“爆炸”:一条 SETBIT 让 Redis 内存飙到数百 MB
这是我在别的团队看到的一次真实事故。有人想用时间戳当偏移量,写了一句SETBIT sign:10001 1735545600000 1,想把 2026 年的某个时间点标记成签到。Redis 收到命令后的第一件事是把字符串扩到偏移量所在的位置,1735545600000 位换算下来接近 217GB,直接触发内存上限,线上 Redis 被打挂。
这里要记住:Redis 字符串长度最大 512MB,所以偏移量理论最大是 2^32 左右,但你实际上不可能真的去申请那么大内存。签到这类业务,偏移量只允许在 0 到 30 之间。正确做法就是在接入层做非法偏移量校验,或者把偏移量计算全部收敛到统一工具函数里,任何人都不允许直接调SETBIT。
还有一个相关坑:BITCOUNT不会因为位没写过就报错,没写过的位默认是 0,这个没问题;但是如果你统计时把未来日期也算进偏移量,BITCOUNT 的结果会包含“未来的 1”,月度签到天数直接失真。
4.3 BITCOUNT 的 start/end 到底是字节还是位
在 Redis 7.0 之前,BITCOUNT key start end的 start 和 end 都是“字节下标”,不是位下标。很多人想统计“第 8 到第 14 位”,写成BITCOUNT key 8 14,结果统计的是 buffer 里第 8 个字节到第 14 个字节,也就是 56 个位,结果直接翻好几倍。
Redis 7.0 加了BIT参数之后,正确的写法是:
# 统计 offset 8 到 14 的位 redis-cli BITCOUNT sign:10001:202601 8 14 BIT如果是旧版本,必须用掩码BITOP AND方案,或者用BITFIELD把位段读出来再自行数 1。项目如果还在用 Redis 6.x,我的建议是把所有位段统计逻辑封装好,明确标注“不要直接用 BITCOUNT 带 start/end”,减少误用。
4.4 补签和未来日期:脏数据是怎么混进来的
补签逻辑本身很简单,无非是再调一次SETBIT把某个历史日期改成 1。但补签有两个限制必须做:
- 只能补最近 N 天,比如 3 天内。原因是运营规则,防止用户无限回溯刷奖励。
- 不能补未来日期。如果用户请求里带了明天的偏移量,而你不校验,明天还没到,那位的值就已经是 1 了。
我的做法是在补签接口里先比较偏移量是否小于等于“今天减 1”,同时大于等于“今天减 3”,两个条件都满足再执行SETBIT。多余校验不花什么成本,但能挡住很多客户端异常。
4.5 Key 数量多起来之后的内存与扫描
Bitmaps 虽然单个 Key 很小,但“每个用户每月一个 Key”意味着 Key 数量等于用户数乘以月数。100 万用户跑一年就是 1200 万个 Key,单个实例可能开始出现内存碎片和慢扫描。
排查时可以用redis-cli --bigkeys扫大键,也可以查INFO memory看碎片率。如果 Key 数量已经大到影响运维,考虑两个方向:一是用 Redis Cluster 按用户哈希分散;二是对超过一年以上的月度 Key 做归档压缩,把历史月份的位数据转成紧凑字符串或者直接删除,只保留年度汇总哈希。
4.6 常见问题速查表
| 现象 | 可能原因 | 检查手段 / 解法 |
|---|---|---|
| 1 号签到查出来是 0 | 偏移量用了 day 而不是 day-1 | 统一走偏移量工具函数 |
| Redis 内存突然暴涨 | SETBIT 偏移量使用了时间戳/大数 | 找出调用方,校验偏移量范围 |
| BITCOUNT 结果明显偏大 | start/end 被当成位下标 | Redis 7 加BIT参数;旧版用 mask |
| 连续签到少算一天 | 没有处理跨月 | 使用第 3.4 节的跨月函数 |
| 月末报表数据缺失 | TTL 设置太短 | TTL 至少 180 天,或归档汇总 |
| BITOP AND 结果不对 | mask 没覆盖到目标位段 | 检查 mask key 的位偏移区间 |
5. 从签到出发:Bitmaps 的更多适用场景
5.1 日活用户(DAU)统计:一天一个 Key,一个用户一个位
Bitmaps 做 DAU 的思路和签到相反:一个固定日期一个 Key,用户 ID 当偏移量。用户当天访问,就执行SETBIT dau:20260107 10001 1,最后用BITCOUNT dau:20260107得到当天活跃用户数。
这个方案的厉害之处在于,1000 万用户一天的活跃标记只需要 1.25MB,一个月 30 个 Key 也才 37.5MB。想算某个月的去重活跃用户数,用BITOP OR把当月所有日 Key 合并一次,再BITCOUNT,几行命令就出了结果。这个需求在关系库里要是没有预先算好的汇总表,跑起来非常痛苦。
5.2 在线状态 / 活跃心跳
在线状态也可以用 Bitmaps 管理,比如online:20260107这个 Key,用户上线时SETBIT 1,下线时SETBIT 0,查询某个用户是否在线就是GETBIT。
但这个场景有一个明显短板:Redis 的位没有“过期时间”,你没法单独让某一个位在一分钟后自动归零。如果客户端异常退出,没走到下线逻辑,这个位会一直停留为 1,在线人数就会虚高。我的处理办法是,服务端收到任意请求时都重新SETBIT 1刷新该用户,同时每天换一个新 Key,旧 Key 设 TTL 自然过期。这样即使有脏位,也只影响当天,不会污染历史统计。
5.3 功能开关与灰度放量
Bitmaps 做灰度发布也很有意思。假设要给新首页做灰度,Key 命名为feature:new_homepage,用户 ID 直接当偏移量。先灰度 10% 用户,就把偏移量小于总用户数 10% 的那批位全部置 1,查询用户是否命中新版本,只需要GETBIT feature:new_homepage userId。
这种做法的好处是:上线瞬间生效,不需要等配置中心刷新;灰度比例调整只需要继续SETBIT更多用户;出问题想快速回滚,把对应位改成 0 或者直接删 Key,比改配置表快得多。而且和海量用户表拆 TPS 的逻辑完全隔离,不会拖累主业务。
5.4 一次性标记和长期防重
凡是“用户是否做过某事”这种一次性判断,也很适合 Bitmaps。比如某用户是否领取过新手奖励、是否参与过某个活动、是否跳过引导页。把不同事件映射成不同的 Key,或者同一个 Key 里不同的位段,就能把原本散落在多张表的标志位全部压进几个字节。
这类标记通常没有严格的时效性,所以 TTL 可以设长一点,或者干脆不设。唯一注意的是 Key 命名要带业务前缀,方便后续按前缀统一清理。
最后补一个个人体会:Bitmaps 不是银弹,它的适用边界非常清晰——状态只有 0/1、数据关心的是“按月/按日”的窗口、能接受用位偏移量换内存和速度。真正把这套方案做好,80% 的功夫都花在 Key 设计、偏移量约定、跨月边界这些不起眼的地方。我前期踩得最多的坑,全在位数和下标错位上,Redis 本身的命令反而没出过问题。如果你也在做签到、日活、灰度这类需求,建议先把开头那 6 个命令打熟,再把第 4.6 节那张速查表贴在工位旁边,能帮你省掉不少值班电话。