数据库结构
积分记录的目的有两个:一个是统计用户当日某一种方式获取的积分是否达到上限;一个是统计积分排行榜。
排行肯定是有时间/赛季的 所以我们需要创建一个实体 用来记录赛季信息
- 赛季名称
- 赛季开始时间
- 赛季结束时间
排行榜也不复杂,核心要素包括:
- 用户id
- 本赛季当前积分
- 本赛季当前排名
当然,由于要区分赛季,还应该关联赛季信息:
- 赛季id
签到功能
BitMap
如果要签到就可以利用这个命令,例如这个月的第1、2、3、6、7、8几天签到了,就可以这样:
# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1
# 第6天签到
SETBIT bm 5 1
# 第7天签到
SETBIT bm 6 1
# 第8天签到
SETBIT bm 7 1
那如果我们要查询签到记录怎么办
BITFIELD key GET encoding offset
- key:就是BitMap的key
- GET:代表查询
- encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
- u:无符号整数,例如 u2,代表读2个bit位,转为无符号整数返回
- i:又符号整数,例如 i2,代表读2个bit位,转为有符号整数返回
- offset:从第几个bit位开始读取,例如0:代表从第一个bit位开始
例如,我想查询从第1天到第3天的签到记录,可以这样:
可以看到,返回的结果是7. 为什么是7呢?
签到记录是 11100111,从0开始,取3个bit位,刚好是111,转无符号整数,刚好是7
了便于统计,我们计划每个月为每个用户生成一个独立的KEY,因此KEY中必须包含用户信息、月份信息,长这样:
sign:uid:xxx:202401
连续签到统计
如何得到连续签到天数?需要下面几步:
- 获取本月到今天为止的所有签到数据
- 从今天开始,向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数
如图:
不过存在个问题,如何遍历bit位
如何从后向前遍历每一个bit位?
-
如何找到并获取签到记录中最后一个bit位
- 任何数与1做与运算,得到的结果就是它本身。因此我们让签到记录与1做与运算,就得到了最后一个bit位
-
如何移除这个bit位
- 把数字右移一位,最后一位到了小数点右侧,由于我们保留整数,最后一位自然就被丢弃了
private int countSignDays(String key, int len) {
// 1.获取本月从第一天开始,到今天为止的所有签到记录
List<Long> result = redisTemplate.opsForValue()
.bitField(key, BitFieldSubCommands.create().get(
BitFieldSubCommands.BitFieldType.unsigned(len)).valueAt(0));
if (CollUtils.isEmpty(result)) {
return 0;
}
int num = result.get(0).intValue();
// 2.定义一个计数器
int count = 0;
// 3.循环,与1做与运算,得到最后一个bit,判断是否为0,为0则终止,为1则继续
while ((num & 1) == 1) {
// 4.计数器+1
count++;
// 5.把数字右移一位,最后一位被舍弃,倒数第二位成了最后一位
num >>>= 1;
}
return count;
}
积分功能
由积分规则可知,获取积分的行为多种多样,而且每一种行为都有自己的独立业务。而这些行为产生的时候需要保存一条积分明细到数据库。
我们显然不能要求其它业务的开发者在开发时帮我们新增一条积分记录,这样会导致原有业务与积分业务耦合。因此必须采用异步方式,将原有业务与积分业务解耦。
如果有必要,甚至可以将积分业务抽离,作为独立微服务。
最后保存的积分记录格式如下:
CREATE TABLE IF NOT EXISTS `points_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '积分记录表id',
`user_id` bigint NOT NULL COMMENT '用户id',
`type` tinyint NOT NULL COMMENT '积分方式:1-课程学习,2-每日签到,3-课程问答, 4-课程笔记,5-课程评价',
`points` tinyint NOT NULL COMMENT '积分值',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_user_id` (`user_id`,`type`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习积分记录,每个月底清零';
我们需要知道:
- userId:用户信息,必须传递
- type:积分类型,由于不同类型通过不同的RoutingKey来发送,通过RoutingKey可以判断积分类型,无需传递
- points:积分值,积分值也随积分方式变化,无需传递
- createTime:时间,就是当前时间,无需传递
综上,在MQ中我们只需要传递用户id一个参数即可。
但是签到需要传入points
保存积分明细
保存到数据库之前,我们需要判断积分上限
如何判断呢?
因此,我们需要在保存到数据库之前,先判断是否超过积分上限。
如何判断呢?
第一,我们要知道积分上限。这个在积分类型枚举中定义了,算是已知。
第二,我们要知道用户今天已经得了多少分。这个就要去数据库查询统计了。
排行榜功能
实时排行榜
榜单分为两类:
- 实时榜单:也就是本赛季的榜单
- 历史榜单:也就是历史赛季的榜单
本节我们先分析一下实现实时榜单功能。
要想形成排行榜,我们在查询数据库时,需要先对用户分组,再对积分求和,最终按照积分和排序,Sql语句是这样:
SELECT user_id, SUM(points) FROM points_record GROUP BY user_id ORDER BY SUM(points)
要知道,每个用户都可能会有数十甚至上百条积分记录,当用户规模达到百万规模,可能产生的积分记录就是数以亿计。
要在每次查询排行榜时,在内存中对这么多数据做分组、求和、排序,对内存和CPU的占用会非常恐怖,不太靠谱。
那该怎么办呢?
在这里介绍两种不同的实现思路:
- 方案一:基于MySQL的离线排序
- 方案二:基于Redis的SortedSet
首先说方案一:简单来说,就是将数据库中的数据查询出来,在内存中自己利用算法实现排序,而后将排序得到的榜单保存到数据库中。但由于这个排序比较复杂,我们无法实时更新排行榜,而是每隔几分钟计算一次排行榜。这种方案实现起来比较复杂,而且实时性较差。不过优点是不会一直占用系统资源。
再说方案二:Redis的SortedSet底层采用了跳表的数据结构,因此可以非常高效的实现排序功能,百万用户排序轻松搞定。而且每当用户积分发生变更时,我们可以实时更新Redis中的用户积分,而SortedSet也会实时更新排名。实现起来简单、高效,实时性也非常好。缺点就是需要一直占用Redis的内存,当用户量达到数千万万时,性能有一定的下降。
当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶,例如:
0~100分、101~200分、201~300分、301~500分、501~800分、801~1200分、1201~1500分、1501~2000分
在Redis内为每个桶创建一个SortedSet类型的key,这样就可以将数据分散,减少单个KEY的数据规模了。而要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值比他高的桶的用户数量即可。依然非常简单、高效。
综上,我们推荐基于Redis的SortedSet来实现排行榜功能。
在Redis中,使用SortedSet结构,以赛季的日期为key,以用户id为member,以积分和为score. 每当用户新增积分,就累加到score中,SortedSet排名就会实时更新。这样一个实时的当前赛季榜单就出现了。
实时榜单:
@Override
public List<PointsBoard> queryCurrentBoardList(String key, Integer pageNo, Integer pageSize) {
// 1.计算分页
int from = (pageNo - 1) * pageSize;
// 2.查询
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet()
.reverseRangeWithScores(key, from, from + pageSize - 1);
if (CollUtils.isEmpty(tuples)) {
return CollUtils.emptyList();
}
// 3.封装
int rank = from + 1;
List<PointsBoard> list = new ArrayList<>(tuples.size());
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
String userId = tuple.getValue();
Double points = tuple.getScore();
if (userId == null || points == null) {
continue;
}
PointsBoard p = new PointsBoard();
p.setUserId(Long.valueOf(userId));
p.setPoints(points.intValue());
p.setRank(rank++);
list.add(p);
}
return list;
}
历史排行榜
积分排行榜是分赛季的,每一个月是一个赛季。因此每到每个月的月初,就会进入一个新的赛季。所有用户的积分应该清零,重新累积。
但是,我们能把Redis中的榜单数据直接清空吗?显然不行!Redis中的榜单数据是上个月的数据,属于历史榜单了,直接清空就丢失了一个赛季的数据。
因此,我们必须将Redis中的历史数据持久化到数据库中,然后再清零。如图:
不过,这里就有一个问题需要解决:
假如有数百万用户,这就意味着每个赛季榜单都有数百万数据。随着时间推移,历史赛季越来越多,如果全部保存到一张表中,数据量会非常恐怖!
该怎么办呢?
海量数据存储
分区
表分区(Partition) 是一种数据存储方案,可以解决单表数据较多的问题。MySQL5.1开始支持表分区功能。
如果表数据过多,就会导致文件体积非常大。文件就会跨越多个磁盘分区,数据检索时的速度就会非常慢。
为了解决这个问题,MySQL在5.1版本引入表分区功能。简单来说,就是按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。从物理上来看,一张表的数据被拆到多个表文件存储了;从逻辑上来看,他们对外表现是一张表。
例如,我们的历史榜单数据,可以按照赛季切分
表分区的本质是对数据的水平拆分,而拆分的方式也有多种,常见的有:
- Range分区:按照指定字段的取值范围分区
- List分区:按照指定字段的枚举值分区,必须提前指定好所有的分区值,如果数据找不到分区会报错
- Hash分区:基于字段做hash运算后分区,一般做hash运算的字段都是数值类型
- Key分区:根据指定字段的值做运算的结果分区,与hash分区类似,但不限定字段类型
分表
水平分表
这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。
由于分表是开发者的行为,因此拆分方式更加灵活。除了水平分表,也可以做垂直分表。
垂直分表
如果一张表的字段非常多,比如达到30个以上,这样的表我们称为宽表。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。
一张表就变成了两张表。而且两张表的结构不同,数据也不同。这种按照字段拆分表的方式,称为垂直拆分。
分库和集群
无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:
- 单点故障问题:数据库发生故障,整个系统就会瘫痪
- 单库的性能瓶颈问题:单库受服务器限制,其网络带宽、CPU、连接数都有瓶颈
- 单库的存储瓶颈问题:单库的磁盘空间有上限,如果磁盘过大,数据检索的速度又会变慢
这种模式的优缺点:
优点:
- 解决了海量数据存储问题,突破了单机存储瓶颈
- 提高了并发能力,突破了单机性能瓶颈
- 避免了单点故障
缺点:
- 成本非常高
- 数据聚合统计比较麻烦
- 主从同步的一致性问题
- 分布式事务问题
历史排行榜的存储策略
看业务数据量来 一般用户不会超过百分级别,所以我们分表就可以了,然后我们还可以优化表结构 , 可以让id作为我们的排序字段,然后赛季id不需要 因为用表名就可以区分
不过这就存在一个问题,每个赛季要有不同的表,这些表什么时候创建呢?
显然,应该在每个赛季刚开始的时候(月初)来创建新的赛季榜单表。每个月的月初执行一个创建表的任务,我们可以利用定时任务来实现。
由于表的名称中包含赛季id,因此在定时任务中我们还要先查询赛季信息,获取赛季id,拼接得到表名,最后创建表。
大概流程如图:
给上个月榜单做持久化
榜单持久化
榜单持久化的基本流程是这样的:
- 创建表
- 持久化Redis数据到数据库
- 清理Redis数据
@XxlJob("savePointsBoard2DB")
public void savePointsBoard2DB(){
// 1.获取上月时间
LocalDateTime time = LocalDateTime.now().minusMonths(1);
// 2.计算动态表名
// 2.1.查询赛季信息
Integer season = seasonService.querySeasonByTime(time);
// 2.2.将表名存入ThreadLocal
TableInfoContext.setInfo(POINTS_BOARD_TABLE_PREFIX + season);
// 3.查询榜单数据
// 3.1.拼接KEY
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
// 3.2.查询数据
int pageNo = 1;
int pageSize = 1000;
while (true) {
List<PointsBoard> boardList = pointsBoardService.queryCurrentBoardList(key, pageNo, pageSize);
if (CollUtils.isEmpty(boardList)) {
break;
}
// 4.持久化到数据库
// 4.1.把排名信息写入id
boardList.forEach(b -> {
b.setId(b.getRank().longValue());
b.setRank(null);
});
// 4.2.持久化
pointsBoardService.saveBatch(boardList);
// 5.翻页
pageNo++;
}
// 任务结束,移除动态表名
TableInfoContext.remove();
}
XXL-JOB 任务分片
刚才定义的定时持久化任务,通过while死循环,不停的查询数据,直到把所有数据都持久化为止。这样如果数据量达到数百万,交给一个任务执行器来处理会耗费非常多时间。
因此,将来肯定会将学习服务多实例部署,这样就会有多个执行器并行执行。**但是,**如果交给多个任务执行器,大家执行相同代码,都从第1页逐页处理数据,又会出现重复处理的情况。
怎么办?
这就要用到任务分片的方案了。
最终,每个执行器处理的数据页情况:
- 执行器1:处理第1、4、7、10、13、...页数据
- 执行器2:处理第2、5、8、11、14、...页数据
- 执行器3:处理第3、6、9、12、15、...页数据
要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:
- 起始页码:pageNo
- 下一页的跨度:step
而这两个参数是有规律的:
- 起始页码:执行器编号是多少,起始页码就是多少
- 页跨度:执行器有几个,跨度就是多少。也就是说你要跳过别人读取过的页码
因此,现在的关键就是获取两个数据:
- 执行器编号
- 执行器数量
@XxlJob("savePointsBoard2DB")
public void savePointsBoard2DB(){
// 1.获取上月时间
LocalDateTime time = LocalDateTime.now().minusMonths(1);
// 2.计算动态表名
// 2.1.查询赛季信息
Integer season = seasonService.querySeasonByTime(time);
// 2.2.存入ThreadLocal
TableInfoContext.setInfo(POINTS_BOARD_TABLE_PREFIX + season);
// 3.查询榜单数据
// 3.1.拼接KEY
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
// 3.2.查询数据
int index = XxlJobHelper.getShardIndex();
int total = XxlJobHelper.getShardTotal();
int pageNo = index + 1; // 起始页,就是分片序号+1
int pageSize = 10;
while (true) {
List<PointsBoard> boardList = pointsBoardService.queryCurrentBoardList(key, pageNo, pageSize);
if (CollUtils.isEmpty(boardList)) {
break;
}
// 4.持久化到数据库
// 4.1.把排名信息写入id
boardList.forEach(b -> {
b.setId(b.getRank().longValue());
b.setRank(null);
});
// 4.2.持久化
pointsBoardService.saveBatch(boardList);
// 5.翻页,跳过N个页,N就是分片数量
pageNo+=total;
}
TableInfoContext.remove();
}
任务链
总结
排行榜怎么设计实现的?
排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。
因为产品设计是每个月为一个赛季,月初清零积分记录,这样学员就有持续的动力去学习。这就有了赛季的概念,因此也就有了当前赛季榜单和历史榜单的区分,其实现思路也不一样。
首先当前赛季榜单,我们采用了Redis的SortedSet来实现。member是用户id,score就是当月积分总值。每当用户产生积分行为的时候,获取积分时,就会更新score值。这样Redis就会自动形成榜单了。非常方便且高效。
然后历史榜单,历史榜单肯定是保存到数据库了。不过由于数据过多,所以需要对数据做水平拆分,我们目前的思路是按照赛季来拆分,也就是每一个赛季的榜单单独一张表。这样做有几个好处:
- 拆分数据时比较自然,无需做额外处理
- 查询数据时往往都是按照赛季来查询,这样一次只需要查一张表,不存在跨表查询问题
因此我们就不需要用到分库分表的插件了,直接在业务层利用MybatisPlus就可以实现动态表名,动态插入了。简单高效。
我们会利用一个定时任务在每月初生成上赛季的榜单表,然后再用一个定时任务读取Redis中的上赛季榜单数据,持久化到数据库中。最后再有一个定时任务清理Redis中的历史数据。
这里要说明一下,这里三个任务是有关联的,之所以让任务分开定义,是为了避免任务耦合。这样在部分任务失败时,可以单独重试,无需所有任务从头重试。
当然,最终我们肯定要确保这三个任务的执行顺序,一定是依次执行的。
Redis的SortedSet来保存榜单数据,如果用户量非常多怎么办?
首先Redis的SortedSet底层利用了跳表机制,性能还是非常不错的。即便有百万级别的用户量,利用SortedSet也没什么问题,性能上也能得到保证。在我们的项目用户量下,完全足够。
当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶。
然后为每个桶创建一个SortedSet类型的key,这样就可以将数据分散,减少单个KEY的数据规模了。
而要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值范围比他高的桶的用户数量即可。依然非常简单、高效。
处理数百万的榜单数据时任务是如何分片的?如何确保多个任务依次执行的呢?
XXL-JOB自带任务分片广播机制,每一个任务执行器都能通过API得到自己的分片编号、总分片数量。在做榜单数据批处理时,按照分页查询的方式:
- 每个执行器的读取的起始页都是自己的分片编号+1,例如第一个执行器,其起始页就是1,第二个执行器,其起始页就是2,以此类推
- 然后不是逐页查询,而是有一个页的跨度,跨度值就是分片总数量。例如分了3片,那么跨度就是3
此时,第一个分片处理的数据就是第1、4、7、10、13等几页数据,第二个分片处理的就是第2、5、8、11、14等页的数据,第三个分片处理的就是第3、6、9、12、15等页的数据。
这样就能确保所有数据都会被处理,而且每一个执行器都执行的是不同的数据了。
最后,要确保多个任务的执行顺序,可以利用XXL-JOB中的子任务功能。比如有任务A、B、C,要按照字母顺序依次执行,我们就可以将C设置为B的子任务,再将B设置为A的子任务。然后给A设置一个触发器。
这样,当A触发时,就会依次执行这三个任务了。
评论区