当前位置: 首页 > news >正文

Java常见业务场景之大量数据存储优化:从 Mysql 到 Redis Bitmap ,实现高效存储用户签到记录

摘要:本文围绕用户刷题签到记录功能展开,先依次对比 MYSQL、Redis Set、Redis Bitmap 三种实现方案的优缺点;接着基于 Redisson中的 RBitSet 解决问题,并针对Redis 交互次数过多、数据传输量大、CPU 循环消耗等问题,提出三大优化方案,实现高性能的签到记录系统。

用户签到记录日历

需求分析

每个用户有自己的签到记录,具体拆解为 2 个子需求:

1. 用户每日首次浏览题目,算作是签到,会记录在系统中。

2. 用户可以在前端以图表的形式查看自己在某个年份的刷题签到记录(每天是否有签到)。


常见场景:

github中的 commit 统计                                        打卡网站

                                                                                        

方案设计 - 后端

初级方案:基于数据库

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

示例表结构如下:

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 即可查询用户的签到记录:

SELECT signDate FROM user_sign_in 
WHERE userId = ? AND signDate BETWEEN ?AND ?;

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

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

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

进阶方案 :基于 Redis Set

可以利用内存缓存加速读写,常用的本地缓存是 Caffeine,分布式缓存是 Redis。

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

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

其中:

  • user 是业务领域前缀

  • signins 是具体操作或功能

  • 【userId】表示每个用户,是动态值

如果 Redis 被多项目公用,还可以在开头增加项目前缀区分,如 hmall:user:signins:{userId}

💡 扩展知识:Redis 键设计规范

  • 明确性:键名称应明确表示数据的含义和结构。例如,通过使用 signins 可以清楚地知道这个键与用户的签到记录有关。

  • 层次结构:使用冒号:分隔不同的部分,可以使键结构化,便于管理和查询。

  • 唯一性:确保键的唯一性,避免不同数据使用相同的键前缀。

  • 一致性:在整个系统中保持键设计的一致性,使得管理和维护变得更加简单。

  • 长度:避免过长的键名称,以防影响性能和存储效率。

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

SADD user:signins:{userId} "YYYY-MM-DD"
​
SADD user:signins:1 "2024-09-02"

使用命令查找集合中的值:

SMEMBERS user:signins:{userId}SMEMBERS user:signins:1

优点: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:{year}:{userId} "MM-DD"
​
SADD user:signins:2024:1 "10-01"

这样一来,不仅节约了内存,也便于管理,可以轻松查询某个用户在某个年份的签到情况。

💡 存储 100 万个用户的年签到记录,使用 Redis 集合类型来存储每个用户的签到信息,每个用户需要大约 1880 字节的空间,总共需要大约 1.88GB 的内存空间,相比数据库节约了 10 倍左右。

高阶方案 :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 天已签到

如果不用 Bitmap,最传统的方式,我们可以先试着用 int 类型来存储签到状态:

int status = 0; // 未签到int status = 1; // 已签到

而 int 类型占用的空间为 4 个字节(byte),一个字节占 8 位(bit),即一个 int 占 32 位。

在这种存储二值(01)的场景,就可使用 Bitmap 位图来优化,因为一个 bit 就可以表示 0 和 1。

把 int 优化成用 bit 存储,那么占用的空间可以优化 32 倍。如图:

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

答案就是 打包 

计算机硬件的设计规则是:最小能直接操作的存储单位是 "字节(Byte)",1 个字节 = 8 个二进制位(bit)。但我们存 "布尔值"时,每个布尔值只需要 1 个 bit。

如果不打包,会发生什么?

  • 存 1 个布尔值,要占用 1 个字节(8bit),但只用了 1bit,剩下 7bit 全浪费;
  • 存 8 个布尔值,要占用 8 个字节(64bit),浪费 7 个字节 —— 这在需要存大量布尔值的场景(比如用户权限、状态标记)里,会造成巨大的空间浪费。

"打包" 就为解决此矛盾:把多个 bit(比如 8 个)塞进 1 个字节里,让空间利用率翻 8 倍

对每一位操作时,要使用位运算进行访问,所以上述的图实际应该改成:

💡 对于此场景,一个用户存储一年的数据仅需占用 46 字节(46 * 8 = 368 ≈365),能覆盖一年的记录。那一百万用户也才占用 43.8 MB,相比于 Redis Set 结构节约了 40 多倍存储空间

Redis 的 Bitmap 实现

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

设置某一个 bit 值的命令如下:

-- 表示用户在第 n 天打卡
SETBIT user:signins:2024:{userId} n 1-- 表示用户在第 240 天打卡
SETBIT user:signins:2024:1 240 1

查询某一个 bit 值的命令:

GETBIT user:signins:2024:123 240

在 Java 程序中,还可以使用 Redisson 库提供的现成的 RBitSet,开发成本也很低。

这种方案的优点:内存占用极小,适合大规模用户和日期的场景。

缺点:需要熟悉位图操作,不够直观。

总结一下

1.基于性能的考虑,我们选用 Redis 中间件来存储用户的签到记录。

2.基于空间的考虑,我们选用 Bitmap 数据结构来存储用户的签到记录。

方案设计 - 前端

要明确前端展示签到记录日历所需的数据类型,后端才好设计接口的返回值,因此方案设计阶段要考虑全面。

复杂的展示组件肯定不用自己开发,只要是图表(可视化),就可以优先考虑使用选择基础日历图,不涉及热力数值的区分(只有 0 和 1 签到 / 未签到的区别):

可以通过官方的 Demo 观察所需的数据格式,官方生成数据的循环代码如下:

for (let time = date; time <= end; time += dayTime) {
data.push([echarts.time.format(time, '{yyyy}-{MM}-{dd}', false),Math.floor(Math.random() * 10000)]);
}

显然,得到的数据是一个二维数组,每个元素表示一个日期和对应的数值:

[['2017-01-01', 3456],['2017-01-02', 8975],...
]

但回归我们的项目,用 Bitmap 每天最多只有一次记录,相当于只有 0 和 1。因此可以调整 Apache ECharts 图表的配置来调整热力值的范围,从而控制颜色深浅。还支持调整颜色:

visualMap: {show: false,min: 0,max: 1,inRange: {color: ['#efefef', 'lightgreen']  // 颜色从灰色到浅绿色},
},

效果如图:

因此,后端只需要返回下列结构即可:

[['2024-01-01', 0],['2024-01-02', 1],...
]

后端开发

需要开发 2 个接口:

  1. 添加刷题签到记录

  2. 查询刷题签到记录

在此之前,需要先引入 Redisson 依赖,以实现 Bitmap 存储。

1. 引入 Redisson

Redisson 是一个基于 Redis 的开源分布式 Java 数据库客户端,提供了类似 Java 标准库的数据结构在分布式环境下的实现。它不仅支持基本的 Redis 操作,还提供了高级功能,如分布式锁、同步器、限流器、缓存等,简化了在分布式系统中使用 Redis 进行数据共享和并发控制的复杂性。

1)引入 Redisson 依赖:

<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.21.0</version>
</dependency>

2)编写 Redisson 客户端配置:

@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {private String host;private Integer port;private Integer database;private String password;@Beanpublic RedissonClient redissonClient() {Config config = new Config();config.useSingleServer().setAddress("redis://" + host + ":" + port).setDatabase(database).setPassword(password);return Redisson.create(config);}
}

3) yml 中补充 Redis 配置:

spring:data:redis:host: 127.0.0.1port: 6379database: 0password:ttl: 3600

2. 添加刷题签到记录接口

触发时机:已登录用户进入题目详情页时,调用接口,触发签到。

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

  • 如果已签到,则忽略

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

1)RedisConstant类:

public interface RedisConstant {/*** 用户签到记录的 Redis Key 前缀*/String USER_SIGN_IN_REDIS_KEY_PREFIX = "user:signins";/*** 获取用户签到记录的 Redis Key* @param year 年份* @param userId 用户 id* @return 拼接好的 Redis Key*/static String getUserSignInRedisKey(int year, long userId) {return String.format("%s:%s:%s", USER_SIGN_IN_REDIS_KEY_PREFIX, year, userId);}}

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

2)在 UserService 中编写接口:

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

编写实现类:

/*** 添加用户签到记录** @param userId 用户签到* @return 当前是否已签到成功*/
public boolean addUserSignIn(long userId) {LocalDate date = LocalDate.now();String key = RedisConstant.getUserSignInRedisKey(date.getYear(), userId);//通过指定的 key,从Redis中获取一个位图对象RBitSet signInBitSet = redissonClient.getBitSet(key);// 获取当前日期是一年中的第几天,作为偏移量(从 1 开始计数)int offset = date.getDayOfYear();// 检查当天是否已经签到if (!signInBitSet.get(offset)) {// 如果当天还未签到,则设置return signInBitSet.set(offset, true);}// 当天已签到return true;
}

3)在 Controller 中编写 API 接口:

/*** 添加用户签到记录** @param request* @return 当前是否已签到成功*/
@PostMapping("/add/sign_in")
public BaseResponse<Boolean> addUserSignIn(HttpServletRequest request) {// 必须要登录才能签到User loginUser = userService.getLoginUser(request);boolean result = userService.addUserSignIn(loginUser.getId());return ResultUtils.success(result);
}

💡 思考:这个接口的签到操作能否异步执行呢?

3. 查询刷题签到记录接口

实现思路:

  1. 通过 userId 和当前年份从 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> getUserSignInRecord(long userId, Integer year) {if (year == null) {LocalDate date = LocalDate.now();year = date.getYear();}String key = RedisConstant.getUserSignInRedisKey(year, userId);RBitSet signInBitSet = redissonClient.getBitSet(key);// LinkedHashMap 保证有序Map<LocalDate, Boolean> result = new LinkedHashMap<>();// 获取当前年份的总天数int totalDays = Year.of(year).length();// 依次获取每一天的签到状态for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) {// 获取 key:当前日期LocalDate currentDate = LocalDate.ofYearDay(year, dayOfYear);// 获取 value:当天是否有刷题boolean hasRecord = signInBitSet.get(dayOfYear);// 将结果放入 mapresult.put(currentDate, hasRecord);}return result;
}

性能优化

1. 优化判断逻辑

循环内部需要判断当天是否有刷题,实际上每次判断都会去与 Redis 交互,一个循环需要交互 365 次 Redis,效率极低。要优化这段代码,核心是减少与 Redis 的交互次数(减少到 1 次),通过一次性获取所有需要的位数据到本地,再在本地循环处理。

// 依次获取每一天的签到状态
for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) {// 获取 key:当前日期LocalDate currentDate = LocalDate.ofYearDay(year, dayOfYear);// 获取 value:当天是否有刷题boolean hasRecord = signInBitSet.get(dayOfYear);// 将结果放入 mapresult.put(currentDate, hasRecord);
}

具体来说,signInBitSet 是通过 Redisson 客户端与 Redis 交互的 RBitSet 对象,而 RBitSet.get (int bitIndex) 这个方法会触发一次 Redis 请求来获取对应位的值,并没有在本地做缓存。

通过 Wirshark 等抓包工具可以看到,客户端发了一大堆请求给 redis 实例。仔细观察右下角的抓包数据,可以看到执行的操作:

因此,我们在循环外缓存一下 Bitmap 的数据,即可大大提升这个方法的效率:


// 加载 BitSet 到内存中,避免后续读取时发送多次请求
BitSet bitSet = signInBitSet.asBitSet();

循环内部使用 bitSet.get 即可:

// 获取 value:当天是否有刷题
boolean hasRecord = bitSet.get(dayOfYear);

2. 优化返回值

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

实际不必完全组装好数据传输给前端,仅需告诉前端哪天刷题(大部分同学不会每天都刷题),这样能大大减少传输的数据量以及后端服务的 CPU 占用,将部分计算压力均摊到用户的客户端。

修改代码如下:

@Override
public List<Integer> getUserSignInRecord(long userId, Integer year) {if (year == null) {LocalDate date = LocalDate.now();year = date.getYear();}String key = RedisConstant.getUserSignInRedisKey(year, userId);RBitSet signInBitSet = redissonClient.getBitSet(key);// 加载 BitSet 到内存中,避免后续读取时发送多次请求BitSet bitSet = signInBitSet.asBitSet();// 统计签到的日期List<Integer> dayList = new ArrayList<>();// 获取当前年份的总天数int totalDays = Year.of(year).length();// 依次获取每一天的签到状态for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) {// 获取 value:当天是否有刷题boolean hasRecord = bitSet.get(dayOfYear);if (hasRecord) {dayList.add(dayOfYear);}}return dayList;
}

3. 计算优化

上述代码中,我们使用循环来遍历所有年份,而循环是需要消耗 CPU 计算资源的。

在 Java 中的 BitSet 类中,可以使用 nextSetBit 和 nextClearBit 方法来获取从指定索引开始的下一个 已设置(1) 或 未设置(0) 的位。

主要是 2 个方法:

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

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

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

修改后的代码如下:

@Override
public List<Integer> getUserSignInRecord(long userId, Integer year) {if (year == null) {LocalDate date = LocalDate.now();year = date.getYear();}String key = RedisConstant.getUserSignInRedisKey(year, userId);RBitSet signInBitSet = redissonClient.getBitSet(key);// 加载 BitSet 到内存中,避免后续读取时发送多次请求BitSet bitSet = signInBitSet.asBitSet();// 统计签到的日期List<Integer> dayList = new ArrayList<>();// 从索引 0 开始查找下一个被设置为 1 的位int index = bitSet.nextSetBit(0);while (index >= 0) {dayList.add(index);// 查找下一个被设置为 1 的位index = bitSet.nextSetBit(index + 1);}return dayList;
}

得到结果示例如下:

[1, 226]

注意,需要同步修改 Controller 接口返回值。



优化小结

本功能的性能优化也是有代表性的,总结出来几个实用优化思路:

  • 减少网络请求或调用次数

  • 减少接口传输数据的体积

  • 减少不必要的循环和计算

  • 客户端处理逻辑分摊服务器端压力


大功告成!

http://www.dtcms.com/a/464992.html

相关文章:

  • 朝阳企业网站建设方案费用东莞公司注册
  • 光影魔术手_4.7.2.1192|win中文|图像修改编辑器|安装教程
  • c++ bug 记录(merge函数调用时错误地传入了vector对象而非迭代器。)
  • 珠海网站专业制作wordpress 折线图
  • css、dom 性能优化方向
  • 【大前端】Android Deep Link 技术详解与实践指南
  • Linux操作系统如何使用ISO镜像文件来搭建本地镜像源?
  • UMI 中使用qiankun问题记录
  • 演示和解读ChatGPT App SDK,以后Android/iOS App不用开发了?
  • Spring Boot 与 WebSocket:长连接掉线、心跳与消息广播的问题
  • 数琨创享:新能源行业标杆企业QMS质量管理平台案例
  • 如何用easyui做网站网站设计说明书5000字
  • 从MySQL到ClickHouse超大规模数据分析的架构迁移实践与性能对比
  • 架构图在什么网站可以做wordpress-saas
  • echarts不根据传入参数,自定义 legend 的内容(视觉映射)
  • H3C IRF
  • 【C++】继承深度解析:继承方式和菱形虚拟继承的详解
  • 徐州 网站 备案 哪个公司做的好phpcms 中英文网站
  • WebSocket | 一点简单了解
  • 算法题基础 : Java : BufferedReader、BufferedWriter 和 StringTokenizer 详解
  • 企业微信 自建应用审批流程引擎功能开发【报错分析】
  • Slf4j 接口文档左侧菜单有显示,但是点击后空白
  • 【AES加密专题】4.Sbox的解析和生成
  • 考完HCIE数通,能转云计算 / 安全 / AI方向吗?
  • 重庆企业网站建设推荐怎么申请域名和备案
  • 松江 网站建设公司拼多多推广联盟
  • 中国极端气象干旱事件(1951-2022)
  • 一文详解Go 语言内存逃逸(Escape Analysis)
  • 学习threejs,实现粒子化交互文字
  • 密码学基础:RSA与AES算法的实现与对比