Redis Bitmaps实战:从位图原理到用户签到系统设计 先问一句你是不是也用 Redis 做过用户签到这种需求如果是那你一定纠结过到底该用 Set 还是 String 存日期直到某天在redis-cli里敲了一行SETBIT发现三千万用户一年签到居然只用几十 MB 内存才明白 Redis Bitmaps 这个东西到底有多香。我最早接触 Bitmaps 是在做一个内部运营活动当时签到人数从几千涨到几十万Set 存储方案的内存直接顶不住了后来一晚上改成 Bitmaps内存降了两个数量级。这篇文章就把这几年的实战经验整理出来从位图原理、命令细节到签到系统的 key 结构设计、连续签到计算、常见坑点一次说透。适合刚接触 Redis 位图的同学也适合已经用过但想优化现有签到方案的人。1. 搞懂 Bitmaps 之前先理解位图这两个字很多人听到位图第一反应是图片格式但在 Redis 里Bitmaps 并不是一种独立的数据类型它本质上是基于 String 类型做的一种位级操作抽象。一个字节有 8 个 bit每个 bit 只能存 0 或 1Bitmaps 就是让你直接操作这些 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 个 bitRedis 会一次性把前面的 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 全是 1BITPOS key 0返回 -1此时连续签到就是从起点一直到现在都签到了要特殊处理。如果整个 key 全是 0BITPOS 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结果会写入 destkeydestkey 不存在会自动创建。它不会修改源 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(hostlocalhost, port6379, db0) YEAR 2024 def sign(user_id: int, day_of_year: int) - bool: 用户签到 返回 True 表示本次签到成功之前未签到 返回 False 表示重复签到 key fsign:{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(days1) return (start - date(year, 1, 1)).days, (end - date(year, 1, 1)).days拿到起止 offset 后最直接的方式是循环GETBIT31 次对 Redis 来说完全没有压力。但如果想显得专业一点可以用BITFIELD批量读取def get_month_sign(user_id: int, year: int, month: int) - list[bool]: key fsign:{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, fu{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 fsign:{year}:{user_id} return r.bitcount(key)连续签到天数要稍微想一下。连续概念是从今天往前有多少天连续签到。假设今天是 2024-06-15offfset 是 167。我们要找从第 167 位往前第一个为 0 的位。def continuous_sign_days(user_id: int, today: date) - int: key fsign:{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 fsign:{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, fu{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。用户量一大这就变成了灾难级查询。常见的解决办法是每天额外维护一个总签到 keysigncounting:2024:0726每来一个用户签到就SETBIT signcounting:2024:{MMDD} {userId} 1。这样统计某天的签到人数只需要BITCOUNT signcounting:2024:0726时间复杂度 O(1)。代价是每次签到要多写一个 bit。相比省下的统计成本完全值得。补签机制也不复杂。补签本质就是修改某个过去日期的 bitdef make_up_sign(user_id: int, days_ago: int) - bool: today date.today() target today - timedelta(daysdays_ago) key fsign:{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/Hash5.1 三种方案横向对比对比项BitmapsSet日期字符串Hash日期字段, 1存储单位bit0/1string日期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 什么场景不适合 BitmapsBitmaps 不适合以下 3 种情况签到数据还需要存储附加信息签到地点、签到设备、签到获得的积分这类结构化数据必须塞到单独的 Redis 结构或数据库里。单个 key 的 offset 跨度极大但数据极稀疏比如 10 亿用户只用前 100 个、后 100 个用户的偏移量会出现内存黑洞。需要频繁查询某一天有多少人没签到这种反向统计。反向统计本质上要遍历全 key 找 0 的位置即使有 BITPOS在密集数据下也不太舒服。这种场景直接维护一个 ZSetscore日期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 跑一遍比看我列十个注意事项都管用。