基于Redis的BitMap数据结构实现签到业务

一、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 实现用户每日签到功能,核心设计思路:

  1. Key 命名规范 :采用 sign:{用户ID}:{年-月} 作为 Bitmap 的 Key(例如 sign:10086:2025-12),按「用户 + 年月」维度隔离签到数据;
  2. Offset 计算:以「当月第几天 - 1」作为 Bit 位偏移量(例如 1 号对应 offset=0,15 号对应 offset=14),保证每个日期对应唯一的 Bit 位;
  3. 签到核心逻辑 :调用 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 测试无误!

下面附签到常见业务的代码

  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("签到成功");
}
  1. 连续签到天数
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);
}
  1. 获取明日签到可得奖励
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);
}
  1. 获取签到状态
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则表明这一位上的数据一定不存在,采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。

相关推荐
hgz07103 小时前
数据库事务
数据库·mysql
一杯美式 no sugar3 小时前
数据结构——单向无头不循环链表
c语言·数据结构·链表
ss2733 小时前
阻塞队列:三组核心方法全对比
java·数据结构·算法
老华带你飞4 小时前
二手商城|基于springboot 二手商城系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
@小码农4 小时前
6547网:2025年9月 Python等级考试(三级)真题及答案
服务器·数据库·python
梦里不知身是客114 小时前
redis的缓存击穿原因
redis·缓存·bootstrap
老华带你飞4 小时前
酒店预约|基于springboot 酒店预约系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
埃伊蟹黄面4 小时前
算法 --- hash
数据结构·c++·算法·leetcode
会飞的土拨鼠呀4 小时前
如何查询MySQL的CPU使用率突然变高
数据库·mysql