【黑马点评日记】:用户签到功能详解——从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 bit≈4字节。
对比:MySQL存一条记录要22字节,Bitmap只要4字节。内存占用降低约80%。
三、 Bitmap 核心命令速览
在写代码前,我们得先知道Redis中的这四个命令,否则代码会看不懂:
SETBIT:将某一位设为1。
GETBIT:获取某一位的值。
BITFIELD:一次获取一段长度的位图数据(这是统计连续签到的关键)。
BITCOUNT:统计有多少个1(总签到天数)。
四、 业务实现:签到与统计
我们将分两步走:点一下(签到)和看一眼(统计连续天数)。
1. 功能一:用户签到
业务流程:
用户点击签到 -> 后端获取当前用户ID和日期 -> 计算偏移量 -> 将Redis中的对应位设为1。
代码详解与坑点:
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 @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); }五、 深度避坑点总结
月份变化:
Key中必须包含年月(yyyyMM)。如果不包含月份,跨月时位图会乱套(2月的28号和3月的1号会冲突)。索引对齐:Redis位图的
offset是从0开始的,而现实中日期是从1开始的。dayOfMonth - 1这步绝对不能省。右移运算符:在统计连续天数时,一定要用
>>>(无符号右移)。如果用>>(有符号右移),一旦数字是负数,高位会一直补1,导致num永远不等于0,程序死循环。BITFIELD 的返回类型:
unsigned一定要用对。如果用有符号signed,当最高位为1时,数字会变成负数,破坏后面的位运算逻辑。
六、 追问
问:如果要统计年度或连续签到奖励(比如连续7天送积分),该怎么改造
答:
连续7天检测:可以在签到成功后(或使用定时任务),利用
BITFIELD获取最近7天的数据组成二进制串。判断1111111(十进制127)是否等于该串。跨月连续:这是难点。比如 9月30日 和 10月1日 连续。我的思路是:当查询今天(如10月1日)连续签到时,如果发现10月1日签到了但10月之前的位断了,就递归/循环去查上个月的位图,看上个月月底是否是签满的,直到遇到未签到的那一天。
七、 整体回顾
通过这个签到功能,我们希望帮助你建立一种意识:架构设计就是做选择题。
用MySQL:逻辑简单,但面对海量数据,IO压力大,资源浪费严重。
用Redis Bitmap:逻辑稍复杂(需要位运算),但内存占用极低(一个用户一个月仅占4字节),适合高并发场景。
结语:
如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!
