【黑马点评日记】:用户签到功能详解——从Bitmap入门到避坑指南

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

接着前面的内容,我们继续对黑马点评项目进行完善。

摘要:

本文详细介绍了黑马点评项目中用户签到功能的Redis位图实现方案。针对传统MySQL方案存在的数据量爆炸和内存浪费问题,提出使用Redis位图存储签到状态,仅需4字节/用户/月。文章重点剖析了签到功能实现中的五大关键点:Key设计策略、偏移量索引、空指针处理、位运算逻辑和无符号右移操作,并提供了完整的签到与统计连续签到天数的代码实现。通过对比MySQL和Redis方案的优缺点,强调了架构设计中的权衡思维,为高并发场景下的签到功能提供了高效解决方案。


一、 踩坑点:为什么签到功能不简单

前面我们刚刚学完SpringBoot+MySQL,可能会想:"签到不就是往数据库插一条记录,有什么难度。如果我们仅仅是这么想,我们就没有考虑到实际情况所带来的性能陷阱。

假设我们设计一张 tb_sign 表,包含 user_id, year, month, date 字段。

  • 数据量爆炸 :如果黑马点评有1000万用户,每人每月签到10次,一年就是 1亿条数据。

  • 内存浪费:签到其实只有是/否两种状态,用一条表记录(约22字节)来存一个是,太浪费了。

难点一: 如何在极其节省内存的前提下,记录海量用户的签到状态


二、 解决方案:Redis Bitmap(位图)

既然只有"是/否"两个状态,为什么不用 1个比特位 来表示呢

核心思想

把一个月(最多31天)想象成一条由31个格子组成的纸条,每天签到了就在格子里涂黑(1),没签到就留白(0)。

  • 空间计算 :一个用户一个月占用 31 bit4字节

  • 对比 :MySQL存一条记录要22字节,Bitmap只要4字节。内存占用降低约80%。


三、 Bitmap 核心命令速览

在写代码前,我们得先知道Redis中的这四个命令,否则代码会看不懂:

  1. SETBIT:将某一位设为1。

  2. GETBIT:获取某一位的值。

  3. BITFIELD:一次获取一段长度的位图数据(这是统计连续签到的关键)。

  4. BITCOUNT:统计有多少个1(总签到天数)。

四、 业务实现:签到与统计

我们将分两步走:点一下 (签到)和 看一眼(统计连续天数)。

1. 功能一:用户签到

业务流程

用户点击签到 -> 后端获取当前用户ID和日期 -> 计算偏移量 -> 将Redis中的对应位设为1。

代码详解与坑点

java 复制代码
java

@Override
public Result sign() {
    // 1. 获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2. 获取当前日期时间
    LocalDateTime now = LocalDateTime.now();
    
    // === 坑点1:Key的设计策略 ===
    // 千万不能只用一个Key存所有用户,也不能不分月份。
    // 标准格式:sign:用户ID:年月
    // 好处:方便按月清理/统计,防止Key过大。
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY + userId + keySuffix;
    
    // 3. 获取今天是本月的第几天
    // 注意:1号是第1天,但在位图中offset是从0开始的
    int dayOfMonth = now.getDayOfMonth();
    
    // === 坑点2:偏移量的索引 ===
    // 如果今天是1号,dayOfMonth=1,但位图第0位才代表1号。
    // 所以必须 dayOfMonth - 1
    int offset = dayOfMonth - 1;
    
    // 4. 写入Redis
    // true 表示签到(即设置为1),false表示未签到(0)
    stringRedisTemplate.opsForValue().setBit(key, offset, true);
    
    return Result.ok();
}
2. 功能二:统计连续签到天数

这是最难的部分,也是面试最爱问的。需求是:计算当前用户截止今天,连续签到了几天?

核心逻辑

从今天(最后一位)往前数,遇到第一个0就停。

技术难点

如何一次性拿到本月1号到今天的所有签到数据?不能循环调用30次GETBIT,那样效率太差。

解决方案BITFIELD 命令。

  • 命令:BITFIELD key GET u[天数] 0

  • 解释:u 表示无符号整数。它会返回一个十进制数字,这个数字的二进制表示就是我们这N天的签到情况。

代码详解与避坑

java 复制代码
java

@Override
public Result signCount() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    String key = USER_SIGN_KEY + userId + now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    int dayOfMonth = now.getDayOfMonth();

    // 1. 获取本月的签到十进制数
    // 这里指定了 unsigned(dayOfMonth),意思是取从0到dayOfMonth位的值
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
        key,
        BitFieldSubCommands.create()
            .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
            .valueAt(0)
    );

    // === 坑点3:空指针与默认值 ===
    // 如果用户从来没签过到,result可能为null或空集合,此时直接返回0
    if (result == null || result.isEmpty()) {
        return Result.ok(0);
    }
    
    Long num = result.get(0);
    if (num == null || num == 0) {
        return Result.ok(0);
    }

    // 2. 核心算法:位运算计数
    // 此时 num 是一个十进制数,比如 3 (二进制 11) 代表前两天都签到了。
    int count = 0;
    while (true) {
        // === 坑点4:与运算逻辑 ===
        // (num & 1) 是取出二进制最右边的一位
        // 如果是 0,说明这天没签到,中断循环
        if ((num & 1) == 0) {
            break;
        } else {
            count++;
        }
        // === 坑点5:无符号右移 ===
        // 必须使用 >>> 而不是 >>
        // 因为 >> 会在左边补符号位(如果是负数补1),导致死循环。
        // >>> 无论正负,左边都补0,保证数据正确归零。
        num >>>= 1;
    }
    return Result.ok(count);
}

五、 深度避坑点总结

  1. 月份变化Key 中必须包含年月(yyyyMM)。如果不包含月份,跨月时位图会乱套(2月的28号和3月的1号会冲突)。

  2. 索引对齐 :Redis位图的 offset 是从 0 开始的,而现实中日期是从 1 开始的。dayOfMonth - 1 这步绝对不能省。

  3. 右移运算符 :在统计连续天数时,一定要用 >>>(无符号右移)。如果用 >>(有符号右移),一旦数字是负数,高位会一直补1,导致 num 永远不等于0,程序死循环

  4. BITFIELD 的返回类型unsigned 一定要用对。如果用有符号 signed,当最高位为1时,数字会变成负数,破坏后面的位运算逻辑。

六、 追问

问: 如果要统计年度连续签到奖励(比如连续7天送积分),该怎么改造

答:

  1. 连续7天检测 :可以在签到成功后(或使用定时任务),利用 BITFIELD 获取最近7天的数据组成二进制串。判断 1111111(十进制127)是否等于该串。

  2. 跨月连续:这是难点。比如 9月30日 和 10月1日 连续。我的思路是:当查询今天(如10月1日)连续签到时,如果发现10月1日签到了但10月之前的位断了,就递归/循环去查上个月的位图,看上个月月底是否是签满的,直到遇到未签到的那一天。

七、 整体回顾

通过这个签到功能,我们希望帮助你建立一种意识:架构设计就是做选择题

  • MySQL:逻辑简单,但面对海量数据,IO压力大,资源浪费严重。

  • Redis Bitmap :逻辑稍复杂(需要位运算),但内存占用极低(一个用户一个月仅占4字节),适合高并发场景。

结语:

如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
woxihuan1234561 天前
SQL删除数据时存在依赖关系_设置外键级联删除ON DELETE
jvm·数据库·python
东风破1371 天前
DM8达梦共享存储集群DSC搭建步骤
数据库·学习·dm达梦数据库
雪碧聊技术1 天前
当数据库字段数大于Java实体类属性数时,MyBatis还能映射成功吗?一文详解
数据库·自动映射·mybatis映射机制·java实体类·宽容映射机制
Jetev1 天前
如何确定SQL字段是否为空_使用IS NULL与IS NOT NULL
jvm·数据库·python
摇滚侠1 天前
Redis 秒杀功能 超卖问题 一人一单问题 分布式锁 精彩!精彩!
redis·分布式·bootstrap
m0_702036531 天前
mysql如何处理不走索引的OR查询_使用UNION ALL优化重写
jvm·数据库·python
庞轩px1 天前
第七篇:Spring扩展点——如何优雅地介入Bean的创建流程
java·后端·spring·bean·aware·扩展点
ltl1 天前
Q/K/V 三件套:把 Bahdanau 抽象成一个公式
后端
代钦塔拉1 天前
Qt4 vs Qt5 带参数信号槽的连接方式详解
开发语言·数据库·qt
2401_846339561 天前
MySQL在云环境如何选择存储类型_SSD与高性能云盘配置建议
jvm·数据库·python