SpringCloud天机学堂:实时排行榜功能

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-learningRedisConstants中定义一个新的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-learningcom.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;
    }
}
相关推荐
2401_857610031 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
凌冰_2 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
码农飞飞2 小时前
深入理解Rust的模式匹配
开发语言·后端·rust·模式匹配·解构·结构体和枚举
一个小坑货2 小时前
Rust 的简介
开发语言·后端·rust
monkey_meng2 小时前
【遵守孤儿规则的External trait pattern】
开发语言·后端·rust
草莓base3 小时前
【手写一个spring】spring源码的简单实现--bean对象的创建
java·spring·rpc
Estar.Lee3 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
乌啼霜满天2493 小时前
Spring 与 Spring MVC 与 Spring Boot三者之间的区别与联系
java·spring boot·spring·mvc
Grey_fantasy3 小时前
高级编程之结构化代码
java·spring boot·spring cloud
新知图书3 小时前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust