用户签到
用户签到
BitMap 功能演示
我们针对签到功能完全可以通过 mysql 来完成,比如说以下这张表
用户一次签到,就是一条记录,假如有 1000 万用户,平均每人每年签到次数为 10 次,则这张表一年的数据量为 1 亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共 22 字节的内存,一个月则最多需要 600 多字节
我们如何能够简化一点呢?其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了
我们可以采用类似这样的方案来实现我们的签到需求。
我们按月来统计用户签到信息,签到记录为 1,未签到则记录为 0.
把每一个 bit 位对应当月的每一天,形成了映射关系。用 0 和 1 标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示
Redis 中是利用 string 类型数据结构实现 BitMap,因此最大上限是 512M,转换为 bit 则是 2^32 个 bit 位。
BitMap 的操作命令有:
- SETBIT:向指定位置(offset)存入一个 0 或 1
- GETBIT :获取指定位置(offset)的 bit 值
- BITCOUNT :统计 BitMap 中值为 1 的 bit 位的数量
- BITFIELD :操作(查询、修改、自增)BitMap 中 bit 数组中的指定位置(offset)的值
- BITFIELD_RO :获取 BitMap 中 bit 数组,并以十进制形式返回
- BITOP :将多个 BitMap 的结果做位运算(与 、或、异或)
- BITPOS :查找 bit 数组中指定范围内第一个 0 或 1 出现的位置
实现签到功能
- 获取当前用户
- 获取当前日期
- 拼接 key sign:userId:date
- 获取今天是这个月的第几天 offset
- 写入 redis SETBIT key offset 1/0
需求:实现签到接口,将当前用户当天签到信息保存到 Redis 中
思路:我们可以把年和月作为 bitMap 的 key,然后保存到一个 bitMap 中,每次签到就到对应的位上把数字从 0 变成 1,只要对应是 1,就表明说明这一天已经签到了,反之则没有签到。
我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改 bitMap。
代码:
UserController
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
UserServiceImpl
@Override
public Result sign() {
// 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.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
签到统计
- 得到到今天为止的所有签到记录 BITFIELD sign:10122208 GET u3 0,返回 的是一个十进制数字
- 遍历这个数字
- 最后一位 跟 1 进行与运算
- 若为 0,结束循坏
- 若为 1,计数器加 1
- num 无符号右移以一位
- 返回结果
**问题 1:**什么叫做连续签到天数? 从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
Java 逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非 0 的数字即可,每得到一个非 0 的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
**问题 2:**如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是 10 号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是 10 号,那么就是 10 位,去拿这段时间的数据,就能拿到所有的数据了,那么这 10 天里边签到了多少次呢?统计有多少个 1 即可。
问题 3:如何从后向前遍历每个 bit 位?
注意:bitMap 返回的数据是 10 进制,哪假如说返回一个数字 8,那么我哪儿知道到底哪些是 0,哪些是 1 呢?我们只需要让得到的 10 进制数字和 1 做与运算就可以了,因为 1 只有遇见 1 才是 1,其他数字都是 0 ,我们把签到结果和 1 进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
有用户有时间我们就可以组织出对应的 key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了
代码
UserController:
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
UserServiceImpl:
@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:5:202203 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1.让这个数字与1做与运算,得到数字的最后一个bit位 // 判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
}else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return Result.ok(count);
}
额外加餐-关于使用 bitmap 来解决缓存穿透的方案
回顾缓存穿透:
发起了一个数据库不存在的,redis 里边也不存在的数据,通常你可以把他看成一个攻击
解决方案:
判断 id<0
如果数据库是空,那么就可以直接往 redis 里边把这个空数据缓存起来
第一种解决方案:遇到的问题是如果用户访问的是 id 不存在的数据,则此时就无法生效
第二种解决方案:遇到的问题是:如果是不同的 id 那就可以防止下次过来直击数据
所以我们如何解决呢?
我们可以将数据库的数据,所对应的 id 写入到一个 list 集合中,当用户过来访问的时候,我们直接去判断 list 中是否包含当前的要查询的数据,如果说用户要查询的 id 数据并不在 list 集合中,则直接返回,如果 list 中包含对应查询的 id 数据,则说明不是一次缓存穿透数据,则直接放行。
现在的问题是这个主键其实并没有那么短,而是很长的一个 主键
哪怕你单独去提取这个主键,但是在 11 年左右,淘宝的商品总量就已经超过 10 亿个
所以如果采用以上方案,这个 list 也会很大,所以我们可以使用 bitmap 来减少 list 的存储空间
我们可以把 list 数据抽象成一个非常大的 bitmap,我们不再使用 list,而是将 db 中的 id 数据利用哈希思想,比如:
id % bitmap.size = 算出当前这个 id 对应应该落在 bitmap 的哪个索引上,然后将这个值从 0 变成 1,然后当用户来查询数据时,此时已经没有了 list,让用户用他查询的 id 去用相同的哈希算法, 算出来当前这个 id 应当落在 bitmap 的哪一位,然后判断这一位是 0,还是 1,如果是 0 则表明这一位上的数据一定不存在, 采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。