第八天 排行榜功能

目录

排行榜分析:

​编辑 添加积分代码实现

查询积分榜

 redis中查询如何分页

​编辑 海量数据存储策略

分区

分表与分区的区别在于:分区从逻辑上来讲只有一张表(虽然在物理层面上是有多个表文件),而分表则是将一张表分解成多张表。

分表

水平分表

垂直分表

分库和集群

赛季积分分表


排行榜分析:

榜单分为两类:

  • 实时榜单:也就是本赛季的榜单

  • 历史榜单:也就是历史赛季的榜单

之前一个积分记录明细表

要知道,每个用户都可能会有数十甚至上百条积分记录,当用户规模达到百万规模,可能产生的积分记录就是数以亿计。

要在每次查询排行榜时,在内存中对这么多数据做分组、求和、排序,对内存和CPU的占用会非常恐怖,不太靠谱。

 采用 redis的zset进行存储积分榜

 添加积分代码实现

查询积分榜

在个人中心,学生可以查看指定赛季积分排行榜(只显示前100 ),还可以查看自己总积分和排名。而且排行榜分为本赛季榜单和历史赛季榜单。

首先我们分析一下请求参数:

  • 榜单数据非常多,不可能一次性查询出来,因此这里一定是分页查询(滚动分页),需要分页参数。

  • 由于要查询历史榜单需要知道赛季,因此参数中需要指定赛季id。当赛季id为空,我们认定是查询当前赛季。这样就可以把两个接口合二为一。

然后是返回值,无论是历史榜单还是当前榜单,结构都一样。分为两部分:

  • 当前用户的积分和排名。当前用户不一定上榜,因此需要单独查询

  • 榜单数据。就是N个用户的积分、排名形成的集合

 

把本赛季的存到redis中,其他赛季的持久化到数据库中 ,所以如果是本赛季的就去redis中查,其他赛季数据库中查

 public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query) {
        // 获取 当前登录用户
        Long userId = UserContext.getUser();
        // 判断是当前赛季还是历史赛季 query.season 赛季id, 为null或者为0表示查当前赛季 为true表示查询当前赛季
        boolean isCurrent = query.getSeason() == null || query.getSeason() == 0;
        LocalDateTime now = LocalDateTime.now();
        String format = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + format;
        Long season = query.getSeason(); //历史赛季id
        // 查询我的排名和积分  根据query.getseason() 判断是查redis还是db
        PointsBoard board = isCurrent ? queryMyCurrentBoard(key) : queryMyHistoryBoard(season);

        // 分页查询赛季列表 根据query.season 判断是查redis还是db
        List<PointsBoard> list = isCurrent ? queryCurrentBoard(key,query.getPageNo(),query.getPageSize()) : queryHistoryBoard(key,query.getPageNo(),query.getPageSize());
        Set<Long> longSet = list.stream().map(PointsBoard::getUserId).collect(Collectors.toSet());
        List<UserDTO> userDTOS = userClient.queryUserByIds(longSet);
        Map<Long, String> map = userDTOS.stream().collect(Collectors.toMap(UserDTO::getId, c -> c.getName()));
        //封装vo
        PointsBoardVO vo = new PointsBoardVO();
        vo.setRank(board.getRank());
        vo.setPoints(board.getPoints());
        ArrayList<PointsBoardItemVO> voList = new ArrayList<>();
        for (PointsBoard pointsBoard : list) {
            PointsBoardItemVO pointsBoardItemVO = new PointsBoardItemVO();
            pointsBoardItemVO.setPoints(pointsBoard.getPoints());
            pointsBoardItemVO.setRank(pointsBoard.getRank());
            pointsBoardItemVO.setName(map.get(pointsBoard.getUserId()));
            voList.add(pointsBoardItemVO);
        }
        vo.setBoardList(voList);
        return vo;
    }

 查找当前赛季的

private List<PointsBoard> queryCurrentBoard(String key, Integer pageNo, Integer pageSize) {
        int start = (pageNo - 1) * pageSize;
        int end = start + pageSize - 1;
        // 利用zrevrange 会按照分页 分数倒序(从大到小)
        int range = start + 1;
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
        if (CollUtils.isEmpty(typedTuples)){
            return CollUtils.emptyList();
        }
        List<PointsBoard> list = new ArrayList<>();
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            Double score = typedTuple.getScore();
            String value = typedTuple.getValue();
            if (score == null || StringUtils.isBlank(value)){
                continue;
            }
            PointsBoard board = new PointsBoard();
            board.setPoints(score.intValue());
            board.setUserId(Long.valueOf(value));
            board.setRank(range++);
            list.add(board);
        }
        return list;
    }

 查找我的排名(仅我自己的)

    private PointsBoard queryMyCurrentBoard(String key) {
        Long userId = UserContext.getUser();
        Double score = redisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null){
            score = 0D;
        }
        // 获取排名
        Long rank = redisTemplate.opsForZSet().reverseRank(key, userId.toString());
        PointsBoard board = new PointsBoard();
        board.setRank(rank == null ? 0: rank.intValue()+1 );
        board.setPoints(score == null ? 0 :score.intValue());
        board.setUserId(userId);
        return board;
    }

 查看排名 从大到校

 redis中查询如何分页

比如 查第二页的10条数据

那么start = 10   end 为19   为闭区间

 reverseRangeWithScores是按照从大到小 按照索引进行排序

 海量数据存储策略

分区

表分区(Partition)是一种数据存储方案,可以解决单表数据较多的问题。MySQL5.1开始支持表分区功能。

数据库的表最终肯定是保存在磁盘中,对于InoDB引擎,一张表的数据在磁盘上对应一个ibd文件。如图,我们的积分榜单表对应的文件:

如果表数据过多,就会导致文件体积非常大。文件就会跨越多个磁盘分区,数据检索时的速度就会非常慢。

为了解决这个问题,MySQL在5.1版本引入表分区功能。简单来说,就是按照某种规则,把表数据对应的ibd文件拆分成多个文件来存储。从物理上来看,一张表的数据被拆到多个表文件存储了;从逻辑上来看,他们对外表现是一张表。

此时,赛季榜单表的磁盘文件就被分成了两个文件。但逻辑上还是一张表。增删改查的方式不会有什么变化,只不过底层MySQL底层的处理上会有变更。例如检索时可以只检索某个文件,而不是全部。

这样做有几个好处:

  • 可以存储更多的数据,突破单表上限。甚至可以存储到不同磁盘,突破磁盘上限

  • 查询时可以根据规则只检索某一个文件,提高查询效率

  • 数据统计时,可以多文件并行统计,最后汇总结果,提高统计效率

  • 对于一些历史数据,如果不需要时,可以直接删除分区文件,提高删除效率

表分区的本质是对数据的水平拆分,而拆分的方式也有多种,常见的有:

  • Range分区:按照指定字段的取值范围分区

  • List分区:按照指定字段的枚举值分区,必须提前指定好所有的分区值,如果数据找不到分区会报错

  • Hash分区:基于字段做hash运算后分区,一般做hash运算的字段都是数值类型

  • Key分区:根据指定字段的值做运算的结果分区,与hash分区类似,但不限定字段类型

 对于赛季榜单来说,最合适的分区方式是基于赛季值分区,我们希望同一个赛季放到一个分区。这就只能使用List分区,而List分区却需要枚举出所有可能的分区值。但是赛季分区id是无限的,无法全部枚举,所以就非常尴尬

分表:指的是通过一定规则,将一张表分解成多张不同的表。比如将用户订单记录根据时间成多个表。

分表与分区的区别在于:分区从逻辑上来讲只有一张表(虽然在物理层面上是有多个表文件),而分表则是将一张表分解成多张表。

分表

分表是一种表设计方案,由开发者在创建表时按照自己的业务需求拆分表。也就是说这是开发者自己对表的处理,与数据库无关。

而且,一旦做了分表,无论是逻辑上,还是物理上,就从一张表变成了多张表!增删改查的方式就发生了变化,必须自己考虑要去哪张表做数据处理。

分区则在逻辑上是同一张表,增删改查与以前没有区别。这就是分区和分表最大的一种区别

水平分表

例如,对于赛季榜单,我们可以按照赛季拆分为多张表,每一个赛季一张新的表。这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。

垂直分表

什么是垂直分表呢?

如果一张表的字段非常多,比如达到30个以上,这样的表我们称为宽表。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。

这个时候一张表就变成了两张表。而且两张表的结构不同数据也不同。这种按照字段拆分表的方式,称为垂直拆分。 

分库和集群

无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:

  • 单点故障问题:数据库发生故障,整个系统就会瘫痪

  • 单库的性能瓶颈问题:单库受服务器限制,其网络带宽、CPU、连接数都有瓶颈

  • 单库的存储瓶颈问题:单库的磁盘空间有上限,如果磁盘过大,数据检索的速度又会变慢

综上,在大型系统中,我们除了要做分表、还需要对数据做分库,建立综合集群。

首先,在微服务项目中,我们会按照项目模块,每个微服务使用独立的数据库,因此每个库的表是不同的,这种分库模式成为垂直分库

而为了保证单节点的高可用性,我们会给数据库建立主从集群,主节点向从节点同步数据。两者结构一样,可以看做是水平扩展

这种模式的优缺点:

优点:

  • 解决了海量数据存储问题,突破了单机存储瓶颈

  • 提高了并发能力,突破了单机性能瓶颈

  • 避免了单点故障

缺点:

  • 成本非常高

  • 数据聚合统计比较麻烦

  • 主从同步的一致性问题

  • 分布式事务问题

赛季积分分表

由于我们要解决的是数据过多问题,因此分表的方式选择水平分表。具体来说,就是按照赛季拆分,每一个赛季是一个独立的表,如图

 

不过这里我们可以做一些简化:

  • 我们可以将id作为排名,排名字段就不需要了。

  • 不同赛季用不同表,那么赛季字段就不需要了。

不过这就存在一个问题,每个赛季要有不同的表,这些表什么时候创建呢?

显然,应该在每个赛季刚开始的时候(月初)来创建新的赛季榜单表。每个月的月初执行一个创建表的任务,我们可以利用定时任务来实现。

由于表的名称中包含赛季id,因此在定时任务中我们还要先查询赛季信息,获取赛季id,拼接得到表名,最后创建表。

大概流程如图:

 定时任务每月1号凌晨执行一次

查询赛季,没有就表示该赛季不存在

在mapper层创建表,使用 

 使用insert方法创建表

 注意:

mybatis plus 动态表名

持久化的流程中存在一个问题,我们的数据库持久化采用的是MybatisPlus来实现的。而MybatisPlus读取表名的方式是通过实体类上的@Table注解,而注解往往是写死的:

那我们该如何让MybatisPlus在执行的时候改变数据写入的表名称呢? 

动态表名插件

MybatisPlus中提供了一个动态表名的插件

这个源码意思就是从这个tablenamehandlermap这个map中取数据,如果存在就以存在的那个新表明,否则不存在就是原来旧表名,所以我们只需要把新表明放到tableNameHandlerMap中即可。

OK,因此我们要做的事情就很简单了,定义DynamicTableNameInnterInterceptor,向其中添加一个TableNameHandler,将points_board这个表名,替换为points_board_赛季id的名称。

可见表名称动态获取就是依赖于tableNameHandlerMapping中的具体的TableNameHandler,这个Map如图 

 实现方案步骤:

第一步 定义DynamicTableNameInnterInterceptor,向其中添加一个TableNameHandler,将points_board这个表名,替换为points_board_赛季id的名称

当我们批量的写数据到数据库时,如果每次插入都计算一次表名,那性能也太差了。因此,我们肯定是希望一次计算,在TableNameHandler中可以随时获取。

流程中,我们会先计算表名,然后去执行持久化,而动态表名插件就会生效,去替换表名。

因此,一旦我们计算完表名,以某种方式传递给插件中的TableNameHandler,那么就无需重复计算表名了。

不过,问题来了:要知道动态表名称插件,以及TableNameHandler,都是由MybatisPlus内部调用的。我们无法传递参数。

那么该如何传递表名称呢?

虽然无法传参,但是从计算表名,到动态表名插件执行,调用TableNameHandler,都是在一个线程内完成的。要在一个线程内实现数据共享,该用什么呢?

大家应该很容易想到,就是ThreadLocal.

我们可以在定时任务中计算完动态表名后,将表名存入ThreadLocal,然后在插件中从ThreadLocal中读取即可:

 第二步:插件虽然定义好了,但是该如何继承到MybatisPlus中呢?

在天机学堂项目中的tj-common模块中,已经实现了MybatisPlus的自动装配,并且定义了很多的MP插件。如果我们在自己的项目中重新定义MP配置,就会导致tj-common中的插件失效。

所以,我们应该修改tj-common中的MP配置,将DynamicTableNameInnerInterceptor配置进去。找到tj-common模块下的MybatisConfig配置:

因为所有微服务都依赖了tj-common,但是有的微服务不需要这个动态表名插件,所以required 为false,并且配置的顺序也有严格的要求 

面试

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值