天机学堂——BitMap实现签到

目录

[1. 引言:传统签到方案的痛点](#1. 引言:传统签到方案的痛点)

[2. 核心解决方案:BitMap 位图思想](#2. 核心解决方案:BitMap 位图思想)

[2.1 BitMap 的设计思路](#2.1 BitMap 的设计思路)

[2.2 BitMap 的概念](#2.2 BitMap 的概念)

[3. Redis BitMap 核心用法](#3. Redis BitMap 核心用法)

[3.1 Redis BitMap 的存储优势](#3.1 Redis BitMap 的存储优势)

[3.2 核心位操作命令实操](#3.2 核心位操作命令实操)

[3.2.1 SETBIT:置位实现签到](#3.2.1 SETBIT:置位实现签到)

[3.2.2 BITFIELD:查位获取签到记录](#3.2.2 BITFIELD:查位获取签到记录)

[4. 签到功能实战实现](#4. 签到功能实战实现)

[4.1 接口设计](#4.1 接口设计)

[4.1.1 基础接口信息](#4.1.1 基础接口信息)

[4.1.2 返回值 VO 设计](#4.1.2 返回值 VO 设计)

[4.2 核心业务规则](#4.2 核心业务规则)

[4.3 代码完整实现](#4.3 代码完整实现)

[4.3.1 定义 Redis Key 常量](#4.3.1 定义 Redis Key 常量)

[4.3.2 控制器层:SignRecordController](#4.3.2 控制器层:SignRecordController)

[4.3.3 服务层接口:ISignRecordService](#4.3.3 服务层接口:ISignRecordService)

[4.3.4 服务层实现:SignRecordServiceImpl](#4.3.4 服务层实现:SignRecordServiceImpl)

[5. 核心难点解析:连续签到天数统计](#5. 核心难点解析:连续签到天数统计)

[5.1 统计核心思路](#5.1 统计核心思路)

[5.2 关键位运算解析](#5.2 关键位运算解析)

6.总结拓展


本文主要是我个人学习天机学堂这个项目自己的一些理解和优化部分,主要是摘出项目中一些比较通用的部分,方便大家以及自己之后如果遇到了类似的业务可以进行参考使用

1. 引言:传统签到方案的痛点

在设计签到功能时,首先想到的是用数据库表存储每条签到记录,比如设计如下sign_record表:

sql 复制代码
CREATE TABLE `sign_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint NOT NULL COMMENT '用户id',
  `year` year NOT NULL COMMENT '签到年份',
  `month` tinyint NOT NULL COMMENT '签到月份',
  `date` date NOT NULL COMMENT '签到日期',
  `is_backup` bit(1) NOT NULL COMMENT '是否补签',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';

该方案的核心问题是数据量爆炸、空间利用率极低

  • 一条记录对应一个用户一次签到 ,若平台有 100 万用户,平均每人每年签到 100 次,仅一年就会产生1 亿条记录
  • 每条记录包含主键、用户 ID、时间等字段,单条记录占用数百字节,海量数据会持续占用数据库磁盘空间,同时降低签到查询、统计的性能。

而签到行为的本质只有 "签" 或**"未签"** 两种状态**,能否用更高效的方式存储这种二元状态?答案就是BitMap(位图)**。

2. 核心解决方案:BitMap 位图思想

2.1 BitMap 的设计思路

BitMap 的设计灵感来源于生活中的签到卡:一张卡片就能记录一个用户一个月的签到情况,签到打勾、未签留白。

我们可以用程序模拟这种思路,用二进制位的 0 和 1映射签到状态:

  • 1:表示对应日期已签到;
  • 0:表示对应日期未签到。

由于一个月最多 31 天,因此仅需31 个二进制位 就能完整记录一个用户一个月的所有签到情况。而计算机中 1 个字节 = 8 个二进制位,31 位仅占不到 4 个字节,对比传统数据库方案的数百字节,空间利用率提升上百倍。

2.2 BitMap 的概念

BitMap(位图)是一种将二进制位与业务状态一一映射的高效数据统计思路:

  • 把每一个二进制位作为最小存储单元,对应一个业务标识(如本文的 "某月第 N 天");
  • 用二进制位上的 0/1 标识业务状态(如本文的 "未签到 / 已签到")。

这种思路的核心优势是极致的空间利用率,因此常被用于海量二元状态数据的统计,比如:用户签到、布隆过滤器的重复判断、活跃用户统计等。

3. Redis BitMap 核心用法

Redis 原生提供了 BitMap 的相关操作命令,是实现签到功能的最佳选择。需要注意的是,Redis 中并没有单独的 BitMap 数据类型,它是基于 String 类型实现的(Redis 的 String 底层是 SDS 简单动态字符串,本质是字节数组),Redis 通过封装位操作命令,实现了 BitMap 的效果。

3.1 Redis BitMap 的存储优势

Redis 的 String 类型最大支持512MB 的存储空间,换算成二进制位就是2^31个 bit,足以存储海量的签到数据:比如一个用户一个月占 31bit,512MB 可存储约 170 亿个用户的月签到记录,完全满足中小型平台的业务需求。

3.2 核心位操作命令实操

Redis BitMap 的核心命令分为 ** 置位(修改状态)查位(查询状态)** 两类,也是实现签到和统计的基础。

3.2.1 SETBIT:置位实现签到

作用:修改指定 BitMap 中某个二进制位的值(0/1),用于实现 "签到置 1" 的核心操作。

命令格式

sql 复制代码
SETBIT key offset value
  • key:BitMap 的唯一标识(后续会按 "用户 + 月份" 设计);
  • offset:二进制位的偏移量,从 0 开始计数(本文中对应 "日期 - 1",如 1 号签到 offset=0,2 号 offset=1);
  • value:二进制位的值,0 = 未签到,1 = 已签到。

实操示例:记录用户 1 号、2 号、3 号、6 号签到

sql 复制代码
# 1号签到:offset=0,置1
SETBIT sign:uid:100:202410 0 1
# 2号签到:offset=1,置1
SETBIT sign:uid:100:202410 1 1
# 3号签到:offset=2,置1
SETBIT sign:uid:100:202410 2 1
# 6号签到:offset=5,置1
SETBIT sign:uid:100:202410 5 1

最终该 BitMap 的二进制状态为:11100100

3.2.2 BITFIELD:查位获取签到记录

作用 :灵活操作 BitMap 的二进制位,支持查询、修改、自增等,本文仅用其查询功能获取签到记录。

查询命令格式

sql 复制代码
BITFIELD key GET encoding offset
  • key:BitMap 的唯一标识;
  • GET:操作类型,表示 "查询位状态";
  • encoding :返回结果的编码规则,核心用无符号整数 u (签到记录无正负,无需有符号 i),格式为uNN 表示要查询的二进制位个数);
  • offset:查询的起始偏移量,本文中从 0 开始(从当月 1 号开始)。

实操示例:查询上述用户 1-3 号的签到记录

复制代码
# 查询从offset=0开始的3个二进制位,返回无符号整数
BITFIELD sign:uid:100:202410 GET u3 0
# 返回结果:7

redis控制台如下:

查询的 3 个二进制位是111,将其转为无符号十进制整数,计算得**1*2² + 1*2¹ + 1*2⁰ = 7。**

4. 签到功能实战实现

基于 Redis BitMap,我们在tj-learning服务中完整实现签到功能,包括签到接口设计防重复签到连续签到天数统计积分奖励计算等核心功能。

4.1 接口设计

签到功能面向平台用户,在个人中心积分页面触发,接口设计遵循简洁、高效原则,无需前端传递额外参数(当前用户、当前时间可由后端自动获取)。

4.1.1 基础接口信息

内容
请求方式 POST
请求路径 /sign-records
请求参数 无(从上下文获取用户 / 时间)
接口作用 实现用户每日签到,返回签到结果

4.1.2 返回值 VO 设计

签到成功后,需要返回连续签到天数积分奖励 ,定义返回值 VOSignResultVO,包含积分明细(方便前端个性化展示):

java 复制代码
package com.tianji.learning.domain.vo;

import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "签到结果")
public class SignResultVO {
    @ApiModelProperty("连续签到天数")
    private Integer signDays;
    @ApiModelProperty("基础签到得分,固定为1分")
    private Integer signPoints = 1;
    @ApiModelProperty("连续签到奖励积分,仅连续7/14/28天有奖励")
    private Integer rewardPoints;

    @JsonIgnore
    public int totalPoints(){
        return signPoints + rewardPoints;
    }
}

字段说明

  • signDays:用户当前连续签到天数,核心统计字段;
  • signPoints:基础签到积分,固定为 1 分;
  • rewardPoints:连续签到奖励积分,按业务规则设置(连续 7 天 10 分、14 天 20 分、28 天 40 分);
  • totalPoints:总积分,方便后续积分发放时快速计算。

4.2 核心业务规则

  • 每个用户每天仅可签到一次,重复签到抛出业务异常;
  • 签到基础积分为 1 分,连续签到达到指定天数可获得额外奖励积分;
  • 用户 + 月份 生成独立的 Redis Key,格式为**sign:uid:{用户ID}:{年月}(如sign:uid:100:202410**),便于按月统计和管理。

4.3 代码完整实现

4.3.1 定义 Redis Key 常量

首先在RedisConstants中定义签到相关的 Key 前缀和日期格式化器,便于统一管理:

java 复制代码
public class RedisConstants {
    /**
     * 签到记录Redis Key前缀:sign:uid:{userId}:{yyyyMM}
     */
    public static final String SIGN_RECORD_KEY_PREFIX = "sign:uid:";
    /**
     * 签到Key的年月后缀格式化器:yyyyMM
     */
    public static final DateTimeFormatter SIGN_DATE_SUFFIX_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");
}

4.3.2 控制器层:SignRecordController

定义对外暴露的 REST 接口,接收签到请求并调用服务层逻辑:

java 复制代码
package com.tianji.learning.controller;

import com.tianji.learning.domain.vo.SignResultVO;
import com.tianji.learning.service.ISignRecordService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Api(tags = "签到相关接口")
@RestController
@RequestMapping("sign-records")
@RequiredArgsConstructor
public class SignRecordController {

    private final ISignRecordService recordService;

    @PostMapping
    @ApiOperation("用户每日签到接口")
    public SignResultVO addSignRecords(){
        // 调用服务层签到逻辑,返回签到结果
        return recordService.addSignRecords();
    }
}

4.3.3 服务层接口:ISignRecordService

定义服务层核心方法,解耦控制器和实现层:

java 复制代码
package com.tianji.learning.service;

import com.tianji.learning.domain.vo.SignResultVO;

public interface ISignRecordService {
    /**
     * 实现用户签到,返回签到结果
     * @return 签到结果VO
     */
    SignResultVO addSignRecords();
}

4.3.4 服务层实现:SignRecordServiceImpl

核心实现类,包含签到置位防重复签到连续签到天数统计积分奖励计算等所有业务逻辑,是整个功能的核心:

java 复制代码
package com.tianji.learning.service.impl;

import com.tianji.common.exceptions.BizIllegalException;
import com.tianji.common.utils.CollUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.learning.constants.RedisConstants;
import com.tianji.learning.domain.vo.SignResultVO;
import com.tianji.learning.service.ISignRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.List;

@Service
@RequiredArgsConstructor
public class SignRecordServiceImpl implements ISignRecordService {

    private final StringRedisTemplate redisTemplate;

    @Override
    public SignResultVO addSignRecords() {
        // 1. 获取当前登录用户ID(从用户上下文获取)
        Long userId = UserContext.getUser();
        // 2. 获取当前日期,用于拼接Key和计算offset
        LocalDate now = LocalDate.now();
        // 3. 拼接Redis Key:sign:uid:{userId}:{yyyyMM}
        String signKey = RedisConstants.SIGN_RECORD_KEY_PREFIX
                + userId
                + now.format(RedisConstants.SIGN_DATE_SUFFIX_FORMATTER);
        // 4. 计算offset:当月第N天 - 1(BitMap从0开始计数)
        int offset = now.getDayOfMonth() - 1;

        // 5. 执行签到:SETBIT置1,返回值为该位原有值(true=已签到,false=未签到)
        Boolean isExisted = redisTemplate.opsForValue().setBit(signKey, offset, true);
        if (Boolean.TRUE.equals(isExisted)) {
            // 原有值为1,说明用户已签到,抛出重复签到异常
            throw new BizIllegalException("今日已签到,不允许重复签到!");
        }

        // 6. 统计当前连续签到天数
        int continuousSignDays = countContinuousSignDays(signKey, now.getDayOfMonth());

        // 7. 计算连续签到奖励积分
        int rewardPoints = calculateRewardPoints(continuousSignDays);

        // 8. 封装签到结果VO(基础积分默认1分,无需手动设置)
        SignResultVO resultVO = new SignResultVO();
        resultVO.setSignDays(continuousSignDays);
        resultVO.setRewardPoints(rewardPoints);

        // TODO 后续扩展:积分发放(如插入积分明细、发送MQ消息通知积分服务)
        // int totalPoints = resultVO.totalPoints();

        return resultVO;
    }

    /**
     * 统计连续签到天数
     * @param signKey 签到Redis Key
     * @param dayOfMonth 当前是当月第几天
     * @return 连续签到天数
     */
    private int countContinuousSignDays(String signKey, int dayOfMonth) {
        // 1. 获取本月1号到今日的所有签到记录:BITFIELD GET u{dayOfMonth} 0
        List<Long> bitFieldResult = redisTemplate.opsForValue().bitField(
                signKey,
                BitFieldSubCommands.create()
                        // 无符号整数,读取dayOfMonth个二进制位,从offset=0开始
                        .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                        .valueAt(0)
        );
        // 无签到记录,返回0
        if (CollUtils.isEmpty(bitFieldResult)) {
            return 0;
        }
        // 2. 将返回的十进制数转为整数(存储的是签到记录的二进制转十进制结果)
        int signRecordNum = bitFieldResult.get(0).intValue();

        // 3. 遍历二进制位,统计连续签到天数(从后向前,遇到0则终止)
        int continuousCount = 0;
        while ((signRecordNum & 1) == 1) {
            // 与1做与运算,结果为1表示最后一位是1(已签到),计数器+1
            continuousCount++;
            // 无符号右移1位,舍弃最后一位,倒数第二位变为新的最后一位
            signRecordNum >>>= 1;
        }
        return continuousCount;
    }

    /**
     * 计算连续签到奖励积分
     * @param continuousDays 连续签到天数
     * @return 奖励积分
     */
    private int calculateRewardPoints(int continuousDays) {
        return switch (continuousDays) {
            case 7 -> 10;  // 连续7天,奖励10分
            case 14 -> 20; // 连续14天,奖励20分
            case 28 -> 40; // 连续28天,奖励40分
            default -> 0;  // 其他天数,无奖励
        };
    }
}

5. 核心难点解析:连续签到天数统计

连续签到天数统计是本次签到功能的核心难点 ,其实现依赖于位运算的灵活使用,这里拆解其核心思路和技术要点:

5.1 统计核心思路

  • 获取签到数据 :通过BITFIELD命令获取本月 1 号到今日的所有签到记录,返回一个十进制数(本质是签到二进制位的转义结果);
  • 从后向前遍历 :从今日对应的最后一个二进制位开始,向前判断每一位是否为 1;
  • 终止条件 :遇到第一个为 0 的二进制位(未签到)时终止遍历,此时的计数器值就是连续签到天数

5.2 关键位运算解析

遍历二进制位的核心是两个位运算,无需手动解析二进制字符串,效率极高(位运算是计算机底层操作,时间复杂度 O (1)):

  • 与 1 做与运算(num & 1 :任何整数与 1 做与运算,结果就是该数最后一个二进制位的值(0 或 1),用于判断当前位是否签到;
  • 无符号右移 1 位(num >>>= 1 :将整数的所有二进制位向右移动 1 位,舍弃最后一位,倒数第二位成为新的最后一位,实现 "从后向前遍历"。

示例 :若签到记录为11100111(对应十进制 119),统计连续天数时:

  • 第一次:119 & 1 = 1 → 计数 1,右移后为01110011(59);
  • 第二次:59 & 1 = 1 → 计数 2,右移后为00111001(29);
  • 第三次:29 & 1 = 1 → 计数 3,右移后为00011100(12);
  • 第四次:12 & 1 = 0 → 终止遍历,最终连续签到天数为 3。

6.总结拓展

BitMap 作为一种高效的二元状态数据统计思路,不仅适用于签到功能,还可广泛应用于活跃用户统计去重判断布隆过滤器等场景。掌握 Redis BitMap 的使用,能让我们在面对海量二元状态数据时,设计出更轻量、更高效的解决方案。

感兴趣的宝子可以关注一波,后续会更新更多有用的知识!!!

相关推荐
迷路爸爸1802 小时前
无sudo权限远程连接Ubuntu服务器安装TeX Live实操记录(适配VS Code+LaTeX Workshop,含路径选择与卸载方案)
java·服务器·ubuntu·latex
有梦想的攻城狮2 小时前
maven中的os-maven-plugin插件的使用
java·maven·maven插件·os-maven-plugin·classifer
宇神城主_蒋浩宇2 小时前
最简单的es理解 数据库视角看写 ES 加 java正删改查深度分页
大数据·数据库·elasticsearch
Carry灭霸2 小时前
【BUG】Redisson Connection refused 127.0.0.1
java·redis
消失的旧时光-19432 小时前
第九课实战版:异常与日志体系 —— 后端稳定性的第一道防线
java·后端
钦拆大仁2 小时前
Java设计模式-状态模式
java·设计模式·状态模式
无限码力2 小时前
华为OD技术面真题 - 数据库Redis - 1
redis·华为od·华为od技术面真题·华为od技术面八股·华为od技术面八股文·华为od技术面redis问题
人道领域2 小时前
javaWeb从入门到进阶(SpringBoot基础案例2)
java·开发语言·mybatis
BHXDML2 小时前
数据结构:(二)逻辑之门——栈与队列
java·数据结构·算法