签到统计是典型的“写多读多、数据量随时间线性增长”场景。假设一个产品有一千万日活用户,每人每天产生一条签到记录,一年就是36.5亿条数据。如果用MySQL逐条存储,光索引就要占用大量磁盘空间,统计连续签到天数时还需要复杂的时间窗口查询。而Redis的BitMap把每天的签到状态压缩成一个二进制位,同一个用户一整年的签到数据加起来也不过46个字节左右,统计效率更是有数量级的提升。这篇文章从原理到实战,完整讲清楚如何用BitMap实现一套生产可用的签到系统。

BitMap的底层存储原理
Redis中的BitMap并不是一种独立的数据类型,而是基于String类型实现的位操作扩展。当你执行SETBIT key offset value时,Redis会在底层维护一段连续的字节数组,offset表示二进制位的偏移位置,value只能是0或1。比如设置offset为7的位,Redis至少会分配1个字节来容纳前8个位。
这里有一个非常重要的特性:BitMap采用稀疏分配策略。如果你直接执行SETBIT sign:10086 365 1(给用户10086设置第365天的签到位),Redis会一次性分配约46字节的空间,中间未被设置的位自动补0。这意味着offset决定了内存占用上限,一个偏移量最大支持2的32次方减1,也就是42亿多个位,理论上一条String就能记录一个用户一百多万年的签到数据。
位与字节的对应关系遵循大端序规则:字节内的位从高位到低位编号。例如第一个字节的第0位是该字节的最高位(二进制10000000中的1)。理解这一点在做位运算分析时很关键,否则很容易把结果算反。
核心命令详解
与BitMap相关的命令主要有四个,各自承担不同的职责:
SETBIT key offset value:设置指定偏移量上的位,返回旧值。签到接口的核心写入命令。GETBIT key offset获取指定偏移量上的值,用于判断某天是否签到。BITCOUNT key [start end]:统计位图中值为1的数量,用于计算累计签到天数。注意start和end参数的单位默认是字节,如果需要按位统计要加BYTE参数显式声明或使用BIT选项。BITPOS key bit [start]:查找第一个被设置为指定值(0或1)的位,可以用来快速定位首次签到日期或第一个未签到的天。
下面用redis-cli演示一个完整的签到流程,以2024年5月的第1天到第3天为例:
# 用户10086在5月1日签到,offset为当月第几天减1 127.0.0.1:6379> SETBIT sign:10086:202405 0 1 (integer) 0 # 5月2日签到 127.0.0.1:6379> SETBIT sign:10086:202405 1 1 (integer) 0 # 5月3日未签到 127.0.0.1:6379> GETBIT sign:10086:202405 2 (integer) 0 # 统计本月累计签到天数 127.0.0.1:6379> BITCOUNT sign:10086:202405 (integer) 2 # 查找本月第一个签到的日期 127.0.0.1:6379> BITPOS sign:10086:202405 1 (integer) 0
key的命名设计也有讲究。上面的例子使用了sign:用户ID:年月的格式,按月拆分数据。这样设计有两个好处:一是单个key最大只占31个位,永远不会成为大key;二是过期策略简单,可以给整个key设置TTL,超过一年的旧数据自动清理。
Spring Boot实战:签到与连续签到统计
理论讲完,接下来用Java实现一套完整的签到服务。技术栈为Spring Boot加Spring Data Redis,核心思路是把“当月第几天”映射为BitMap的offset。统计连续签到天数时,从今天开始往前逐位检查,遇到0就停止。
@Service
public class SignInService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 执行签到
* @param userId 用户ID
* @return 是否签到成功,false表示今天已经签过
*/
public boolean doSignIn(long userId) {
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
String key = "sign:" + userId + ":" + keySuffix;
// offset = 当月第几天 - 1,从0开始
int offset = now.getDayOfMonth() - 1;
Boolean result = redisTemplate.opsForValue().setBit(key, offset, true);
// 设置过期时间,一年后自动清理(重复调用不会重置已有TTL)
redisTemplate.expire(key, Duration.ofDays(365));
return Boolean.TRUE.equals(result) ? false : true;
}
/**
* 获取本月签到情况,用于日历回显
*/
public List<Boolean> getSignInCalendar(long userId) {
LocalDateTime now = LocalDateTime.now();
String key = "sign:" + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM"));
int days = now.getMonth().maxLength();
List<Boolean> calendar = new ArrayList<>(days);
for (int i = 0; i < days; i++) {
Boolean bit = redisTemplate.opsForValue().getBit(key, i);
calendar.add(Boolean.TRUE.equals(bit));
}
return calendar;
}
/**
* 统计从今天开始往前的连续签到天数
*/
public long getContinuousDays(long userId) {
LocalDateTime now = LocalDateTime.now();
String key = "sign:" + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM"));
int todayOffset = now.getDayOfMonth() - 1;
long count = 0;
for (int i = todayOffset; i >= 0; i--) {
Boolean bit = redisTemplate.opsForValue().getBit(key, i);
if (Boolean.TRUE.equals(bit)) {
count++;
} else {
break;
}
}
return count;
}
}
doSignIn方法里有个细节值得注意:setBit返回的是该位的旧值。如果旧值为true,说明今天已经签过到,直接返回失败,天然实现了签到幂等性,不需要额外的去重逻辑。
getContinuousDays的循环最多执行31次,即使全部命中也只是31次Redis往返。如果对性能有更高要求,可以把整个BitMap一次性读出来在内存中做位运算,一次网络往返搞定。连续签到跨月的场景(比如5月31日签完,6月1日继续签)需要额外查询上一个月的key,把两个BitMap的结果拼接起来再计算,代码逻辑稍微复杂一些但思路完全一致。
生产环境中的常见坑与优化建议
第一个坑是offset设计错误。有的团队图省事直接用“一年中的第几天”作为offset,一个key存一整年。这样虽然减少了key的数量,但统计“本月签到天数”就需要按位截取,而且当年的key会占用46个字节还不算大,可一旦把offset设计成时间戳除以86400这种全局天数,几年后offset会达到几千甚至几万,单个key的字节数虽然也不算夸张,但失去了按月清理的便利性。按月拆分是最均衡的方案。
第二个坑是时区问题。服务器时间、Redis服务器时间、用户实际所在时区三者可能不一致。一个乌鲁木齐的用户在北京时间23点50分签到,如果按UTC计算日期可能已经跨天。严谨的做法是在接口层接收用户时区参数,统一用用户本地时间计算“当月第几天”,避免签到日期错乱引发客诉。
第三个坑是重复设置TTL。上面的代码每次签到都会调用expire,这本身没有问题,但要清楚Redis的expire会刷新过期时间。如果你希望key严格在一年后的自然日过期,需要自行判断是否首次设置。另外,如果业务需要补签功能,BitMap同样支持,直接SETBIT历史日期对应的offset即可,但补签是否计入连续签到要由业务规则决定。
最后谈一下存储成本的直观对比:1000万用户、每人一个key、每个key 4个字节左右,总内存占用不到50MB。同样的数据放MySQL里,哪怕只存用户ID和日期两个整型字段,加上索引和行开销,轻松超过1GB。这也是BitMap在签到、活跃用户统计(DAU)、布隆过滤器等场景被广泛使用的根本原因——用极小的空间换取极高的统计效率。如果你的项目里还没有引入这套方案,不妨从签到模块开始实践。
Redis BitMap用户签到签到统计修改时间:2026-09-10 11:03:16