SpringCloud天机学堂:实时排行榜功能
文章目录
1、实时排行榜
榜单分为两类:
- 实时榜单:也就是本赛季的榜单
- 历史榜单:也就是历史赛季的榜单
本节我们先分析一下实现实时榜单功能。
1.1.思路分析
目前,我们有一个积分记录明细表,结构如下:
一个用户可能产生很多条积分记录,数据结构大概像这样:
id | userId | type | points | c_time |
---|---|---|---|---|
1 | 9527 | 1 | 10 | |
2 | 9528 | 4 | 3 | |
3 | 9529 | 2 | 1 | |
4 | 9528 | 2 | 7 | |
5 | 9529 | 4 | 3 | |
6 | 9528 | 2 | 1 | |
7 | 9527 | 1 | 10 | |
8 | 9529 | 4 | 3 | |
9 | 9527 | 3 | 5 |
要想形成排行榜,我们在查询数据库时,需要先对用户分组,再对积分求和,最终按照积分和排序,Sql语句是这样:
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来实现排行榜功能。
SortedSet的常用命令,可以参考官网:
https://redis.io/commands/?group=sorted-set
1.2.生成实时榜单
既然要使用Redis的SortedSet来实现排行榜,就需要在用户每次积分变更时,累加积分到Redis的SortedSet中。因此,我们要对之前的新增积分功能做简单改造,如图中绿色部分:
在Redis中,使用SortedSet结构,以赛季的日期为key,以用户id为member,以积分和为score. 每当用户新增积分,就累加到score中,SortedSet排名就会实时更新。这样一个实时的当前赛季榜单就出现了。
1.2.1.定义Redis的KEY前缀
在tj-learning
的RedisConstants
中定义一个新的KEY前缀:
注意,KEY的后缀是时间戳,我们最好定义一个DateTimeFormatter
,方便后期使用。因此,我们需要修改tj-commom
中的DateUtils
,添加一个DateTimeFormatter
的常量:
1.2.2.更新积分到Redis
接下来,我们改造tj-learning中的com.tianji.learning.service.impl.PointsRecordServiceImpl
,首先注入StringRedisTemplate
:
然后,改造其中的addPointsRecord
方法,添加积分到Redis中:
Java
@Override
public void addPointsRecord(Long userId, int points, PointsRecordType type) {
LocalDateTime now = LocalDateTime.now();
int maxPoints = type.getMaxPoints();
// 1.判断当前方式有没有积分上限
int realPoints = points;
if(maxPoints > 0) {
// 2.有,则需要判断是否超过上限
LocalDateTime begin = DateUtils.getDayStartTime(now);
LocalDateTime end = DateUtils.getDayEndTime(now);
// 2.1.查询今日已得积分
int currentPoints = queryUserPointsByTypeAndDate(userId, type, begin, end);
// 2.2.判断是否超过上限
if(currentPoints >= maxPoints) {
// 2.3.超过,直接结束
return;
}
// 2.4.没超过,保存积分记录
if(currentPoints + points > maxPoints){
realPoints = maxPoints - currentPoints;
}
}
// 3.没有,直接保存积分记录
PointsRecord p = new PointsRecord();
p.setPoints(realPoints);
p.setUserId(userId);
p.setType(type);
save(p);
// 4.更新总积分到Redis
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
redisTemplate.opsForZSet().incrementScore(key, userId.toString(), realPoints);
}
1.3.查询积分榜
在个人中心,学生可以查看指定赛季积分排行榜(只显示前100 ),还可以查看自己总积分和排名。而且排行榜分为本赛季榜单和历史赛季榜单。
我们可以在一个接口中同时实现这两类榜单的查询。
1.3.1.分析和设计接口
首先,我们来看一下页面原型(这里我给出的是原型对应的设计稿,也就是最终前端设计的页面效果):
首先我们分析一下请求参数:
- 榜单数据非常多,不可能一次性查询出来,因此这里一定是分页查询(滚动分页),需要分页参数。
- 由于要查询历史榜单需要知道赛季,因此参数中需要指定赛季id。当赛季id为空,我们认定是查询当前赛季。这样就可以把两个接口合二为一。
然后是返回值,无论是历史榜单还是当前榜单,结构都一样。分为两部分:
- 当前用户的积分和排名。当前用户不一定上榜,因此需要单独查询
- 榜单数据。就是N个用户的积分、排名形成的集合。
综上,接口信息如下:
接口说明 | 查询指定赛季的积分排行榜以及当前用户的积分和排名信息 |
---|---|
请求方式 | GET |
请求路径 | /boards |
请求参数 | 分页参数,例如PageNo、PageSize赛季id,为空或0时,代表查询当前赛季。否则就是查询历史赛季 |
返回值 | { "rank": 8, // 当前用户的排名 "points": 21, // 当前用户的积分值 [ { "rank": 1, // 排名 "points": 81, // 积分值 "name": "Jack" // 姓名 }, { "rank": 2, // 排名 "points": 74, // 积分值 "name": "Rose" // 姓名 } ] } |
1.3.2.实体类
查询积分排行榜接口中包括3个实体:
- 查询条件QUERY实体
- 分页返回结果VO实体
- 分页中每一条数据的VO实体
这些在课前资料中都提供好了。
首先是QUERY实体:
然后是分页VO实体、分页条目VO实体:
1.3.3.实现接口
首先,在tj-learning
的com.tianji.learning.controller.PointsBoardController
中定义接口:
Java
/**
* <p>
* 学霸天梯榜 控制器
* </p>
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/boards")
@Api(tags = "积分相关接口")
public class PointsBoardController {
private final IPointsBoardService pointsBoardService;
@GetMapping
@ApiOperation("分页查询指定赛季的积分排行榜")
public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query){
return pointsBoardService.queryPointsBoardBySeason(query);
}
}
然后,在com.tianji.learning.service.IPointsBoardService
中定义service方法:
Java
package com.tianji.learning.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.tianji.learning.domain.po.PointsBoard;
import com.tianji.learning.domain.query.PointsBoardQuery;
import com.tianji.learning.domain.vo.PointsBoardVO;
import java.util.List;
/**
* <p>
* 学霸天梯榜 服务类
* </p>
*/
public interface IPointsBoardService extends IService<PointsBoard> {
PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query);
}
然后,在com.tianji.learning.service.impl.PointsBoardServiceImpl
中实现方法:
Java
/**
* <p>
* 学霸天梯榜 服务实现类
* </p>
*
* @author 虎哥
*/
@Service
@RequiredArgsConstructor
public class PointsBoardServiceImpl extends ServiceImpl<PointsBoardMapper, PointsBoard> implements IPointsBoardService {
private final StringRedisTemplate redisTemplate;
private final UserClient userClient;
@Override
public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query) {
// 1.判断是否是查询当前赛季
Long season = query.getSeason();
boolean isCurrent = season == null || season == 0;
// 2.获取Redis的Key
LocalDateTime now = LocalDateTime.now();
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
// 2.查询我的积分和排名
PointsBoard myBoard = isCurrent ?
queryMyCurrentBoard(key) : // 查询当前榜单(Redis)
queryMyHistoryBoard(season); // 查询历史榜单(MySQL)
// 3.查询榜单列表
List<PointsBoard> list = isCurrent ?
queryCurrentBoardList(key, query.getPageNo(), query.getPageSize()) :
queryHistoryBoardList(query);
// 4.封装VO
PointsBoardVO vo = new PointsBoardVO();
// 4.1.处理我的信息
if (myBoard != null) {
vo.setPoints(myBoard.getPoints());
vo.setRank(myBoard.getRank());
}
if (CollUtils.isEmpty(list)) {
return vo;
}
// 4.2.查询用户信息
Set<Long> uIds = list.stream().map(PointsBoard::getUserId).collect(Collectors.toSet());
List<UserDTO> users = userClient.queryUserByIds(uIds);
Map<Long, String> userMap = new HashMap<>(uIds.size());
if(CollUtils.isNotEmpty(users)) {
userMap = users.stream().collect(Collectors.toMap(UserDTO::getId, UserDTO::getName));
}
// 4.3.转换VO
List<PointsBoardItemVO> items = new ArrayList<>(list.size());
for (PointsBoard p : list) {
PointsBoardItemVO v = new PointsBoardItemVO();
v.setPoints(p.getPoints());
v.setRank(p.getRank());
v.setName(userMap.get(p.getUserId()));
items.add(v);
}
vo.setBoardList(items);
return vo;
}
private List<PointsBoard> queryHistoryBoardList(PointsBoardQuery query) {
// TODO
return null;
}
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;
}
private PointsBoard queryMyHistoryBoard(Long season) {
// TODO
return null;
}
private PointsBoard queryMyCurrentBoard(String key) {
// 1.绑定key
BoundZSetOperations<String, String> ops = redisTemplate.boundZSetOps(key);
// 2.获取当前用户信息
String userId = UserContext.getUser().toString();
// 3.查询积分
Double points = ops.score(userId);
// 4.查询排名
Long rank = ops.reverseRank(userId);
// 5.封装返回
PointsBoard p = new PointsBoard();
p.setPoints(points == null ? 0 : points.intValue());
p.setRank(rank == null ? 0 : rank.intValue() + 1);
return p;
}
}