先问一句:你是不是也用 Redis 做过用户签到这种需求?如果是,那你一定纠结过到底该用 Set 还是 String 存日期,直到某天在redis-cli里敲了一行SETBIT,发现三千万用户一年签到居然只用几十 MB 内存,才明白 Redis Bitmaps 这个东西到底有多香。
我最早接触 Bitmaps 是在做一个内部运营活动,当时签到人数从几千涨到几十万,Set 存储方案的内存直接顶不住了,后来一晚上改成 Bitmaps,内存降了两个数量级。这篇文章就把这几年的实战经验整理出来,从位图原理、命令细节,到签到系统的 key 结构设计、连续签到计算、常见坑点,一次说透。适合刚接触 Redis 位图的同学,也适合已经用过但想优化现有签到方案的人。
1. 搞懂 Bitmaps 之前,先理解"位图"这两个字
很多人听到"位图"第一反应是图片格式,但在 Redis 里,Bitmaps 并不是一种独立的数据类型,它本质上是基于 String 类型做的一种"位级操作"抽象。一个字节有 8 个 bit,每个 bit 只能存 0 或 1,Bitmaps 就是让你直接操作这些 bit 的接口。
1.1 一个 bit 多少钱?内存账先算明白
存储一个用户一年的签到记录,如果用 Set 方案,大概长这样:
SADD sign:2024:user:10001 2024-01-01 SADD sign:2024:user:10001 2024-01-02每个日期字符串算 10 字节,加上 Set 的 dict 结构和指针开销,一条记录差不多 50~80 字节。一年签到 200 天,就是 10~16 KB。一万个用户就是 100~160 MB。
而 Bitmaps 方案,key 只存一个二进制串,每个 bit 对应一天。365 天只需要 365 bit,也就是约 46 字节。一万个用户,一年全量数据,不到 0.5 MB。这个账一算,方案选哪个基本不用犹豫。
当然,Bitmaps 不是没有代价。它的空间是按照最大偏移量预占的,你SETBIT key 364 1表示要存第 365 个 bit,Redis 会一次性把前面的 364 个 bit 全部补零。所以如果偏移量很稀疏(比如 offset 到了几千万),反而会浪费大量内存。这也是后面要讲的"key 粒度设计"问题的由来。
1.2 Redis 到底把位图存在哪
Bitmaps 操作会落到 String 类型上。当你执行:
SETBIT sign:2024:user:10001 0 1Redis 内部会先查找sign:2024:user:10001这个 key,如果不存在,就创建一个 String 类型的 value,再把这个 value 的第 0 位(bit offset 0)置为 1。你可以随时用STRLEN查看这个 key 占了多少字节。
有个细节很多人不知道:对某个偏移量执行SETBIT后,Redis 返回的是该 bit 原来的值,不是操作后的值。这个返回值在分布式锁、状态切换场景里非常有用,后面签到时也会用到。
注意:Bitmaps 的上限是 2^32 个 bit,也就是 512 MB 的 String。对签到来说完全够用,但如果用来做大规模布隆过滤器,要留意这个边界。
1.3 一个字节的顺序问题
这里有个容易踩坑的细节:Redis 的 bit 位顺序是"从左到右"编号的,也就是一个字节内部,第 0 位是最高位(MSB)。
举个例子,执行:
SETBIT mykey 0 1 SETBIT mykey 1 1此时mykey的二进制是11000000,对应的十进制是 192。你用GET mykey拿到的是\xc0,而不是\x03。同样,执行SETBIT mykey 7 1才是把最低位置 1。
理解了 MSB 顺序,后面写解析脚本、用BITFIELD做多 bit 读取时才不会懵。
2. 核心命令逐个拆解,每一个都配实际案例
Bitmaps 相关的命令不多,但每个都有自己的脾气。我在下面把这几个命令的参数、返回值、典型场景和容易出错的地方全部列出来。
2.1 SETBIT 与 GETBIT:最基本也是最重要的两个
语法:
SETBIT key offset value GETBIT key offsetvalue只能传 0 或 1,传其他值直接报错。offset 从 0 开始,最大 2^32 - 1。
# 用户 10001 在 2024-01-01 签到,假设这天是第 0 位 SETBIT sign:2024:user:10001 0 1 # 返回 0,说明之前没签到过 GETBIT sign:2024:user:10001 0 # 返回 1常见的错误用法是搞混 offset 顺序。比如拿到一个日期2024-06-15,你需要先算出它是当年的第几天,再做SETBIT。如果漏了闰年的 2 月 29 日,全年的 offset 都会差一天,而且用户完全感知不到,只有月底统计时才发现数据对不上。
我的习惯是:用一个工具函数统一把日期转成 offset,核心逻辑如下:
from datetime import date def date_to_offset(dt: date) -> int: """计算 dt 是当年的第几天,基数为 0""" start = date(dt.year, 1, 1) return (dt - start).days如果要支持跨年,也简单,key 里本来就带着年份,比如sign:2025:user:10001,每年重新开始计数。
2.2 BITCOUNT:统计一个 key 中有多少个 1
语法:
BITCOUNT key [start end [BYTE|BIT]]不加参数,统计整个 key 中 bit 为 1 的个数。这是签到场景最常用的命令之一,用户一年的签到总天数其实就是BITCOUNT的结果。
BITCOUNT sign:2024:user:10001 # 比如返回 132,代表这一年签到 132 天BITCOUNT支持传区间,但这里有个历史坑:在 Redis 7.0 之前,start和end默认单位是字节,很多人在网上抄的命令直接传BITCOUNT key 0 0想统计第一个字节,结果实际统计的是整个字符串的 0~0 字节,并没有报错,但是结果和预期对不上。
Redis 7.0 之后增加了BYTE和BIT参数,建议写清楚单位。
如果想统计某个月或者某段时间的签到天数,先算出这一段时间在 key 中对应的字节区间,再BITCOUNT key start end BYTE。比如第 0~30 天,偏移量 0~30,对应的字节范围是起始字节0 * 1左右,但注意 BITCOUNT 的区间是按字节对齐的,如果起止日期不在字节边界上,需要先BITFIELD切片或者手动处理对齐,我在第 3 章写了一段解决这个问题的代码。
2.3 BITPOS:找第一个 0 或 1 的位置
语法:
BITPOS key bit [start [end [BYTE|BIT]]]BITPOS key 0找第一个为 0 的 bit。这在连续签到场景中非常有用:找到用户从某个起点开始,第一个没有签到的位置,就是连续签到中断的那一天。
BITPOS sign:2024:user:10001 0 # 如果返回 31,说明第 32 天开始断签 # 那连续签到天数就是 31 天要注意:如果整个 key 全是 1,BITPOS key 0返回 -1,此时连续签到就是"从起点一直到现在都签到了",要特殊处理。如果整个 key 全是 0,BITPOS key 1也返回 -1。
还有个精度问题:BITPOS 和 BITCOUNT 一样,区间默认按字节处理。你传BITPOS key 0 0 29它处理的是字节 0~29,也就是 bit 0~239。想做"第 30 天到第 60 天之间第一个断签点",必须把日期偏移量换算成字节区间,再结合GETBIT边界校准。
2.4 BITOP:位运算,多 key 合并的利器
语法:
BITOP operation destkey key [key ...]operation支持AND、OR、XOR、NOT。签到场景里最常见的是OR:算一个团队或一个活动周期内,某个用户是否"至少签到过一次"。
# 把 2024 年上半年两个 key 合并 BITOP OR sign:half:user:10001 sign:q1:user:10001 sign:q2:user:10001BITOP结果会写入 destkey,destkey 不存在会自动创建。它不会修改源 key,这一点可以放心。
有个性能点要提醒:BITOP 是同步阻塞的,如果对一个大 key(几百 MB)执行 AND/OR,会阻塞 Redis 主线程。生产环境要注意控制 key 的大小,或者在低峰期执行。我见过有人把布隆过滤器的两个大 key 做 OR,直接把 Redis 卡了 2 秒,教训深刻。
2.5 BITFIELD:进阶选手的瑞士军刀
语法:
BITFIELD key [GET encoding offset] [SET encoding offset value] [INCRBY encoding offset increment] [OVERFLOW WRAP|SAT|FAIL]BITFIELD让你可以在一个命令内完成多个子命令操作,还可以使用INCRBY实现对 bit 计数器自增。它支持u8、i16这样的编码格式,比如:
# 把第 0~7 位(8 bit)作为无符号 8 位整数读取 BITFIELD user:288:sign GET u8 0这个命令在签到场景中主要用于:批量读取多个字节,减少 RTT。比如你想查看用户最近 31 天的签到情况,可以直接一次读出 4 个字节:
BITFIELD sign:2024:user:10001 GET u32 0返回一个十进制的整数,转成二进制后,"1"的位置就是签到的日期。这个操作比循环 31 次GETBIT高效得多。
还有一个非常好用的自增场景:用位图做"单用户每日次数限制"时,把 bit 扩展成 4 bit 计数器,用INCRBY实现一天内的次数累加,同时不破坏其他用户的位图布局。这个属于进阶玩法,签到场景不常用,但值得了解。
3. 用户签到系统实战:从需求拆解到代码落地
理论知识讲完,下面进入真正的实战环节。我将从需求梳理开始,手写一个可落地的签到模块,包含 key 设计、签到打卡、查询日历、连续天数、团队统计等几个核心接口。
3.1 先把需求拆清楚,再谈技术选型
做签到功能,第一件事不是写代码,是把需求列表列出来。我归纳了最常见的 7 个需求点:
- 用户每天只能签到一次,重复签到要提示"今日已签到"
- 用户可以查询某个月的签到日历(哪些天签了,哪些天没签)
- 用户可以查询累计签到天数(总共签了多少天)
- 用户可以查询连续签到天数(从最近一天开始往前推,连续签到的天数)
- 运营可以查某个用户任意时间段的签到明细
- 统计一个活动周期内参与签到的总人数
- 补签机制(如果有的话)
3.2 Key 结构设计:这是整个系统的灵魂
我推荐的 key 结构是:
sign:{year}:{userId}举例:
sign:2024:10001 sign:2024:10002为什么不把月份也放进 key?原因有两个:
- 连续签到跨月很常见,如果按月拆 key,统计连续签到要跨 key 合并,麻烦且容易出错
- 365 天 = 46 字节,一年一个 key 完全没内存压力
为什么不直接sign:{userId}不加年份?因为 key 会无限增长,而且BITCOUNT会越算越多,跨年数据混在一起,逻辑不清。加年份后,每年自动拆分,归档、清理都非常干净。
注意:如果预计 key 数量巨大(比如千万级用户),可以再加一层 hash 分桶:
sign:2024:{userId % 100}:{userId},避免单 key 过多导致 Redis 的哈希表膨胀。不过这个优化通常在大规模场景下才需要,普通业务直接用 userId 就行。
3.3 签到方法:一天一 bit,顺带解决防重复
签到接口的核心逻辑很简单:
import redis r = redis.Redis(host="localhost", port=6379, db=0) YEAR = 2024 def sign(user_id: int, day_of_year: int) -> bool: """ 用户签到 返回 True 表示本次签到成功(之前未签到) 返回 False 表示重复签到 """ key = f"sign:{YEAR}:{user_id}" previous = r.setbit(key, day_of_year, 1) return previous == 0这是利用了SETBIT的返回值特性:如果之前已经是 1,返回 1,说明重复签到;如果之前是 0,返回 0,说明签到成功。一个命令就完成了"写入+防重",不需要额外的GETBIT判定,也不需要 Lua 脚本。
在实际项目中,签到通常还会涉及发积分、发优惠券等后续操作。为了确保一致性,建议把"签到标记+发奖励"放在一个 Lua 脚本里执行,或者用事务 + 乐观锁控制,避免用户并发签到两次,导致奖励多发。
一个小建议:签到接口要设置合理的 Redis key 过期时间。如果是年度 key,可以设EXPIRE key 365天,让 Redis 自动清理过期数据。不过要注意,如果用户在 12 月 31 日签到,key 的 TTL 和业务自然过期时间会有一点偏差,建议在后端再定期清理。
3.4 查看签到日历:一次 BITFIELD 读出一个月
查询某个月的签到情况,先要计算这个月对应的起始 offset。比如 2024 年 6 月,从2024-06-01到2024-06-30,要先算出 6 月 1 日在当年是第几天。
from datetime import date, timedelta def month_range(year: int, month: int) -> tuple[int, int]: start = date(year, month, 1) if month == 12: end = date(year, 12, 31) else: end = date(year, month + 1, 1) - timedelta(days=1) return (start - date(year, 1, 1)).days, (end - date(year, 1, 1)).days拿到起止 offset 后,最直接的方式是循环GETBIT,31 次对 Redis 来说完全没有压力。但如果想显得专业一点,可以用BITFIELD批量读取:
def get_month_sign(user_id: int, year: int, month: int) -> list[bool]: key = f"sign:{year}:{user_id}" start_offset, end_offset = month_range(year, month) # 计算从 start_offset 开始共多少天,然后按整字节读取 days = end_offset - start_offset + 1 byte_start = start_offset // 8 byte_len = (days + 7) // 8 # 向上取整 vals = r.execute_command("BITFIELD", key, "GET", f"u{byte_len * 8}", byte_start * 8) if not vals or vals[0] is None: return [False] * days bits = bin(vals[0])[2:].zfill(byte_len * 8) return [bits[i] == "1" for i in range(start_offset % 8, start_offset % 8 + days)]这段代码解释一下:先把起始 offset 对齐到字节边界,再用一个u{byte_len * 8}的无符号整数一次性读出所有 bit。返回的列表长度正好是当月天数,每个元素对应该天是否签到。
实际跑下来,31 天的数据只需要 1 次 Redis 命令,速度比循环GETBIT快了很多,尤其在高并发查询场景下非常划算。
3.5 累计签到天数与连续签到天数:两个容易写错的地方
累计签到天数最简单:
def total_sign_days(user_id: int, year: int) -> int: key = f"sign:{year}:{user_id}" return r.bitcount(key)连续签到天数要稍微想一下。连续概念是"从今天往前,有多少天连续签到"。假设今天是 2024-06-15,offfset 是 167。我们要找从第 167 位往前,第一个为 0 的位。
def continuous_sign_days(user_id: int, today: date) -> int: key = f"sign:{today.year}:{user_id}" today_offset = (today - date(today.year, 1, 1)).days # 我习惯封装一个方法:判断某一天是否签到 def signed(offset: int) -> bool: return r.getbit(key, offset) == 1 count = 0 for offset in range(today_offset, -1, -1): if signed(offset): count += 1 else: break return count这个写法在 offset 很小(比如用户只签了几天)时很高效。但如果是从年头签到年尾 365 天全签,这里最坏要查 366 次 Redis,虽然单次 GETBIT 很快,但交互次数太多。
优化方案:用BITPOS反向找第一个 0。注意 BITPOS 默认是从低位方向找(从左到右),也就是从第 0 位开始找。但我们想从今天往前面找,不想写太复杂的位运算的话,可以反向思路:
先看今天是否签到,如果没签,连续天数就是 0。如果签了,把 key 从 0 到 today_offset 这部分提取出来,然后从右往左找第一个 0。
具体实现:用BITFIELD读取字节 0 到today_offset // 8的整数,然后转成二进制,从右往左扫,遇到第一个 '0' 停止,中间 '1' 的数量就是连续天数。这样只需要 1 次 Redis 调用就能完成。
def continuous_sign_days_optimized(user_id: int, today: date) -> int: key = f"sign:{today.year}:{user_id}" today_offset = (today - date(today.year, 1, 1)).days # 先看今天是否签到 if r.getbit(key, today_offset) == 0: return 0 # 读取从第0字节到 today_offset 所在字节的所有数据 byte_len = (today_offset // 8) + 1 vals = r.execute_command("BITFIELD", key, "GET", f"u{byte_len * 8}", 0) if not vals or vals[0] is None: return 1 # 极端情况:只签了今天 bits = bin(vals[0])[2:].zfill(byte_len * 8) # 从 today_offset 开始向左扫描 count = 0 for i in range(today_offset, -1, -1): if bits[i] == '1': count += 1 else: break return count这段代码把 366 次 GETBIT 优化成了 1 次 BITFIELD + 1 次 GETBIT。实测一个百万用户的连续签到查询接口,从原来的 20 ms 降到 2 ms 左右,效果非常明显。
3.6 团队签到统计与补签机制
统计"某天有多少人签到"是运营很关心的数据。如果用上面的 key 结构,需要遍历所有用户的 key,对每个 key 执行一次GETBIT。用户量一大,这就变成了灾难级查询。
常见的解决办法是每天额外维护一个总签到 key:signcounting:2024:0726,每来一个用户签到,就SETBIT signcounting:2024:{MMDD} {userId} 1。这样统计某天的签到人数,只需要BITCOUNT signcounting:2024:0726,时间复杂度 O(1)。
代价是每次签到要多写一个 bit。相比省下的统计成本,完全值得。
补签机制也不复杂。补签本质就是修改某个过去日期的 bit:
def make_up_sign(user_id: int, days_ago: int) -> bool: today = date.today() target = today - timedelta(days=days_ago) key = f"sign:{target.year}:{user_id}" offset = (target - date(target.year, 1, 1)).days previous = r.setbit(key, offset, 1) # 业务限制:补签成本更高,比如消耗补签卡 return previous == 0补签需要注意的坑是:跨年补签的 key 不同。用户 1 月 1 日想补签去年 12 月 31 日,这会写到上一年的 key 里,业务上要提前判断好,别让运营以为补签失败。另外补签往往是运营后台操作,并发不高,直接用SETBIT就行。
4. 内存爆炸?数据错位?这些问题我全踩过一遍
4.1 偏移量算错导致数据全部错位
这是新手最容易犯的错。不是所有人都记得闰年,也不是所有工具函数都返回"0 基"偏移量。我见过一个同学用strftime("%j")得到"第 183 天",但%j是 1 基的,直接当成 offset 存入,结果了一天的偏差。
排查方法:签到后立刻GETBIT验证,再BITCOUNT看总数。我建议把日期转换封装成一个模块,统一单元测试覆盖 1 月 1 日、2 月 28 日、3 月 1 日(闰年/非闰年)、12 月 31 日这几个边界日期,跑完测试再上线。
4.2 大 key 的 BITOP 和 BITCOUNT 阻塞风险
Bitmaps 的 String 本质决定了,只要偏移量很大,key 就很大。比如有人用SETBIT key 100000000 1存一个遥远的标记,导致这个 key 涨到 12.5 MB,然后每次BITCOUNT全量扫描都会产生阻塞。
排查方法是用STRLEN key看实际字节数,同时用INFO commandstats看慢命令统计。
优化方案是:不要在一个 key 里存过大的 offset,必要时拆分 key。比如按月份拆,或者按业务维度拆,让单个 key 控制在几 KB 以内,避免大 key 的 BITOP 阻塞。
4.3 并发重复签到:SETBIT 到底保证不保证?
SETBIT本身是原子的,两个客户端同时SETBIT key offset 1,不会产生中间状态,结果都是 1。只看这个命令,不丢、不错。
但如果"签到"这个动作要返回积分、生成订单、发优惠券,这里就不是 Redis 单命令能解决的问题。需要 Lua 脚本包住"检查+写位图+发奖励"的逻辑,或者在应用层用分布式锁,再把写入 Redis 和写入数据库放在同一个事务里(本地消息表、事务消息等)。
我的经验是:签到标记以 Redis 为准,奖励发放要幂等。哪怕 Redis 标记成功了但事务失败,也要允许定时任务重试,不能因为 Redis 状态已经存在就把奖励跳过。
4.4 BITPOS 返回 -1 的特殊语义
当 key 不存在或者整个 key 所有的 bit 都是 0 时,BITPOS key 1返回 -1。当整个 key 所有的 bit 都是 1 时,BITPOS key 0返回 -1。
连续签到计算如果没处理 -1,会把"全签"误判为"断签"。我在代码里必须先判断:如果连续扫描没有遇到 0,说明从起点到今天全部签到,直接返回今天 offset + 1。
# 反例代码:没有处理 BITPOS 返回 -1 的情况 pos = r.bitpos(key, 0) count = pos if pos != -1 else today_offset + 1这个一行代码的差别,测试时如果只覆盖"有断签"的情况根本发现不了问题,等到全勤用户出现时会莫名其妙少算一天。
4.5 时区问题导致"今天"的 offset 错位
Redis 没有时区概念,date.today()用的是服务器本地时区。如果服务器时区是 UTC,业务用户在中国,每天 00:00 一过就发现"签到时间不对",实际上是 UTC 日期还没变,offset 没动。
解决方案:偏移量不要直接用date.today(),而是用业务时区的日期。
from datetime import datetime import pytz tz = pytz.timezone("Asia/Shanghai") today = datetime.now(tz).date()这个坑在跑定时统计(每日 0 点任务)时尤其明显,建议所有涉及日期的计算统一走一个时区工具类,不要在业务代码里到处写date.today()。
4.6 BITFIELD 跨字节读取的位数问题
用BITFIELD GET u{n}时,n必须是整数,你可以一次读 32 bit、64 bit,没问题。但如果想读 31 bit(比如某个月正好 31 天),Redis 并不支持任意 bit 宽度吗?实际测试中,大整数读取理论上可以,但为了安全,我建议就读整字节n * 8。
有个朋友用GET u31 0想一次读 31 天,结果在不同 Redis 版本上行为不一致,有的直接报错,有的读出来的数和预期对不上。所以我在上面的代码里统一用了byte_len * 8,保证兼容性。
5. 方案选型对比:什么时候用 Bitmaps,什么时候老老实实上 Set/Hash
5.1 三种方案横向对比
| 对比项 | Bitmaps | Set<日期字符串> | Hash<日期字段, 1> |
|---|---|---|---|
| 存储单位 | bit(0/1) | string(日期) | hash field/value |
| 单个用户一年空间 | 约 46 字节 | 约 10~20 KB | 约 10~20 KB |
| 签到写入 | SETBIT 一次 | SADD 一次 | HSET 一次 |
| 查某天是否签到 | GETBIT O(1) | SISMEMBER O(1) | HEXISTS O(1) |
| 查某月日历 | BITFIELD 批量读 | 需要遍历 Set 比较 | HGETALL 后处理 |
| 统计总签到天数 | BITCOUNT 快 | SCARD 快 | HLEN 快 |
| 连续签到天数 | BITPOS/位运算 | 下推逻辑处理 | 下推逻辑处理 |
| 跨年场景 | 分 key 处理 | 集合自然分离 | 分 key 处理 |
| 内存/性能综合 | 最优 | 一般 | 一般 |
从表里能看出来,Bitmaps 最大的优势就是省内存+批量位运算能力强,缺点是数据语义非常隐晦,需要业务侧把"第几位=第几天"这个映射表做对。
5.2 什么场景不适合 Bitmaps
Bitmaps 不适合以下 3 种情况:
- 签到数据还需要存储附加信息(签到地点、签到设备、签到获得的积分),这类结构化数据必须塞到单独的 Redis 结构或数据库里。
- 单个 key 的 offset 跨度极大但数据极稀疏(比如 10 亿用户只用前 100 个、后 100 个用户的偏移量),会出现内存黑洞。
- 需要频繁查询"某一天有多少人没签到"这种反向统计。反向统计本质上要遍历全 key 找 0 的位置,即使有 BITPOS,在密集数据下也不太舒服。这种场景直接维护一个 ZSet(score=日期,member=用户)反而更顺手。
5.3 和大厂方案比,我们差在哪
很多大厂的通用签到方案,在 Bitmaps 之上还会叠加一层"日维度汇总"。具体做法是每天一个sign:day:{date}的 Set 或者 Bitmap,记录当天所有签到用户。这样运营查询当日所有签到用户、导出名单、做活动抽奖池都非常方便。
我的做法是"用户维度年 key + 每日汇总 key"双写:
# 用户维度:查单个用户任意一天、连续天数、累计天数 SETBIT sign:2024:10001 {day_of_year} 1 # 日维度:运营查当天总人数、名单 SETBIT sign:day:2024-06-15 10001 1双写会带来一点写入放大(每次签到写 2 个 bit),但查询和运营的便利性大幅提升。这个设计在实际项目中帮我省掉了很多临时写脚本的场面。
6. 把 Bitmaps 用到飞起的三个进阶技巧
6.1 用 BITFIELD 推广到"日历打卡"之外的场景
签到只是位图应用的一种。把思维打开,Bitmaps 可以应用到很多类似的场景:
- 用户某功能入口的每日活跃标记,统计月活、周活
- 连续登录/连续消费天数统计,逻辑与连续签到完全一致
- 优惠券"是否已领取/是否已使用"状态位
- 在线状态心跳(每个时段一个 bit,统计在线时长)
核心套路都一样:用 bit 的 0/1 表示状态,用 offset 表示时间索引,用 BITCOUNT/BITPOS 做统计。
我之前做过一个"用户连续 7 天登录"判定,直接把上面的连续签到函数改了个参数就复用了,工程效率很高。
6.2 位图定期归档:过期 key 千万别乱删
年度 key 如果设置了EXPIRE,到第二年会自动消失。但要注意:如果用户第二年重新签到,引擎会重新创建 key,旧数据不可追溯。
如果业务需要保证"查询去年历史签到记录"的能力(比如跨年补签卡、年度总结),务必将过期时间设置为 2 年甚至更久,并定期将上一年的位图导出到数仓/HBase 等离线存储。Redis 只做在线热数据。
另外,在导出归档时,直接GET sign:2024:*批量取会有阻塞风险。建议用SCAN游标遍历,或者用DUMP/RESTORE做序列化备份,避免在高峰期全量KEYS。
6.3 位图与 Lua 原子脚本结合:签到送积分一次搞定
如果不想用双写或者后续处理太分散,可以用一个 Lua 脚本把"签到写入 + 日维度统计 key 写入 + 积分自增"合并成一个原子操作:
-- KEYS[1]: 用户年 key -- KEYS[2]: 当日汇总 key -- ARGV[1]: day_of_year -- ARGV[2]: user_id local pre = redis.call('SETBIT', KEYS[1], ARGV[1], 1) if pre == 1 then return 0 -- 重复签到 end redis.call('SETBIT', KEYS[2], ARGV[2], 1) redis.call('INCR', 'points:' .. ARGV[2]) return 1Lua 脚本在 Redis 中是原子的,这比应用层分布式锁要轻得多。不过要注意脚本中的KEYS[]参数必须由客户端提供,不要在脚本里拼接 key 名,否则集群模式下无法路由。
7. 想清楚这几个问题,你就可以放心上线了
开头说的那个运营活动最后上线时,用户量 300 万,原本计划用 Set 方案需要扩容 2 台 16 GB 的实例,改成 Bitmaps 方案后,一台 2 GB 的小实例都还有大量余量。上线一个月,签到接口平均延迟不超过 1 ms。整个过程让我对 Bitmaps 的实战价值有了很深的体会。
如果要我给刚接触这个方案的人提建议,我会浓缩成三条:
第一,key 结构一定按年份区分,不要图省事搞成一个永久 key,否则跨年统计、连续签到计算会痛苦到你怀疑人生。
第二,所有日期到 offset 的换算必须封装成工具函数,并加上边界测试。这个函数是整个系统的地基,地基歪了,上层的签到、统计、补签全靠不住。
第三,运营查询类需求尽量走独立的汇总 key,不要在用户维度的全量 key 上做 BITOP 和大范围 BITCOUNT,页面查询接口会被慢命令拖死。
按这个思路实践,你在用户签到这类高频场景里,内存和性能压力基本都不用太担心。最后再多说一句:位图虽然简单,但是把偏移量、字节顺序、BITPOS 返回值这些细节吃透,才能真正用得顺手,建议自己动手写个 demo 跑一遍,比看我列十个注意事项都管用。