一、学习链接
二、学习总结
(一) 底层原理:bitmap
BitMap 在 Redis 中并不是一个新的数据类型,其底层是 Redis 实现,Redis 的位图(BitMap)是由多个二进制位组成的数组,只有两种状态,0和1, 数组中的每个二进制位都有与之对应的偏移量(从 0 开始),因此签到由day-1,通过这些偏移量可以对位图中指定的一个或多个二进制位进行操作,由于采用一个bit 来存储一个数据,因此可以大大的节省空间。
1. BitMap 能解决什么
BitMap 能解决很多问题,核心就是使用位数组节省存储空间,常见业务有用户签到、打卡、统计活跃用户、统计用户在线状态、实现布隆过滤器、数据去重、快速查找等。
BitMap是如何使用位数组节省存储空间的
在20亿个随机整数中找出某个数m是否存在其中,并假设32位操作系统,4G内存。
计算机分配给内存的最小单元是bit,在Java中,int占4字节,1字节=8位(1 byte = 8 bit)。
如果每个数字用int存储,那就是20亿个int,因而占用的空间约为 (2000000000*4/1024/1024/1024)≈7.45G
如果按位存储就不一样了,20亿个数就是20亿位,占用空间约为 (2000000000/8/1024/1024/1024)≈0.23G
2. BitMap 存储空间计算
在 Redis 中是使用字符串类型存储的,Redis 中字符串的最大长度是 512M,所以 BitMap 的 offset (偏移量)最大值为:512 * 1024 * 1024 * 8 = 2^32,也就是说一个BitMap只能存储2^32个位,差不多4.29亿。
还注意一个问题,如果我们只在一个 BitMap 偏移量为99的位置存放了一个数据,那么这个 BitMap 也是会占用100个位的内存的,0-98这些位都会被隐式地初始化为 0。
3. BitMap 存在问题
- 数据碰撞。比如将字符串映射到 BitMap 的时候会有碰撞的问题,那就可以考虑用 Bloom Filter 来解决,Bloom Filter 使用多个 Hash 函数来减少冲突的概率。
- 数据稀疏。又比如要存入(10,100000,10000000)这三个数据,我们需要建立一个 9999999 长度的 BitMap ,但是实际上只存了3个数据,这时候就有很大的空间浪费,碰到这种问题的话,可以通过引入 Roaring BitMap 来解决。
(二) Redis BitMap 操作基本语法和原生实现签到
1. 基本语法
# 设置指定偏移量上的位的值(0 或 1),语法:SETBIT key offset value
## 示例:给mykey 偏移量为9的位置设置值为1
SETBIT mykey 9 1
# 获取指定偏移量上的位的值,语法:GETBIT key offset
## 示例:获取mykey 偏移量为9上的值
GETBIT mykey 9
# 统计指定范围内所有位为1的数量 如果不指定范围则统计整个key,这个范围是以字节为单位的比如start设置成1其实代表8bit,对应偏移量是8开始,语法:BITCOUNT key [start end]
## 示例:获取mykey 所有所有位为1的数量
BITCOUNT mykey
# 在指定范围内查找第一个被设置为 1 或 0 的位,语法:BITPOS key bit [start] [end]
## 示例:查找mykey中第一个被设置为 1 的位置
BITPOS mykey 1
# 对位图的指定偏移量进行位级别的读写操作:语法:BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment]
## GET type offset 用于获取指定偏移量上的位,type 可以是 u<n>(无符号整数)或 i<n>(有符号整数),offset 是位图的偏移量。
## SET type offset value 用于设置指定偏移量上的位,type 是位的类型,offset 是位图的偏移量,value 是要设置的值。
## INCRBY type offset increment 用于递增或递减指定偏移量上的位,type 是位的类型,offset 是位图的偏移量,increment 是递增或递减的值。
## 示例:获取mykey 偏移量从 0 开始的4位无符号整数(u4 表示 4 位的无符号整数)
BITFIELD mykey GET u4 0
# 对一个或多个位图执行指定的位运算操作(AND、OR、XOR、NOT),语法:BITOP operation destkey key [key ...]
## 示例:将key1和key1进行AND运算(对应位都为 1 时结果位为 1,否则为 0),将运算后的结果保存到新的key:destkey
BITOP AND destkey key1 key2
2. Redis BitMap 实现签到操作指令
这里模拟一个月签到,日期上选择2023年11月的1 3 5号这三天签到,因为偏移量是从0开始,所以对应偏移量就是0、2、4,其余日期不签到。
1、添加用户签到位 key = USER_SIGN_IN:U0001:202311,其中U0001代表用户编号,202311代表对应年和月
127.0.0.1:6379> SETBIT USER_SIGN_IN:U0001:202311 0 1
(integer) 0
127.0.0.1:6379> SETBIT USER_SIGN_IN:U0001:202311 2 1
(integer) 0
127.0.0.1:6379> SETBIT USER_SIGN_IN:U0001:202311 4 1
(integer) 0
2、查看用户指定日期是否有签到(查看当天是否有签到同理),这里查看5号是否有签到偏移量为4,返回1则代表有签到
127.0.0.1:6379> GETBIT USER_SIGN_IN:U0001:202311 4
(integer) 1
3、查看用户2023年11月一共签到了几天
127.0.0.1:6379> BITCOUNT USER_SIGN_IN:U0001:202311
(integer) 3
4、查看用户2023年11月那些日期签到了,11月一共有30天
通过BITFIELD获取30位的无符号十进制整数,从偏移量0开始
127.0.0.1:6379> BITFIELD USER_SIGN_IN:U0001:202311 GET u30 0
1) (integer) 704643072
将获取到的无符号十进制整数转换成二进制,这里可以看到从左到右二进制的第1 3 5位置值都是1,对应偏移量0 2 4,这里不是从右到左的,然后通过业务代码判断判断这个二进制对应位为1则代表有签到,具体代码会在下面做实现。
# 十进制
704643072
# 二进制
101010000000000000000000000000
三、SpringBoot 使用 Redis BitMap 实现签到与统计功能
这里会使用SpringBoot环境RedisTemplate来操作Redis,需要集成文章可以查看,SpringBoot集成Lettuce客户端操作Redis:
SpringBoot集成Lettuce客户端操作Redis_spring boot redis lettuce 单节点-CSDN博客
(一) 代码实现
代码里注释比较完整这里就不做额外介绍了
package com.example.sign;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.YearMonth;
import java.util.*;
/**
* 签到业务
*/
@Service
public class SignInService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static String yyyy_MM_dd = "yyyy-MM-dd";
private static String yyyy_MM = "yyyy-MM";
/**
* 用户签到
* @param userNo 用户id
* @param date 日期 格式yyyy-MM-dd
*/
public boolean signIn(String userNo, String date) {
// 获取缓存key
String cacheKey = getCacheKey(userNo, date);
// 获取日期
DateTime dateTime = DateUtil.parse(date, yyyy_MM_dd);
int day = dateTime.dayOfMonth();
// 设置给BitMap对应位标记 其中offset为0表示第一天所以要day-1
Boolean result = redisTemplate.opsForValue().setBit(cacheKey, day - 1, true);
// 如果响应true则代表之前已经签到,在Redis指令操作setbit 设置对应位为1的时候,如果之前是0或者不存在会响应0,如果为1则响应1
if (result) {
System.out.println("用户userNo=" + userNo + " date=" + date + " 已签到");
}
return result;
}
/**
* 查看用户指定日期是否签到(查看当天是否有签到同理)
* @param userNo
* @param date 日期 格式yyyy-MM-dd
*/
public boolean isSignIn(String userNo, String date) {
// 获取缓存key
String cacheKey = getCacheKey(userNo, date);
// 获取日期
DateTime dateTime = DateUtil.parse(date, yyyy_MM_dd);
int day = dateTime.dayOfMonth();
return redisTemplate.opsForValue().getBit(cacheKey, day - 1);
}
/**
* 统计用户指定年月签到次数
* @param userNo
* @param date 格式yyyy-MM
*/
public Long getSignInCount(String userNo, String date) {
// 获取缓存key
String cacheKey = getCacheKey(userNo, date);
// 统计操作
Long count = redisTemplate.execute(connection -> connection.bitCount(cacheKey.getBytes()), true);
return count;
}
/**
* 获取用户指定年月签到列表,也可以通过这种方式获取用户月签到次数
*
* @param userNo 用户编号
* @param date 格式yyyy-MM
*/
public List<Map> getSignInList(String userNo, String date) {
// 获取缓存key
String cacheKey = getCacheKey(userNo, date);
// 获取传入月份有多少天
DateTime dateTime = DateUtil.parse(date, yyyy_MM);
YearMonth yearMonth = YearMonth.of(dateTime.year(), dateTime.monthBaseOne());
int days = yearMonth.lengthOfMonth();
/**
* .create():初始化一个BitFieldSubCommands实例。
* .get(BitFieldSubCommands.BitFieldType.unsigned(days)):定义一个子命令,用于获取一个无符号整数值。这里BitFieldType.unsigned(days)指定了要获取的位数,days应该是一个表示位宽度的整数值。例如,如果days是3,那么这将获取一个3位的无符号整数。
* .valueAt(0):指定了要获取的值的偏移量。在这里,偏移量是0,意味着操作将从字符串的起始位开始。
* 综合起来,这段代码创建了一个BITFIELD命令,它将获取从偏移量0开始的、位宽为days的无符号整数值。
*/
BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(days)).valueAt(0);
// 获取位图的无符号十进制整数
List<Long> list = redisTemplate.opsForValue().bitField(cacheKey, bitFieldSubCommands);
if (list == null || list.isEmpty()) {
return null;
}
// 获取位图的无符号十进制整数值
long bitMapNum = list.get(0);
// 进行位运算判断组装那些日期有签到
List<Map> result = new ArrayList<>();
for (int i = days; i > 0; i--) {
Map<String, Object> map = new HashMap<>();
map.put("day", i);
/**
* bitMapNum & 1:这是一个位与操作,它将 bitMapNum 与数字1进行位与运算。由于1的二进制表示是…0001,这个操作实际上只检查 bitMapNum 的最低位。
*如果 bitMapNum 的最低位是0,则 (bitMapNum & 1) 的结果是0。
*如果 bitMapNum 的最低位是1,则 (bitMapNum & 1) 的结果是1。
*/
if ((bitMapNum & 1) == 0) {
map.put("active", false);
} else {
//与本身不等,则最低位是1 表示已签到
map.put("active", true);
}
result.add(map);
// 将位图的无符号十进制整数右移一位,准备下一轮判断
bitMapNum >>= 1;
}
Collections.reverse(result);
return result;
}
/**
* 获取缓存key
*/
private static String getCacheKey(String userNo, String date) {
DateTime dateTime = DateUtil.parse(date, yyyy_MM);
return String.format("USER_SIGN_IN:%s:%s", userNo, dateTime.year() + "" + dateTime.monthBaseOne());
}
}
(二) 功能测试
package sign;
import com.example.RedisDemoApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.example.sign.SignInService;
import java.util.List;
import java.util.Map;
/**
* 签到功能测试
*/
@SpringBootTest(classes = RedisDemoApplication.class)
public class SignInServiceTest {
@Autowired
private SignInService signInService;
/**
* 测试用户签到
*/
@Test
public void testSignIn() {
boolean b1 = signInService.signIn("U0001", "2023-11-01");
boolean b2 = signInService.signIn("U0001", "2023-11-03");
boolean b3 = signInService.signIn("U0001", "2023-11-05");
boolean b4 = signInService.signIn("U0001", "2023-11-01");
System.out.println("b1=" + b1 + " b2=" + b2 + " b3=" + b3 + " b4=" + b4);
}
/**
* 测试查看用户指定日期是否签到(查看当天是否有签到同理)
*/
@Test
public void testIsSignIn() {
boolean b1 = signInService.isSignIn("U0001", "2023-11-01");
System.out.println(b1 ? "b1已签到" : "b1未签到");
boolean b2 = signInService.isSignIn("U0001", "2023-11-06");
System.out.println(b2 ? "b2已签到" : "b2未签到");
}
/**
* 测试统计用户指定年月签到次数
*/
@Test
public void testGetSignInCount() {
Long count = signInService.getSignInCount("U0001", "2023-11");
System.out.println("签到次数count=" + count);
}
/**
* 测试获取用户指定年月签到列表,也可以通过这种方式获取用户月签到次数
*/
@Test
public void testGetSignInList() {
List<Map> list = signInService.getSignInList("U0001", "2023-11");
if (list != null && !list.isEmpty()) {
list.forEach(item -> System.out.println(item));
}
}
}