java用户签到的几种实现方法以及性能优化

一.方案设计
后端方案-基于数据库

在数据库中设计一张签到表,记录用户每次签到的日期及其他相关信息。然后通过时间范围查询得到用户的签到记

录示例表结构如下: 

CREATE TABLE user_sign_in (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,  -- 主键,自动递增
  userId BIGINT NOT NULL,               -- 用户ID,关联用户表
  signDate DATE NOT NULL,            -- 签到日期
  createdTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,  -- 记录创建时间
  UNIQUE KEY uq_user_date (userId, signDate)  -- 用户ID和签到日期的唯一性约束
);

通过唯一索引,可以确保同一用户在同一天内只能签到一次。通过下面的 SQL 即可查询用户的签到记录:

java代码:

实现简单这里不做演示

优点:原理简单,容易实现,适用于用户量较小的系统。

缺点:随着用户量和数据量增大,对数据库的压力增大,直接查询数据库性能较差。除了单接口的响应会增加,可能整个系统都会被其拖垮。

试想一下,每天1万个用户签到,1 个月就 30 万条数据,3 个月就接近百万的数据量了,占用硬盘空间大概 50 MB。存储 100 万个用户 365 天的签到记录,需要 17.52 GB 左右。

后端方案-基于缓存 Redis Set

可以利用内存缓存加速读写,常用的本地缓存是 Caffeine,分布式缓存是 Redis。由于每个用户会有多个签到记

录,很适合使用 Redis 的 Set 类型存储,每个用户对应一个键,Set 内的每个元素为签到的具体日期。

Redis Key 的设计为:user:signins: {userId}

其中:

  • user是业务领域前缀
  • siqnins 是具体操作或功能
  • {userld} 表示每个用户,是动态值

如果 Redis 被多个项日公用,还可以在开头增加项目前缀区分,比如 mianshiniu:user:signins:fuserId}。

扩展知识:Redis 键设计规范

  • 明确性: 键名称应明确表示数据的含义和结构。例如,通过使用 signins 可以清楚地知道这个键与用户的签到记录有关。
  • 层次结构: 使用冒号 :分隔不同的部分,可以使键结构化,便于管理和查询,
  • 唯一性: 确保键的唯一性,避免不同数据使用相同的键前缀。
  • 一致性: 在整个系统中保持键设计的一致性,使得管理和维护变得更加简单。
  • 长度: 避避免过长的键名称,以防影响性能和存储效率。.

具体示例如下,可以使用 Redis 命令行工具添加值到集合中:

SADD user:signins:123 "2024-09-01"
SADD user:signins:123 "2024-09-02"

使用命令查找集合中的值

SMEMBERS user:signins:123

该万案的

优点: Set 数据结构天然支持去重,适合存储和检索打卡记录

缺点: 上述设计显然存储了很多重复的字符串,针对海量数据场景,需要考虑内存的占用量。

key = user:signins:123
value = ["2024-09-01", "2024-09-02", "2024-10-01", "2024-10-02"]

其中,年份被重复存储。

为了减少内存占用,还可以在 key 中增加更多日期层级,比如 user:signins:{year}:{userId}。示例命令如下:

SADD user:signins:2024:123 "09-01"
SADD user:signins:2024:123 "10-01"

这样一来,不仅节约了内存,也便于管理,可以轻松查询某个用户在某个年份的签到情况。 存储 100 万个用户的 365 天 签到记录,使用 Redis 集合类型来存储每个用户的签到信息,每个用户需要大约 1880字节 的空间,总共需要大约 1.88GB 的内存空间,相比数据库节约了 10 倍左右。

有没有更节约内存的方式呢?

后端方案Redis Bitmap 位图(推荐)

Bitmap 位图,是一种使用位(bit)来表示数据的 紧凑 数据结构。每个位可以存储两个值:0 或 1,常用于表示某种

状态或标志。因为每个位仅占用1位内存,Bitmap 在大规模存储二值数据(如布尔值)时,非常高效且节约空间。

核心思想: 与其存储用户签到的具体日期,不如存储用户在今年的第 N 天是否签到。

2024-01-01 =>1(第-天)
2024-01-03=>3(第三天)

使用位图类型存储,每个用户对应一个键,Bitmap 的 每一位 来表示用户在 某一天 是否打卡。

举个例子,我们签到的状态可以用0和1表示,0代表未签到,1代表到。 从后往前看

0101 表示第 1 天和第 3 天已签到
1010 表示第 2 天和第 4 天已签到

而 int 类型占用的空间为4个字节(byte),一个字节占8位(bit),即一个 int 占 32 位。在这种仅存储二值(0 或 1)的场景,就可以使用 Bitmap 位图来优化存储,因为一个 bit 就可以表示0和 1。把 int 优化成用 bit 存储,那么占用的空间可以优化 32 倍!假设原先占用的大小需要 32G,那么改造后仅需1G。

这里需要注意:

现代计算机体系结构通常以字节(8位)作为最小寻址单位,那么上述的 bit 是如何存储的呢?

答案就是 打包.

通过将多个 bit 打包到一个字节(或者其他更大的数据类型,如 int、long)中来实现的。每个字节(或数据类型)被视

为一个桶,里面可以存放若干个布尔值(0 或 1)。

对每一位操作时,要使用位运算进行访问

对于刷题签到记录场景,一个用户存储一年的数据仅需占用 46 字节,因为 46*8= 368,能覆盖 365 天的记录。那一百万用户也才占用 43.8 MB,相比于 Redis set 结构节约了 40 多倍存储空间!

1000w 个用户也才占用 438 MB! 

当然,我们没必要自己通过 int 等类型实现 Bitmap,JDK 自带了 BitSet类、Redis 也支持 Bitmap 高级数据结构。考虑到项目的分布式、可扩展性,采用 Redis 的 Bitmap 实现。

Redis Key 的设计为: user:signins:{年份}:{userId}

在 Java 程序中,还可以使用 Redisson 库提供的现成的 RBitSet,对应的redis也就是Bitmap 开发成本也很低.

这种方案

  • 优点: 内存占用极小,适合大规模用户和日期的场景。
  • 缺点 :需要熟悉位图操作,不够直观。

总结一下:

  • 基于性能的考虑,我们选用 Redis 中间件来存储用户的签到记录
  • 基于空间的考虑,我们选用 Bitmap 数据结构来存储用户的签到记录
二.java实现
2.1签到接口实现

1 在 config 目录下编写 Redisson 客户端配置类,会自动读取项目中的 Redis 配置,初始化客户端 Bean。


@Configuration
//会读取配置文件下spring.redis.* 开头的配置
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {

    private String host;

    private Integer port;

    private Integer database;

    private String password;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
        .setAddress("redis://" + host + ":" + port)
        .setDatabase(database)
        .setPassword(password);
        return Redisson.create(config);
    }
}

2 添加刷题签到记录接口

接口逻辑:判断目前用户当天是否签到

如果已签到,则忽略

如果未签到,则在 Bitmap 中设置记录

1)因为读写 Redis 使用的是相同的 key,可以将所有 Redis 的 key 单独定义成常量,放在 constant 日录下,还可以提供拼接完整 key 的方法。代码如下:

public interface RedisConstant {

    /**
     * 用户签到记录的 Redis Key 前缀
     */
    String USER_SIGN_IN_REDIS_KEY_PREFIX = "user:signins";

    /**
     * 获取用户签到记录的 Redis Key
     * @param year   年份
     * @param userId 用户 id
     * @return 拼接好的 Redis Key
     */
    //%s表示会被一个字符串值替换,%d表示会被一个整数值替换
    static String getUserSignInRedisKey(int year, long userId) {
        return String.format("%s:%d:%d", USER_SIGN_IN_REDIS_KEY_PREFIX, year, userId);
    }

}

Userservice编写接口

/**
 * 添加用户签到记录
 *
 * @param userId 用户 id
 * @return 当前是否已签到成功
 */
boolean addUserSignIn(long userId);

实现类

@Override
public boolean addUserSignIn(long userId) {
//获取当前时间
LocalDate date = LocalDate.now();
//拼接redis key
String key = RedisConstant.getUserSignInRedisKey(date.getYear(), userId);
//获取redis的操作对象
RBitSet bitSet = redissonClient.getBitSet(key);
//获取当前日期是一年中的第几天,作为偏移量
int offSet = date.getDayOfYear();
//判断redis中key对应的天数是否已经签到 true就是已签到
if (!bitSet.get(offSet)){
    //能查到就是true因为加了!取反 就签到
    bitSet.set(offSet,true); //true就是已签到
}

//当查到是未签到flase时不走if判断,直接改为true
return true;
}

controller

@PostMapping("/add/sign_in")
public BaseResponse<Boolean> addUserSignIn(HttpServletRequest request) {
//获取用户登录状态
User loginUser = userService.getLoginUser(request);
boolean res = userService.addUserSignIn(loginUser.getId());

return ResultUtils.success(res);
}
2.2查询用户是否签到实现

实现思路:

1.通过 userld 和当前年份从 Redis 中获取对应的 Bitmap

2.获取当前年份的总天数

3.循环天数拼接日期,根据日期去 Bitmap 中判断是否有签到记录,并记录到数组中

4.最后,将拼接好的、一年的签到记录返回给前端即可

1)在 UserService 中定义接口:

/**
 * 获取用户某个年份的签到记录
 *
 * @param userId 用户 id
 * @param year   年份(为空表示当前年份)
 * @return 签到记录映射
 */
Map<LocalDate, Boolean> getUserSignInRecord(long userId, Integer year);

2)编写实现类

@Override
public Map<LocalDate, Boolean> getUserSignIn(long userId, Integer year) {
        //先判断年份是否为空
        if (year == null) {
            LocalDate date = LocalDate.now();
            year = date.getYear();
        }
        //根据当前年和用户id获取key
        String key = RedisConstant.getUserSignInRedisKey(year, userId);
        //获取redis的BitMap
        RBitSet signInBitSet = redissonClient.getBitSet(key);
        //注意   加载bitSet到内存中,避免后续读取时发送多次请求
        BitSet bitSet = signInBitSet.asBitSet();
        //这里使用LinKefHashMap是因为放入和取出的顺序有序
        Map<LocalDate, Boolean> res = new LinkedHashMap<>();
        //获取当年的总天数
        int totalDays = Year.of(year).length();
        //依次遍历获取每一天的签到状态
        for (int i = 1; i <= totalDays; i++) {
            //获取key 当前日期
            LocalDate currentDate = LocalDate.ofYearDay(year, i);
            //获取value 当天是否签到
            boolean b = bitSet.get(i);
            //结果放入到map
            res.put(currentDate, b);
        }
        return res;
    }
//Map<LocalDate, Boolean> res = new LinkedHashMap<>();

为什么使用了 LinkedHashMap 而不使用HashMap?

因为LinkedHashMap保证了键值对映射的有序性,相当于直接得到了映射列表,符合前端要求的返回值格式。

而HashMap不保证元素的顺序,因为插入顺序和遍历顺序可能不同.

3) 编写Controller代码

/**
 * 获取用户签到记录
 *
 * @param year    年份(为空表示当前年份)
 * @param request
 * @return 签到记录映射
 */
@GetMapping("/get/sign_in")
public BaseResponse<Map<LocalDate, Boolean>> getUserSignInRecord(Integer year, HttpServletRequest request) {
    // 必须要登录才能获取
    User loginUser = userService.getLoginUser(request);
    Map<LocalDate, Boolean> userSignInRecord = userService.getUserSignInRecord(loginUser.getId(), year);
    return ResultUtils.success(userSignInRecord);
}

4) 通过 Swagger 接口文档调用接口进行测试即可 使用JMeter进行压测(放最后测试了)

优化2.2查询接口

1 返回值优化

从示例结果我们可以看到 传输的数据较多、计算时间耗时、带宽占用多、效率低。

修改代码如下:

@Override
public List<Integer> getUserSignIn(long userId, Integer year) {
    //先判断年份是否为空
    if (year == null) {
        LocalDate date = LocalDate.now();
        year = date.getYear();
    }
    //根据当前年和用户id获取key
    String key = RedisConstant.getUserSignInRedisKey(year, userId);
    //获取redis的BitMap
    RBitSet signInBitSet = redissonClient.getBitSet(key);
    //注意   加载bitSet到内存中,避免后续读取时发送多次请求
    BitSet bitSet = signInBitSet.asBitSet();
    //这里使用LinKefHashMap是因为放入和取出的顺序有序
    List<Integer> list = new ArrayList<>();
    //获取当年的总天数
    int totalDays = Year.of(year).length();
     for (int i = 1; i <= totalDays; i++) {
        //获取value 当天是否签到
        boolean b = bitSet.get(i);
        if (b) {
            //签到就把已经签到的天放到集合中 第3天签到 第5天签到
            list.add(i);
        }
    }
    return list;
}

3、计算优化

上述代码中,我们使用循环来遍历所有年份,而循环是需要消耗 CPU 计算资源的。在Java 中的 Bitset 类中,可以使用 nextsetBit(int fromIndex)和 nextclearBit(int fromIndex)方法来获取从指定索引开始的下一个 已设置(即为 1)或 未设置(即为 0)的位。主要是2 个方法:

nextsetBit(int fromIndex):从 fromIndex 开始(包括 fromIndex 本身)寻找下一个被设置为1的位。如果找到了,返回该位的索引;如果没有找到,返回-1。

nextclearBit(int fromIndex):从 fromIndex 开始(包括 fromIndex 本身)寻找下一个为0的位。如果找到了返回该位的索引:如果没有找到,返回一个大的整数值。

使用 nextSetBit,可以跳过无意义的循环检查,通过位运算来获取被设置为1的位置,性能更高。

修改后的代码如下:


@Override
public List<Integer> getUserSignIn(long userId, Integer year) {
    //先判断年份是否为空
    if (year == null) {
        LocalDate date = LocalDate.now();
        year = date.getYear();
    }
    //根据当前年和用户id获取key
    String key = RedisConstant.getUserSignInRedisKey(year, userId);
    //获取redis的BitMap
    RBitSet signInBitSet = redissonClient.getBitSet(key);
    //注意   加载bitSet到内存中,避免后续读取时发送多次请求
    BitSet bitSet = signInBitSet.asBitSet();
    //这里使用LinKefHashMap是因为放入和取出的顺序有序
    List<Integer> list = new ArrayList<>();
    //获取当年的总天数
    int totalDays = Year.of(year).length();
    //依次遍历获取每一天的签到状态 优化
    //从索引0开始查找下一个设置为1的位置的索引 查找不到返回-1即不等于-1就一直循环
    int index = bitSet.nextSetBit(0);
    while (index != -1) {
        list.add(index);
        //找到下一个设置为1的位置 索引位置要+1
        index = bitSet.nextSetBit(index + 1);
    }
    return list;
}
测试

优化前

使用JMeter压测1秒钟执行200次请求,吞吐量只有73左右

性能优化后

使用JMeter压测1秒钟执行200次请求,吞吐量可达200左右

 如有写的不对的地方欢迎指正交流

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值