一、Redis BitMap 基本用法
BitMap 基本语法、指令
签到功能我们可以使用MySQL来完成,比如下表:

用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节
这样的坏处,占用内存太大了,极大的消耗内存空间!
我们可以根据 Redis中 提供的 BitMap 位图 功能来实现,每次签到与未签到用0 或1 来标识 ,一次存31个数字,只用了2字节 这样我们就用极小的空间实现了签到功能
核心命令前置说明
|------------|------------------------------------------------------------|
| 命令 | 核心用途 |
| SETBIT | 给指定 key 的 Bitmap 设置指定偏移量(offset)的 bit 值(0/1),offset 从 0 开始 |
| GETBIT | 获取指定 key 中指定 offset 位置的 bit 值(0 = 未签到,1 = 已签到) |
| BITFIELD | 灵活操作 Bitmap:支持无符号 / 有符号查询、修改、自增指定范围的 bit 位 |
| BITPOS | 查找 Bitmap 中指定范围内第一个 0 或 1 出现的 offset 位置 |
实操步骤(以「用户 10086 的 2025 年签到」为例)
1. 新增 Bitmap Key 并存储签到状态(SETBIT)
命令格式 :SETBIT key offset value说明:
- key 建议命名规范:
sign:user:{用户ID}:{年份}(如sign:user:10086:2025) - offset:代表「当月第 N 天 - 1」(如 1 号 = 0,5 号 = 4,31 号 = 30)
- value:1 = 已签到,0 = 未签到
实操示例:
bash
# 1. 用户10086 2025年1月1日签到(offset=0,设为1)
SETBIT sign:user:10086:2025 0 1
# 2. 1月5日签到(offset=4,设为1)
SETBIT sign:user:10086:2025 4 1
# 3. 1月8日未签到(offset=7,设为0,可省略,默认0)
SETBIT sign:user:10086:2025 7 0
2. 查询指定日期的签到状态(GETBIT)
命令格式 :GETBIT key offset实操示例:
bash
# 查询1月1日签到状态(预期返回1)
GETBIT sign:user:10086:2025 0
# 查询1月5日签到状态(预期返回1)
GETBIT sign:user:10086:2025 4
# 查询1月8日签到状态(预期返回0)
GETBIT sign:user:10086:2025 7
# 查询未设置的1月10日(offset=9,默认返回0)
GETBIT sign:user:10086:2025 9
3. 无符号查询 Bitmap 片段(BITFIELD 无符号模式)
命令格式 :BITFIELD key GET u<位数> <offset>说明:
u<位数>:无符号整数(u8=8 位,u16=16 位,按需选择)- offset:起始偏移量,按「位」计算
实操示例:
bash
# 无符号查询1月1日-8日(offset=0-7,共8位)的签到状态
BITFIELD sign:user:10086:2025 GET u8 0
# 结果解读:返回十进制数,转为二进制后每一位对应offset(如返回17 → 二进制00010001 → offset0=1,offset4=1,其余=0)
# 无符号查询1月9日-16日(offset=8-15,共8位)的签到状态(默认全0)
BITFIELD sign:user:10086:2025 GET u8 8
4. 查找第一个 1/0 出现的位置(BITPOS)
命令格式 :BITPOS key bit [start] [end]说明:
- bit:0 或 1(要查找的 bit 值)
- start/end:可选,指定字节范围(1 字节 = 8 位),默认全量查找
实操示例:
bash
# 查找第一个已签到(bit=1)的位置(预期返回0,对应1月1日)
BITPOS sign:user:10086:2025 1
# 查找第一个未签到(bit=0)的位置(预期返回1,对应1月2日)
BITPOS sign:user:10086:2025 0
# 查找1月5日之后(字节1开始,即offset≥8)第一个1的位置(无签到,返回-1)
BITPOS sign:user:10086:2025 1 1
使用 BitMap 完成功能实现
服务器Redis版本采用 6.2
进入redis查询 SETBIT 命令

新增key 进行存储

查询 GETBIT命令

查看指定坐标的签到状态 
查询 BITFIELD

无符号查询

BITPOS 查询1 和 0 第一次出现的坐标

二、SpringBoot 整合 Redis 实现签到 功能
需求与核心思路
基于 Redis Bitmap 实现用户每日签到功能,核心设计思路:
- Key 命名规范 :采用
sign:{用户ID}:{年-月}作为 Bitmap 的 Key(例如sign:10086:2025-12),按「用户 + 年月」维度隔离签到数据; - Offset 计算:以「当月第几天 - 1」作为 Bit 位偏移量(例如 1 号对应 offset=0,15 号对应 offset=14),保证每个日期对应唯一的 Bit 位;
- 签到核心逻辑 :调用 Redis
SETBIT命令,将当前用户当天对应的 Offset 位设为 1(1 = 已签到,0 = 未签到);
实现签到接口,将当前用户当天签到信息保存至Redis中
|------|---------------|
| | 说明 |
| 请求方式 | POST |
| 请求路径 | /user/checkIn |
| 请求参数 | 无 |
| 返回值 | 无 |
提示: 因为BitMap 底层是基于String数据结构,因此其操作都封装在字符串操作中了。

核心源码
UserController
java
@ApiOperation("签到功能")
@GetMapping("/checkIn")
public Result<String> checkIn() {
return userService.checkIn();
}
UserServiceImpl
java
@Override
public Result<String> checkIn() {
// 1. 获取当前登录的用户
Long userId = SecurityUtil.getCurrentUserId();
// 2. 获取日期,拼接前缀
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyy:MM"));
String key = CHECK_IN_KEY + userId + ":" + keySuffix;
// 3. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
int offset = dayOfMonth - 1;
// 4. 检查是否已签到
Boolean isCheckedIn = stringRedisTemplate.opsForValue().getBit(key, offset);
if (Boolean.TRUE.equals(isCheckedIn)) {
return Result.ok("今日您已签到过了,明日再来吧~");
}
// 5. 执行签到
stringRedisTemplate.opsForValue().setBit(key, offset, true);
// 6. 设置过期时间(当前月月底)
if (!stringRedisTemplate.hasKey(key)) {
LocalDateTime endOfMonth = now.with(TemporalAdjusters.lastDayOfMonth())
.withHour(23).withMinute(59).withSecond(59);
long expireSeconds = Duration.between(now, endOfMonth).getSeconds();
stringRedisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
}
// 7. 发送消息到消息队列
// 1.更新用户签到时间 2.发放签到奖励
int continuousSignInDays = getContinuousSignInDays().getData();
int reward = 0;
if (continuousSignInDays >= SIGN_IN_DAYS_7){
reward = SIGN_IN_DAYS_7_REWORD;
}else if (continuousSignInDays >= SIGN_IN_DAYS_5){
reward = SIGN_IN_DAYS_5_REWORD;
}else if (continuousSignInDays >= SIGN_IN_DAYS_3){
reward = SIGN_IN_DAYS_3_REWORD;
}else {
reward = SIGN_IN_DAYS_1_REWORD;
}
SignInMessage message = new SignInMessage(true, reward, userId, now);
rabbitTemplate.convertAndSend(SIGN_IN_SUCCESS_TOPIC_EXCHANGE, ROUTING_KEY_SIGN_IN_SUCCESS, message);
return Result.ok("签到成功");
}
我这里使用RabbitMQ加入了签到奖励机制,基础的签到功能仅实现前6步即可
接口进行测试
ApiFox进行测试

查看Redis 数据

三、SpringBoot 整合Redis 实现 签到统计功能
问题一: 什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

逻辑分析:
获得当前这个月的最后一次签到数据 ,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可 ,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
问题二: 如何得到本月到今天为止的所有签到数据?
bash
BITFIELD key GET u[dayOfMonth] 0
假设今天是7号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是7号,那么就是7位,去拿这段时间的数据,就能拿到所有的数据了,那么这7天里边签到了多少次呢?统计有多少个1即可。
**问题三:**如何从后向前遍历每个Bit位?
注意: bitMap返回的数据 是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?
我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,以此类推,我们就能完成逐个遍历的效果了。
需求:
实现以下接口,统计当前截至当前时间在本月的连续天数
|------|---------------------------------------|
| | 说明 |
| 请求方式 | GET |
| 请求路径 | /user/checkIn/getContinuousSignInDays |
| 请求参数 | 无 |
| 返回值 | 连续签到的天数 |
有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了
核心源码
UserController
java
@ApiOperation(value = "获取连续签到天数", notes = "从当前日期开始计算(不包含当天),返回连续签到的天数")
@GetMapping("/checkIn/getContinuousSignInDays")
public Result<Integer> getContinuousSignInDays() {
return userService.getContinuousSignInDays();
}
UserServiceImpl
java
@Override
public Result<Integer> getContinuousSignInDays() {
Long userId = SecurityUtil.getCurrentUserId();
LocalDateTime now = LocalDateTime.now();
// 1. 获取今天的日期信息
int dayOfMonth = now.getDayOfMonth(); // 今天是本月的第几天
// 2. 构造Redis键
String key = CHECK_IN_KEY + userId + ":" +
now.format(DateTimeFormatter.ofPattern("yyyy:MM"));
// 3. 从今天开始往前检查,直到找到未签到的天为止
int continuousDays = 0;
Boolean todaySigned = stringRedisTemplate.opsForValue().getBit(key, dayOfMonth - 1);
if (Boolean.TRUE.equals(todaySigned)) {
// 今天签到了,从今天开始检查
// 从今天(偏移量=天数-1)开始往前检查
for (int offset = dayOfMonth - 1; offset >= 0; offset--) {
Boolean isSigned = stringRedisTemplate.opsForValue().getBit(key, offset);
// 如果该天已签到,继续往前一天检查
if (Boolean.TRUE.equals(isSigned)) {
continuousDays++;
} else {
// 找到未签到的天,结束循环
break;
}
}
}else {
//今天没签到从昨天开始检查
// 从今天(偏移量=天数-1)开始往前检查
for (int offset = dayOfMonth - 2; offset >= 0; offset--) {
Boolean isSigned = stringRedisTemplate.opsForValue().getBit(key, offset);
// 如果今天已签到,继续往前一天检查
if (Boolean.TRUE.equals(isSigned)) {
continuousDays++;
} else {
// 找到未签到的天,结束循环
break;
}
}
}
return Result.ok(continuousDays);
}
进行测试

查看 Redis 变量

从今天开始,往前查询 连续签到的天数,结果为1 测试无误!
下面附签到常见业务的代码
- 签到
java
@Override
public Result<String> checkIn() {
// 1. 获取当前登录的用户
Long userId = SecurityUtil.getCurrentUserId();
// 2. 获取日期,拼接前缀
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyy:MM"));
String key = CHECK_IN_KEY + userId + ":" + keySuffix;
// 3. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
int offset = dayOfMonth - 1;
// 4. 检查是否已签到
Boolean isCheckedIn = stringRedisTemplate.opsForValue().getBit(key, offset);
if (Boolean.TRUE.equals(isCheckedIn)) {
return Result.ok("今日您已签到过了,明日再来吧~");
}
// 5. 执行签到
stringRedisTemplate.opsForValue().setBit(key, offset, true);
// 6. 设置过期时间(当前月月底)
if (!stringRedisTemplate.hasKey(key)) {
LocalDateTime endOfMonth = now.with(TemporalAdjusters.lastDayOfMonth())
.withHour(23).withMinute(59).withSecond(59);
long expireSeconds = Duration.between(now, endOfMonth).getSeconds();
stringRedisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
}
// 7. 发送消息到消息队列
// 1.更新用户签到时间 2.发放签到奖励
int continuousSignInDays = getContinuousSignInDays().getData();
int reward = 0;
if (continuousSignInDays >= SIGN_IN_DAYS_7){
reward = SIGN_IN_DAYS_7_REWORD;
}else if (continuousSignInDays >= SIGN_IN_DAYS_5){
reward = SIGN_IN_DAYS_5_REWORD;
}else if (continuousSignInDays >= SIGN_IN_DAYS_3){
reward = SIGN_IN_DAYS_3_REWORD;
}else {
reward = SIGN_IN_DAYS_1_REWORD;
}
SignInMessage message = new SignInMessage(true, reward, userId, now);
rabbitTemplate.convertAndSend(SIGN_IN_SUCCESS_TOPIC_EXCHANGE, ROUTING_KEY_SIGN_IN_SUCCESS, message);
return Result.ok("签到成功");
}
- 连续签到天数
java
@Override
public Result<Integer> getContinuousSignInDays() {
Long userId = SecurityUtil.getCurrentUserId();
LocalDateTime now = LocalDateTime.now();
// 1. 获取今天的日期信息
int dayOfMonth = now.getDayOfMonth(); // 今天是本月的第几天
// 2. 构造Redis键
String key = CHECK_IN_KEY + userId + ":" +
now.format(DateTimeFormatter.ofPattern("yyyy:MM"));
// 3. 从今天开始往前检查,直到找到未签到的天为止
int continuousDays = 0;
Boolean todaySigned = stringRedisTemplate.opsForValue().getBit(key, dayOfMonth - 1);
if (Boolean.TRUE.equals(todaySigned)) {
// 今天签到了,从今天开始检查
// 从今天(偏移量=天数-1)开始往前检查
for (int offset = dayOfMonth - 1; offset >= 0; offset--) {
Boolean isSigned = stringRedisTemplate.opsForValue().getBit(key, offset);
// 如果该天已签到,继续往前一天检查
if (Boolean.TRUE.equals(isSigned)) {
continuousDays++;
} else {
// 找到未签到的天,结束循环
break;
}
}
}else {
//今天没签到从昨天开始检查
// 从今天(偏移量=天数-1)开始往前检查
for (int offset = dayOfMonth - 2; offset >= 0; offset--) {
Boolean isSigned = stringRedisTemplate.opsForValue().getBit(key, offset);
// 如果今天已签到,继续往前一天检查
if (Boolean.TRUE.equals(isSigned)) {
continuousDays++;
} else {
// 找到未签到的天,结束循环
break;
}
}
}
return Result.ok(continuousDays);
}
- 获取明日签到可得奖励
java
@Override
public Result<Integer> getTomorrowSignInReward() {
Long userId = SecurityUtil.getCurrentUserId();
Integer data = getContinuousSignInDays().getData();
int tomorrowSignInDays = data + 1;
int reward = 0;
if (tomorrowSignInDays >= SIGN_IN_DAYS_7) {
reward = SIGN_IN_DAYS_7_REWORD;
}else if (tomorrowSignInDays >= SIGN_IN_DAYS_5 ) {
reward = SIGN_IN_DAYS_5_REWORD;
} else if (tomorrowSignInDays >= SIGN_IN_DAYS_3 ) {
reward = SIGN_IN_DAYS_3_REWORD;
} else if (tomorrowSignInDays >= SIGN_IN_DAYS_1 ){
reward = SIGN_IN_DAYS_1_REWORD;
}
return Result.ok(reward);
}
- 获取签到状态
java
@Override
public Result<CheckInStatusVO> getCheckInStatus() {
Long userId = SecurityUtil.getCurrentUserId();
LocalDateTime now = LocalDateTime.now();
// 1. 构造Redis键
String key = CHECK_IN_KEY + userId + ":" +
now.format(DateTimeFormatter.ofPattern("yyyy:MM"));
// 2. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
int todayOffset = dayOfMonth - 1; // 今天是第3天,偏移量就是2
// 3. 检查今天是否已经签到
Boolean todaySigned = stringRedisTemplate.opsForValue().getBit(key, todayOffset);
boolean checkedIn = Boolean.TRUE.equals(todaySigned);
// 4. 计算连续签到天数
int continuousDays = getContinuousSignInDays().getData();
// 5. 获取本月总签到天数(使用BITCOUNT命令)
int monthCheckInDays = 0;
// 方法1:使用RedisCallback执行BITCOUNT命令
Long bitCount = stringRedisTemplate.execute(
new RedisCallback<Long>() {
@Override
public Long doInRedis(RedisConnection connection) {
return connection.bitCount(key.getBytes());
}
}
);
if (bitCount != null) {
monthCheckInDays = bitCount.intValue();
}
// 方法2:或者使用更简洁的lambda写法
// monthCheckInDays = stringRedisTemplate.execute(
// (RedisCallback<Long>) conn -> conn.bitCount(key.getBytes())
// ).intValue();
// 获取明日可得音浪数
int tomorrowSignInReward = getTomorrowSignInReward().getData();
// 获取当前用户音浪数
Integer currentWaves = userMapper.selectMyWaves(userId);
// 6. 封装返回结果
CheckInStatusVO result = new CheckInStatusVO();
result.setCheckedIn(checkedIn);
result.setContinuousDays(continuousDays);
result.setMonthCheckInDays(monthCheckInDays);
result.setTomorrowSignInReward(tomorrowSignInReward);
result.setCurrentWaves(currentWaves);
return Result.ok(result);
}
四、关于使用bitmap来解决缓存穿透的方案
回顾缓存穿透:
发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击
解决方案:
- 判断id<0
- 数据库为空的话,向redis里边把这个空数据缓存起来
第一种解决方案:遇到的问题是如果用户访问的是id不存在的数据,则此时就无法生效
第二种解决方案:遇到的问题是:如果是不同的id那就可以防止下次过来直击数据
所以我们如何解决呢?
我们可以将数据库的数据,所对应的id写入到一个list集合中 ,当用户过来访问的时候 ,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据 ,则直接放行。

现在的问题是这个主键其实并没有那么短,而是很长的一个 主键
如果采用以上方案,这个list也会很大 ,所以我们可以使用bitmap来减少list的存储空间
我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:
id 求余bitmap长度 :id % bitmap.size = 算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在,采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。
