今天我们来学习系统的签到功能实现!
为了更节省存储空间我们使用 位图(BitMap)通过操作二进制位来存储数据
redis和MySQL都使用这个
也可以看看我往期的文章
女朋友说没找到好用的画ER图工具,于是我们自己手搓了一个!🚀🚀🚀
业务背景
一个系统为了吸引用户会推出签到业务。
普通
通常我们想的是一个用户签到一次就往数据库中保存一次数据。
所以数据库设计可以为
id int 签到表主键
userId int 签到的用户
time datetime签到的时间
CREATE TABLE sign_in (
id INT AUTO_INCREMENT PRIMARY KEY,
userId INT NOT NULL,
time DATETIME NOT NULL
);
id
是INT
类型,占用 4 字节。userId
是INT
类型,占用 4 字节。time
是DATETIME
类型,占用 8 字节。
4 + 4 + 8 = 16 字节
所以一个用户签到一次需要 16个字节,一个月需要16*30 = 480个字节
如果是1w个用户签到一年可想而知这需要 多少个字节,所以这样设计肯定是不行的。
优化
因为我们只需要记录用户是否签到。我们完全没必要给每一天都保存到数据库里面去。我们可以按照计算机的那样01010101的保存。每一条记录一个月的签到情况这样大大减少了存储内存
CREATE TABLE `sys_user_sign_in` (
`user_id` bigint NOT NULL COMMENT '签到用户id',
`year` int NOT NULL COMMENT '签到年',
`month` int NOT NULL COMMENT '签到月',
`sign_in_bitmap` blob NULL COMMENT '年月日位图',
PRIMARY KEY (`user_id`, `year`, `month`) USING BTREE
)
因为我们会频繁的查看一个用户的签到状态,并且用户状态不会轻易改变。频繁查看不易改变 正好可以利用redis进行缓存。这又进一步减少了对MySQL的压力
然后redis中也有一个位图(BitMap)刚好满足我们的需求
在 Redis 中,位图(BitMap)是一种非常高效的存储结构,用来处理布尔值的数据。位图的每一位可以存储 0 或 1,通常用来表示某种状态,比如用户是否签到、某个功能是否启用等。
位图的特点:
- 节省空间:每一位只占用 1 bit,而不像普通布尔值要占用 1 byte。
- 随机访问:位图支持随机访问,可以非常高效地设置或获取某个位的数据。
- 按位操作:可以对整个位图执行按位操作,比如 AND、OR、NOT 等。
以用户签到为例,使用位图可以很高效地记录用户的签到情况:
- 如果我们有 1,000,000 用户,每个用户一个 bit,那么只需要 1,000,000 / 8 = 125,000 字节,大约 122 KB 的内存就能存储所有用户的签到信息。
- 0表示未签到1表示签到
实现步骤
声明redis键
/**
* 用户签到信息 格式:user:signIn:userId:year:month
*/
public static final String SIGN_IN_KEY_PREFIX = "user:signIn:%d:%d:%d";
通过redissonClient.getBitSet(monthKey);获取到用户签到的位图,获取到当天的时间,然后进行签到
签到操作bitSet.set(dayOfMonth, true);
如果签到成功则这个位置上面的数字会由0变成1
/**
* 用户签到操作
*
* @param userId 用户ID
* @return BaseResponse<String> 返回签到成功或失败信息
*/
@Transactional
public BaseResponse<String> userSign(Long userId) {
// ------------------1. 参数校验------------------
// ------------------2. 业务处理------------------
LocalDate now = LocalDate.now();
int dayOfMonth = now.getDayOfMonth();
String monthKey = RedisKey.SIGN_IN_KEY_PREFIX + userId + ":" + now.getYear() + ":" + now.getMonthValue();
RBitSet bitSet = redissonClient.getBitSet(monthKey);
// 检查今天是否已签到
ThrowUtils.throwIf(bitSet.get(dayOfMonth), Code.PARAM_VERIFY_FAILED, UserConstant.SIGN_IN_TODAY);
// 签到操作
bitSet.set(dayOfMonth, true);
applicationEventPublisher.publishEvent(new UserSignEvent(userId));
// ------------------3. 返回结果------------------
return ResultUtils.success(UserConstant.SIGN_IN_SUCCESS);
}
因为这里为了更好的扩展签到的业务(可能我们以后会修改签到的奖励,如果直接在这个方法体里面进行修改那就违背了 设计模式的开闭原则)所以我们把签到后的逻辑和签到进行了解耦。我们这里使用了观察者模式进行事件的监听。谁关注了订阅了那就把信息给谁。这里就不一一介绍了。详细介绍
查看是否签到
通过键获取值
/**
* 检查用户是否签到
*
* @param userId 用户ID
* @return 是否签到
*/
public BaseResponse<Boolean> checkSignIn(Long userId) {
// 获取今天是哪一天
int year = LocalDate.now().getYear(); // 获取年份
int month = LocalDate.now().getMonthValue(); // 获取月份
int day = LocalDate.now().getDayOfMonth(); // 获取当天日期
// 生成签到的键名,建议包含年月信息
String key = RedisKey.SIGN_IN_KEY_PREFIX + userId + ":" + year + ":" + month;
// 获取Redisson的位图对象
RBitSet bitSet = redissonClient.getBitSet(key);
// 如果bitSet为null或长度为0,则从MySQL加载
if (bitSet == null || bitSet.size() == 0) {
// 如果Redis中的位图为空,从MySQL恢复数据到Redis
restoreSignInDataFromMySQL(userId, year, month);
// 重新获取位图
bitSet = redissonClient.getBitSet(key);
// 再次检查位图是否为空,如果仍然为空则返回签到失败
if (bitSet == null || bitSet.size() == 0) {
return ResultUtils.success(false); // MySQL中无数据或恢复失败
}
}
// 检查当天的签到状态
boolean signedIn = bitSet.get(day);
return ResultUtils.success(signedIn);
}
/**
* 从 MySQL 恢复用户签到数据到 Redis
*
* @param userId 用户ID
* @param year 年份
* @param month 月份
*/
public void restoreSignInDataFromMySQL(long userId, int year, int month) {
// 从 MySQL 获取用户的签到位图
QueryWrapper<UserSignInEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId)
.eq("year", year)
.eq("month", month);
UserSignInEntity userSignIn = userSignInManager.selectOne(queryWrapper);
// 如果结果为空,直接返回
if (userSignIn == null) {
// 可以根据需要处理空结果,比如记录日志或返回默认值
System.out.println("在MySQL中找不到用户ID的数据: " + userId + ", year: " + year + ", month: " + month);
return;
}
byte[] signInBitmap = userSignIn.getSignInBitmap(); // 取出结果
// 恢复到 Redis,使用 Redisson 的 RBitSet 操作
String redisKey = RedisKey.SIGN_IN_KEY_PREFIX + userId + ":" + year + ":" + month;
RBitSet bitSet = redissonClient.getBitSet(redisKey);
// 将字节数组转换为位图并逐位设置
for (int i = 0; i < signInBitmap.length * 8; i++) {
if ((signInBitmap[i / 8] & (1 << (7 - i % 8))) != 0) {
bitSet.set(i + 1); // 天数从1开始
}
}
}
查看用户签到情况以及连续签到次数
/**
* 获取本月的连续签到天数
*
* @param userId 用户ID
* @param bitSet 位图签到数据
* @return 连续签到天数
*/
private int getMonthlyContinuousSignInDays(RBitSet bitSet) {
LocalDate now = LocalDate.now();
int dayOfMonth = now.getDayOfMonth();
// 获取本月的签到记录,从位图中读取
long signInData = 0;
for (int i = 1; i <= dayOfMonth; i++) {
if (bitSet.get(i)) {
signInData |= (1L << (dayOfMonth - i)); // 逐天记录签到
}
}
// 记录连续签到天数
int continuousDays = 0;
// 逐位检查
while (signInData > 0) {
if ((signInData & 1) == 0) {
break; // 如果当前位为0,表示未签到,结束
}
continuousDays++;
signInData >>= 1;
}
return continuousDays;
}
将数据同步到MySQL
@Scheduled(cron = "0 0 1 1 * ?") // 每月1号凌晨1点
public void syncAllUserSignInDataToMySQL() {
LocalDate lastMonth = LocalDate.now().minusMonths(1);
int year = lastMonth.getYear();
int month = lastMonth.getMonthValue();
// 获取所有用户的签到数据 并发处理所有用户的签到数据同步
userManager.getAllUserIds().parallelStream().forEach(userId -> userService.syncSignInDataToMySQL(userId, year, month));
}
交流学习
最后,如果这篇文章对你有所启发,请帮忙转发给更多的朋友,让更多人受益!如果你有任何疑问或想法,欢迎随时留言与我讨论,我们一起学习、共同进步。别忘了关注我,我将持续分享更多有趣且实用的技术文章,期待与你的交流!