一.方案设计
后端方案-基于数据库
在数据库中设计一张签到表,记录用户每次签到的日期及其他相关信息。然后通过时间范围查询得到用户的签到记
录示例表结构如下:
通过唯一索引,可以确保同一用户在同一天内只能签到一次。通过下面的 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 命令行工具添加值到集合中:
使用命令查找集合中的值
该万案的
优点: Set 数据结构天然支持去重,适合存储和检索打卡记录
缺点: 上述设计显然存储了很多重复的字符串,针对海量数据场景,需要考虑内存的占用量。
其中,年份被重复存储。
为了减少内存占用,还可以在 key 中增加更多日期层级,比如 user:signins:{year}:{userId}。示例命令如下:
这样一来,不仅节约了内存,也便于管理,可以轻松查询某个用户在某个年份的签到情况。 存储 100 万个用户的 365 天 签到记录,使用 Redis 集合类型来存储每个用户的签到信息,每个用户需要大约 1880字节 的空间,总共需要大约 1.88GB 的内存空间,相比数据库节约了 10 倍左右。
有没有更节约内存的方式呢?
后端方案Redis Bitmap 位图(推荐)
Bitmap 位图,是一种使用位(bit)来表示数据的 紧凑 数据结构。每个位可以存储两个值:0 或 1,常用于表示某种
状态或标志。因为每个位仅占用1位内存,Bitmap 在大规模存储二值数据(如布尔值)时,非常高效且节约空间。
核心思想: 与其存储用户签到的具体日期,不如存储用户在今年的第 N 天是否签到。
使用位图类型存储,每个用户对应一个键,Bitmap 的 每一位 来表示用户在 某一天 是否打卡。
举个例子,我们签到的状态可以用0和1表示,0代表未签到,1代表到。 从后往前看
而 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。
2 添加刷题签到记录接口
接口逻辑:判断目前用户当天是否签到
如果已签到,则忽略
如果未签到,则在 Bitmap 中设置记录
1)因为读写 Redis 使用的是相同的 key,可以将所有 Redis 的 key 单独定义成常量,放在 constant 日录下,还可以提供拼接完整 key 的方法。代码如下:
Userservice编写接口
实现类
controller
2.2查询用户是否签到实现
实现思路:
1.通过 userld 和当前年份从 Redis 中获取对应的 Bitmap
2.获取当前年份的总天数
3.循环天数拼接日期,根据日期去 Bitmap 中判断是否有签到记录,并记录到数组中
4.最后,将拼接好的、一年的签到记录返回给前端即可
1)在 UserService 中定义接口:
2)编写实现类
为什么使用了 LinkedHashMap 而不使用HashMap?
因为LinkedHashMap保证了键值对映射的有序性,相当于直接得到了映射列表,符合前端要求的返回值格式。
而HashMap不保证元素的顺序,因为插入顺序和遍历顺序可能不同.
3) 编写Controller代码
4) 通过 Swagger 接口文档调用接口进行测试即可 使用JMeter进行压测(放最后测试了)
优化2.2查询接口
1 返回值优化
从示例结果我们可以看到 传输的数据较多、计算时间耗时、带宽占用多、效率低。
修改代码如下:
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的位置,性能更高。
修改后的代码如下:
测试
优化前
使用JMeter压测1秒钟执行200次请求,吞吐量只有73左右
性能优化后
使用JMeter压测1秒钟执行200次请求,吞吐量可达200左右
如有写的不对的地方欢迎指正交流