签到功能的难点不在于记录一天的状态,而在于连续天数、自然月统计、补签后的重新计算,以及用户量上升后的查询压力。用关系表存签到流水固然直观,但连续天数往往要按照日期倒序逐条扫描,当活跃用户达到几十万时,查询很容易碰到性能瓶颈。Redis 的 BitMap 使用一个 bit 表示一天,签到置 1,缺勤置 0,一个用户一年的签到数据只占几十字节。本文会完整拆解 key 设计、写入命令、连续天数算法和跨月处理,并给出一份可运行的 Go 代码。

为什么 BitMap 适合签到,key 该怎么设计
Redis 的 BitMap 并不是一种独立的类型,它建立在 String 之上。SETBIT 命令接收一个偏移量 offset 和一个值 0 或 1,Redis 会按字节扩展字符串,把对应 bit 写入。一个字节有 8 个 bit,因此第 0 天到第 7 天共用第一个字节,第 8 天到第 15 天共用第二个字节。对于年度签到来说,365 个 bit 只需要约 46 字节的实际数据,加上 Redis 字符串头信息后约 56 字节。这个存储量远低于普通数据库表,也让 SETBIT、GETBIT、BITFIELD 都能保持 O(1) 时间复杂度。
key 设计通常有两种。按月拆分如 user:sign:{uid}:{yyyyMM},优点是单 key 短小,适合只做自然月统计的产品;缺点是跨月连续签到要同时读两个月 key,拼接逻辑容易出错。按年拆分如 user:sign:{uid}:{yyyy},优点是一个 key 覆盖全年,连续签到计算非常直接,缺点是 key 稍大。实际项目中,如果连续签到是一个核心玩法,优先选年 key。若还需要月出勤报表,可以同时维护一个冗余月 key,写入时同时设置两个 key。时区也必须固定,例如统一用东八区日期,避免服务器 UTC 时间与用户日期不一致。
签到写入、查询与幂等控制
写入签到本质是执行 SETBIT key offset 1。如果今天是 5 月 3 日,offset 就是 2,因为 BitMap 从 0 开始计数;通常统一用 day-1 作为偏移。查询某天是否签到则使用 GETBIT key offset。统计累计签到天数使用 BITCOUNT key,它返回值为 1 的 bit 数量。Redis 命令示例如下:
SETBIT user:sign:2025:1001 0 1 SETBIT user:sign:2025:1001 1 1 SETBIT user:sign:2025:1001 2 1 BITCOUNT user:sign:2025:1001 GETBIT user:sign:2025:1001 1
这段命令表示用户 1001 在 5 月 1 日、2 日、3 日完成签到,BITCOUNT 返回 3,GETBIT 返回 1 表示 5 月 2 日已签到。需要注意的是,SETBIT 重复设置同一天为 1 不会报错,所以它天然支持重复请求,但不代表业务幂等。生产环境仍建议保留签到流水表,BitMap 仅作为高性能统计层。若 Redis 节点发生故障,可通过流水表重建位图。
补签可以复用 SETBIT,例如用户补签 5 月 2 日,就再执行 SETBIT user:sign:2025:1001 1 1。但在业务上,补签可能需要审批或消耗积分,这些规则不适合放在 Redis 中判断。正确做法是在业务层检查补签条件,写流水表成功后,再执行 SETBIT 更新位图。这样能避免把 Redis 当成唯一数据源带来的审计风险。
连续签到天数:别掉进低位扫描的坑
连续天数常见的误区是用 BITCOUNT 来算,但 BITCOUNT 只统计总量,不关心位置。比如用户 5 月 1 日、2 日签到,5 月 3 日未签,5 月 4 日又签到,累计签到 3 天,最近连续只有 1 天。连续天数必须从最近的一天开始往前扫描,遇到第一个 0 就停止。
另一个容易出错的地方是扫描方向。BITFIELD key GET u{宽度} 0 会从第 0 天开始取指定宽度的位,返回一个无符号整数。第 0 天的状态在最低位,第 last-1 天的状态在最高位。如果从最低位往高位扫描,得到的是从月初开始的连续天数,而不是最近连续天数。正确做法是从最高位向下扫描。假设今天已签到,今天就是最高位;从最高位开始向低位逐位判断,遇到 1 累加,遇到 0 跳出。
下面的 Go 代码演示了这一过程:先判断今天是否已签到,如果未签到,连续天数截止到昨天;如果已签到,截止到今天。然后通过 BITFIELD 取出 last 位,从高位向低位循环。
func ContinuousDays(ctx context.Context, rdb *redis.Client, uid string, today int) (int, error) {
key := fmt.Sprintf("user:sign:2025:%s", uid)
signedToday, err := rdb.GetBit(ctx, key, int64(today-1)).Result()
if err != nil {
return 0, err
}
last := today - 1
if signedToday == 1 {
last = today
}
if last <= 0 {
return 0, nil
}
vals, err := rdb.BitField(ctx, key, "GET", fmt.Sprintf("u%d", last), 0).Result()
if err != nil {
return 0, err
}
val := vals[0]
count := 0
for i := last - 1; i >= 0; i-- {
if val&(1<<i) != 0 {
count++
} else {
break
}
}
return count, nil
}
这段代码中,last 是本次要考察的天数范围。BITFIELD 的 u 表示无符号整数,后面跟着位数。当 last 等于 3 且值为二进制 101 时,循环从 i=2 开始,最高位是 1,count 加 1;再检查 i=1,对应中间位是 0,直接跳出,返回 1。这样得到的就是最近连续天数,而不是月初连续天数。若产品口径要求今天未签到也把今天视为连续,只需调整 last 的取值即可。
跨月、补签与内存评估
如果选择按月 key,跨月连续签到就会变得麻烦。例如今天是 6 月 2 日,连续签到可能追溯到 5 月 30 日。你需要先从 5 月的 key 中取最后若干天,再从 6 月 key 中取开头几天,把两段拼接成一个临时整数,再做高位扫描。每多一步拼接,代码复杂度和出错概率都会上升。这也是推荐按年 key 的原因:一年内不需要跨 key,跨年场景与跨月类似,只需处理两个年度 key。
补签不仅要写当天,还要保证写的位置正确。假设用户补 4 月 15 日,offset 是 14。SETBIT 本身很快,但如果补签后需要重算连续天数,只需要重新读取位图即可,无需额外维护连续天数缓存。连续天数缓存可以存在 Redis 中,但要设置合理的过期时间,并在补签后主动失效。
内存方面,BitMap 的优势非常明显。按年 key 的一位用户数据约 56 字节,100 万活跃用户大约 56 MB;如果按月 key,一个用户一个月约 8 到 12 字节,100 万用户约 10 MB 左右,但同时要处理跨月逻辑。命令性能上,SETBIT、GETBIT 都是 O(1),BITFIELD 取 30 到 366 位也非常快。需要注意的是不要设计成从 1970 年开始一直偏移到今天的单 key 位图,否则早期用户会使 key 变得很大,还可能产生大 key 问题。按年或按月滚动 key 是更稳妥的做法。
总结
Redis BitMap 通过把日期映射为 bit,将签到状态压缩到极小的空间内。核心命令只有 SETBIT、GETBIT、BITFIELD 和 BITCOUNT。实现连续签到天数时,应先明确统计口径,再从最高位向低位扫描;如果需要跨月,优先用年度 key 简化逻辑。BitMap 适合作为高性能统计层,真正的签到流水和补签规则仍应由业务数据库负责。掌握这些细节后,你可以用很小的内存支撑大量用户的签到与连续天数查询。
Redis BitMap连续签到签到统计修改时间:2026-09-29 06:19:40