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 个接口:
添加刷题签到记录
查询刷题签到记录
在此之前,需要先引入 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. 查询刷题签到记录接口
实现思路:
通过 userId 和当前年份从 Redis 中获取对应的 Bitmap
获取当前年份的总天数
循环天数拼接日期,根据日期去 Bitmap 中判断是否有签到记录,并记录到数组中
最后,将拼接好的、一年的签到记录返回给前端
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 接口返回值。
优化小结
本功能的性能优化也是有代表性的,总结出来几个实用优化思路:
减少网络请求或调用次数
减少接口传输数据的体积
减少不必要的循环和计算
客户端处理逻辑分摊服务器端压力
大功告成!