如果说前一节的 SETBIT 只是简单的数据记录,那么这一节我们要做的,就是把底层的二进制流完整地取出来,并利用 Java 的位运算(与运算、右移运算)像剥洋葱一样,一层层算出连续签到的天数。
📚 实战篇 17. 用户签到 - 统计连续签到天数学习文档
一、 核心痛点与破解思路
业务需求: 统计当前用户,从这个月的第一天开始,到今天为止,连续签到了多少天。
- 注意关键词:“连续”。如果昨天没签到,哪怕前天签了,连续签到天数也是 0。
破解思路分两步:
-
获取历史记录: 把从本月 1 号到今天的所有签到状态(0 和 1)一口气全部拿出来。
-
位运算统计: 拿到这串二进制数字后,从最后一位(今天)开始往前倒推:
- 看看最后一位是不是 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;如果为0,说明最后一位是0。
- 任何数字与
-
无符号右移 (
>>> 1):把最后一位扔掉。- 把整个二进制数字向右移动一位,相当于把判断过的今天扔掉,明天倒退变成今天,继续下一轮判断。
🌟 逻辑推演图表:以二进制 00111 (十进制 7) 为例
| 循环轮次 | 当前二进制数字 | 执行 num & 1 判断最后一位 | 计数器 | 执行 num >>> 1 (向右移一位) |
|---|---|---|---|---|
| 第 1 轮 (查今天) | 00111 (7) | 00111 & 1 结果为 1 | 连续 1 天 | 00111 >>> 1 变成 00011 (3) |
| 第 2 轮 (查昨天) | 00011 (3) | 00011 & 1 结果为 1 | 连续 2 天 | 00011 >>> 1 变成 00001 (1) |
| 第 3 轮 (查前天) | 00001 (1) | 00001 & 1 结果为 1 | 连续 3 天 | 00001 >>> 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 开发工程师的核心业务武器库。