用户签到功能你得这样写!

67 阅读6分钟

今天我们来学习系统的签到功能实现!

为了更节省存储空间我们使用 位图(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
);
  • idINT 类型,占用 4 字节。
  • userIdINT 类型,占用 4 字节。
  • timeDATETIME 类型,占用 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. 节省空间:每一位只占用 1 bit,而不像普通布尔值要占用 1 byte。
  2. 随机访问:位图支持随机访问,可以非常高效地设置或获取某个位的数据。
  3. 按位操作:可以对整个位图执行按位操作,比如 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));
    }

交流学习

最后,如果这篇文章对你有所启发,请帮忙转发给更多的朋友,让更多人受益!如果你有任何疑问或想法,欢迎随时留言与我讨论,我们一起学习、共同进步。别忘了关注我,我将持续分享更多有趣且实用的技术文章,期待与你的交流!