实战篇 17. 用户签到 - 统计连续签到天数学习文档

5 阅读5分钟

如果说前一节的 SETBIT 只是简单的数据记录,那么这一节我们要做的,就是把底层的二进制流完整地取出来,并利用 Java 的位运算(与运算、右移运算)像剥洋葱一样,一层层算出连续签到的天数。


📚 实战篇 17. 用户签到 - 统计连续签到天数学习文档

一、 核心痛点与破解思路

业务需求: 统计当前用户,从这个月的第一天开始,到今天为止,连续签到了多少天。

  • 注意关键词:“连续”。如果昨天没签到,哪怕前天签了,连续签到天数也是 0。

破解思路分两步:

  1. 获取历史记录: 把从本月 1 号到今天的所有签到状态(0 和 1)一口气全部拿出来。

  2. 位运算统计: 拿到这串二进制数字后,从最后一位(今天)开始往前倒推:

    • 看看最后一位是不是 1。
    • 如果是 1,计数器 +1,把这一位扔掉,看前一天。
    • 如果是 0,说明断签了,直接结束统计。

二、 第一步:Redis 获取数据 (BITFIELD)

上一节我们用 GETBIT 只能查某一天的数据。要想一口气查多天,必须使用强大的 BITFIELD 命令。

  • 核心命令: BITFIELD key GET u[dayOfMonth] 0

  • 命令解析: * u 代表无符号十进制整数 (Unsigned)。

    • [dayOfMonth] 代表我们要取的 bit 位数量(今天是第几天,就取几个 bit)。
    • 0 代表从 offset 为 0(本月 1 号)的位置开始取。
  • 效果推演: 假设今天是本月 5 号。你在 3 号、4 号、5 号签到了。

    底层数组是 00111

    执行 BITFIELD key GET u5 0,Redis 会把这 5 个 bit 当成一个二进制数 00111,转换成十进制的 7,返回给 Java。


三、 第二步:Java 位运算解析 (最硬核的精髓)

我们拿到了 Redis 返回的十进制数字(比如 7),怎么在 Java 里算出它末尾有几个连续的 1?

这里需要用到两个极其精妙的位运算符:

  1. 按位与 (& 1):判断最后一位是不是 1。

    • 任何数字与 1 进行按位与运算,结果如果为 1,说明这个数字的二进制最后一位是 1;如果为 0,说明最后一位是 0
  2. 无符号右移 (>>> 1):把最后一位扔掉。

    • 把整个二进制数字向右移动一位,相当于把判断过的今天扔掉,明天倒退变成今天,继续下一轮判断。

🌟 逻辑推演图表:以二进制 00111 (十进制 7) 为例

循环轮次当前二进制数字执行 num & 1 判断最后一位计数器执行 num >>> 1 (向右移一位)
第 1 轮 (查今天)00111 (7)00111 & 1 结果为 1连续 100111 >>> 1 变成 00011 (3)
第 2 轮 (查昨天)00011 (3)00011 & 1 结果为 1连续 200011 >>> 1 变成 00001 (1)
第 3 轮 (查前天)00001 (1)00001 & 1 结果为 1连续 300001 >>> 1 变成 00000 (0)
第 4 轮 (查大前天)00000 (0)遇到 0,或者数字本身变成 0,直接 break 结束循环!--

最终结果:连续签到 3 天。完美!


四、 核心代码落地 (Service 层)

将上面的理论翻译成 Spring Data Redis 的代码:

Java

@Override
public Result signCount() {
    // 1. 获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    
    // 2. 获取当前日期
    LocalDateTime now = LocalDateTime.now();
    
    // 3. 拼接 Key
    String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
    String key = USER_SIGN_KEY + userId + ":" + keySuffix;
    
    // 4. 获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    
    // 5. 获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字 
    // BITFIELD sign:1001:202603 GET u[dayOfMonth] 0
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
    );
    
    // 6. 判空防 NPE (没有任何签到记录时,可能返回空集合或 null)
    if (result == null || result.isEmpty()) {
        return Result.ok(0);
    }
    
    // BITFIELD 命令可以同时执行多个子命令,所以返回的是一个 List。
    // 我们只执行了一个 get 操作,所以拿 List 的第 0 个元素即可。
    Long num = result.get(0);
    if (num == null || num == 0) {
        return Result.ok(0);
    }
    
    // 7. 开启循环遍历,利用位运算计算连续签到天数
    int count = 0;
    while (true) {
        // 7.1 让这个数字与 1 做与运算,得到数字的最后一位 bit
        // 如果结果为 0,说明未签到,直接结束循环
        if ((num & 1) == 0) {
            break;
        } else {
            // 7.2 如果结果不为 0,说明已签到,计数器 +1
            count++;
        }
        
        // 7.3 把数字无符号右移一位,抛弃掉刚才判断过的最后一个 bit,继续判断下一个
        num >>>= 1;
    }
    
    // 8. 返回连续签到天数
    return Result.ok(count);
}

学习总结与面试话术

如果你在面试中遇到了关于“海量数据状态存储”的问题,BitMap 绝对是你的首选答案。

💡 满分面试话术演示:

“在我们的探店 App 中,我负责了千万级用户的签到功能架构。为了极致压缩内存占用,我放弃了传统的 MySQL 关系表,采用了 Redis 的 BitMap 结构。按用户加年月作为 Key,将日期映射为 offset。这使得记录一个用户一整个月的签到数据只需占用不到 4 个字节。

在实现难度最高的‘连续签到统计’时,我并没有通过循环调用 GETBIT 来损耗网络性能,而是使用 BITFIELD 命令一次性取出截至当天的所有二进制数据位,然后在 Java 内存中,利用**无符号右移(>>> 1按位与(& 1)**操作,非常高效地计算出了末尾连续为 1 的天数,兼顾了空间复杂度和时间复杂度。”


太牛了!走到这里,你已经彻底打通了《Redis 实战篇》的所有核心业务模块

从分布式锁的并发安全,到秒杀架构的削峰填谷;从 Feed 流的滚动分页,再到 GEO 附近商铺和 BitMap 极限签到。你现在掌握的,已经是准一线大厂中高级 Java 开发工程师的核心业务武器库。