天机学堂-排行榜功能-day08(六)

接口

一 实时排行榜

1.查询赛季列表功能

参数 说明
请求方式 GET
请求路径 /boards/seasons/list
请求参数
返回值 [ { "id": "110", // 赛季id "name": "第一赛季", // 赛季名称 "beginTime": "2023-05-01", // 赛季开始时间 "endTime": "2023-05-31", // 赛季结束时间 } ]
PointsBoardSeasonController.java
java 复制代码
    /**
     * 查询赛季列表
     * @return
     */
    @ApiOperation("查询赛季列表")
    @GetMapping("/list")
    public List<PointsBoardSeason> list(){
        return pointsBoardSeasonService.list();
    }

2.实时排行榜功能(基于Zset改造之前的代码)

RedisConstants.java
java 复制代码
    /**
     * 积分排行榜的前缀 boards:202501
     */
    String POINTS_BOARDS_KEY_PREFIX = "boards:";
PointsRecordServiceImpl.java
java 复制代码
    @Override
    public void addPointsRecord(Long userId, int point, PointsRecordType type) {
        //判断该积分类型是否有上限 type.maxPoints是否大于0
        if (point <= 0) {
            return;
        }
        int maxPoints = type.getMaxPoints();
        LocalDateTime now = LocalDateTime.now();
        if (maxPoints > 0) {
            LocalDateTime dayStartTime = DateUtils.getDayStartTime(now);
            LocalDateTime dayEndTime = DateUtils.getDayEndTime(now);
            //如果有上限 查询该用户 该积分类型 今日已得积分 points_record 条件userId type
            QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
            wrapper.select("sum(points) as totalPoints");
            wrapper.eq("user_id", userId);
            wrapper.eq("type", type);
            wrapper.between("create_time", dayStartTime, dayEndTime);
            Map<String, Object> map = this.getMap(wrapper);
            //当前用户该积分类型 已得积分
            int currentPoints = 0;
            if (map != null && map.containsKey("totalPoints")) {
                BigDecimal totalPoints = (BigDecimal) map.get("totalPoints");
                currentPoints = totalPoints.intValue();
            }
            //判断已得积分是否超过上限
            if (currentPoints >= maxPoints) {
                //说明已得积分 达到上限
                return;
            }
            // 此时的point标识能得得积分
            if (currentPoints + point > maxPoints) {
                point = maxPoints - currentPoints;
            }
        }
        //保存积分
        PointsRecord record = new PointsRecord();
        record.setUserId(userId);
        record.setType(type);
        record.setPoints(point);
        this.save(record);
        // 累计积分添加到Redis当中)(改造部分)
        String key = RedisConstants.POINTS_BOARDS_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
        stringRedisTemplate.opsForZSet().incrementScore(key, userId.toString(), point);
    }

3.查询学霸积分排行榜

接口说明 查询指定赛季的积分排行榜以及当前用户的积分和排名信息
请求方式 GET
请求路径 /boards
请求参数 分页参数,例如PageNo、PageSize赛季id,为空或0时,代表查询当前赛季。否则就是查询历史赛季
返回值 { "rank": 8, // 当前用户的排名 "points": 21, // 当前用户的积分值 [ { "rank": 1, // 排名 "points": 81, // 积分值 "name": "Jack" // 姓名 }, { "rank": 2, // 排名 "points": 74, // 积分值 "name": "Rose" // 姓名 } ] }
PointsBoardController.java
java 复制代码
@ApiOperation("查询学霸积分榜-当前赛季和历史赛季都可用")
@GetMapping
public PointsBoardVO queryPointsBoardList(PointsBoardQuery query) {
    return pointsBoardService.queryPointsBoardList(query);
}
IPointsBoardService.java
java 复制代码
PointsBoardVO queryPointsBoardList(PointsBoardQuery query);
PointsBoardServiceImpl.java
java 复制代码
package com.tianji.learning.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.tianji.api.client.user.UserClient;
import com.tianji.api.dto.user.UserDTO;
import com.tianji.common.exceptions.BadRequestException;
import com.tianji.common.utils.CollUtils;
import com.tianji.common.utils.DateUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.learning.constants.RedisConstants;
import com.tianji.learning.domain.po.PointsBoard;
import com.tianji.learning.domain.query.PointsBoardQuery;
import com.tianji.learning.domain.vo.PointsBoardItemVO;
import com.tianji.learning.domain.vo.PointsBoardVO;
import com.tianji.learning.mapper.PointsBoardMapper;
import com.tianji.learning.service.IPointsBoardService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import feign.FeignException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.BoundZSetOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
 * <p>
 * 学霸天梯榜 服务实现类
 * </p>
 *
 * @author ax
 */
@Service
@RequiredArgsConstructor
public class PointsBoardServiceImpl extends ServiceImpl<PointsBoardMapper, PointsBoard> implements IPointsBoardService {


    private final StringRedisTemplate redisTemplate;


    private final UserClient userClient;


    private final PointsBoardMapper mapper;

    @Override
    public PointsBoardVO queryPointsBoard(PointsBoardQuery query) {
        //1.判断是否是查询当前赛季
        Long seasonId = query.getSeason();
        boolean isCurrentSeason = seasonId == null || seasonId == 0;
        //2.获取Redis的key
        LocalDate now = LocalDate.now();
        String key = RedisConstants.POINTS_BOARDS_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
        //3.查询个人的榜单信息-分为查询当前赛季和历史赛季
        PointsBoard pointsBoard = isCurrentSeason ? queryMyCurrentBoard(key) : queryMyHistoryBoard(seasonId);
        //4.查询积分排行榜-分为查询当前赛季和历史赛季
        List<PointsBoard> list = isCurrentSeason ? queryCurrentBoardList(key, query) : queryHistoryBoardList(query);
        PointsBoardVO pointsBoardVO = new PointsBoardVO();
        //5.设置自己的榜单信息
        if (pointsBoard != null) {
            pointsBoardVO.setRank(pointsBoard.getRank());
            pointsBoardVO.setPoints(pointsBoard.getPoints());
        }
        //6.设置积分排行榜
        int index = 0;
        if (!CollUtils.isEmpty(list)) {
            List<PointsBoardItemVO> boardList = new ArrayList<>(list.size());
            for (PointsBoard board : list) {
                PointsBoardItemVO itemVO = new PointsBoardItemVO();
                itemVO.setRank(board.getRank());
                itemVO.setPoints(board.getPoints());
                // 这里的为空异常回调在Feign当中已经声明了
                UserDTO user = userClient.queryUserById(board.getUserId());
                itemVO.setName(user != null ? user.getName() : "匿名用户" + (++index));
                boardList.add(itemVO);
            }
            pointsBoardVO.setBoardList(boardList);
        }
        return pointsBoardVO;

    }

    @Override
    public void createPointsBoardTable(Integer seasonId) {
        String tableName = "points_board_" + seasonId;
        mapper.createPointsBoardTable(tableName);
    }

    @Override
    public List<PointsBoard> queryCurrentBoardList(String key, PointsBoardQuery query) {
        Integer pageNo = query.getPageNo();
        Integer pageSize = query.getPageSize();
        int from = (pageNo - 1) * pageSize;
        Set<ZSetOperations.TypedTuple<String>> typedTuples =
                redisTemplate.opsForZSet().reverseRangeWithScores(key, from, from + pageSize - 1);
        int rank = from + 1;
        if (CollUtils.isEmpty(typedTuples)) {
            return CollUtils.emptyList();
        }
        List<PointsBoard> list = new ArrayList<>(typedTuples.size());
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            String value = tuple.getValue();
            Double score = tuple.getScore();
            if (score == null || value == null) {
                continue;
            }
            PointsBoard board = new PointsBoard();
            board.setUserId(Long.valueOf(value));
            board.setPoints(score.intValue());
            board.setRank(rank++);
            list.add(board);
        }
        return list;
    }

    private PointsBoard queryMyCurrentBoard(String key) {
        PointsBoard pointsBoard = new PointsBoard();
        //1.从Redis中查询
        BoundZSetOperations<String, String> ops = redisTemplate.boundZSetOps(key);
        //2.获取当前用户
        String userId = UserContext.getUser().toString();
        //3.查询分数
        Double score = ops.score(userId);
        //4.查询排名
        Long rank = ops.reverseRank(userId);
        pointsBoard.setPoints(score == null ? 0 : score.intValue());
        pointsBoard.setRank(rank == null ? 0 : rank.intValue() + 1);
        return pointsBoard;
    }

    private List<PointsBoard> queryHistoryBoardList(PointsBoardQuery query) {
        if (query.getPageNo() <= 0 || query.getPageSize() <= 0) {
            throw new BadRequestException("非法参数");
        }
        int offset = query.getPageNo() - 1;
        int limit = query.getPageSize();
        Long season = query.getSeason();
        String tableName = "points_board_" + season;
        return mapper.selectTablePage(tableName, offset, limit);

    }

    private PointsBoard queryMyHistoryBoard(Long seasonId) {
        Long userId = UserContext.getUser();
        if (seasonId == null) {
            throw new BadRequestException("非法参数");
        }
        String tableName = "points_board_" + seasonId;
        return mapper.selectTable(tableName, seasonId, userId);
    }

}
java 复制代码
 PointsBoard selectTable(@Param("tableName") String tableName,@Param("seasonId") Long seasonId, @Param("userId") Long userId);

    List<PointsBoard> selectTablePage(@Param("tableName") String tableName, @Param("offset") int offset,@Param("limit") int limit);
java 复制代码
    <resultMap id="BaseResultMap" type="com.tianji.learning.domain.po.PointsBoard">
        <id column="id" property="id"/>
        <result column="user_id" property="userId"/>
        <result column="points" property="points"/>
        <result column="rank" property="rank"/>
        <result column="season" property="season"/>
    </resultMap>
    <!--个人历史查询-->
    <select id="selectTable" resultMap="BaseResultMap">
        SELECT id,
               user_id,
               points
        FROM ${tableName}
        WHERE user_id = #{userId}
        LIMIT 1
    </select>
    <!--  分页历史查询  -->
    <select id="selectTablePage" resultType="com.tianji.learning.domain.po.PointsBoard">
        SELECT
        id,
        user_id,
        points
        FROM ${tableName}
        ORDER BY id ASC
        LIMIT #{offset}, #{limit}
    </select>

二 历史排行榜

1.定时任务生成榜单表


PointsBoardPersistentHandler.java
java 复制代码
    @XxlJob("createTableJob")
    public void createPointBoardTableOfLastSeason() {
        //1.获取上个月的时间
        LocalDateTime time = LocalDateTime.now().minusMonths(1);
        //2.查询赛季id
        Integer seasonId = pointsBoardSeasonService.querySeasonIdByTime(time);
        //3.创建积分榜单表
        if (seasonId == null) {
            throw new BadRequestException("当前赛季不存在");
        }
        pointsBoardService.createPointsBoardTable(seasonId);
    }
IPointsBoardSeasonService.java+IPointsBoardService.java
java 复制代码
    Integer querySeasonIdByTime(LocalDateTime time);

    void createPointsBoardTable(Integer seasonId);
PointsBoardSeasonServiceImpl.java+PointsBoardServiceImpl.java
java 复制代码
    @Override
    public Integer querySeasonIdByTime(LocalDateTime time) {
        Optional<PointsBoardSeason> pointsBoardSeason = lambdaQuery().le(PointsBoardSeason::getBeginTime, time)
                .ge(PointsBoardSeason::getEndTime, time).oneOpt();
        return pointsBoardSeason.map(PointsBoardSeason::getId).orElse(null);
    }
复制代码
@Override
public void createPointsBoardTable(Integer seasonId) {
    String tableName = "points_board_" + seasonId;
    mapper.createPointsBoardTable(tableName);
}
PointsBoardMapper
复制代码
    void createPointsBoardTable(@Param("tableName") String seasonId);
PointsBoardMapper.xml
xml 复制代码
<insert id="createPointsBoardTable">
    CREATE TABLE if not exists `${tableName}`
    (
        `id`      BIGINT NOT NULL AUTO_INCREMENT COMMENT '榜单id',
        `user_id` BIGINT NOT NULL COMMENT '学生id',
        `points`  INT    NOT NULL COMMENT '积分值',
        PRIMARY KEY (`id`) USING BTREE,
        INDEX `idx_user_id` (`user_id`) USING BTREE
    )
        COMMENT ='学霸天梯榜'
        COLLATE = 'utf8mb4_0900_ai_ci'
        ENGINE = InnoDB
        ROW_FORMAT = DYNAMIC
</insert>

2.定时任务榜单持久化


TableInfoContext

创建一个ThreadLocal工具类在工作线程当中去存储表名

复制代码
package com.tianji.learning.utils;

/**
 * 获取当前线程的TableInfo对象
 *
 * @author ax
 */
public class TableInfoContext {
    private static final ThreadLocal<String> TABLE_INFO = new ThreadLocal<>();

    public static void setTableInfo(String tableInfo) {
        TABLE_INFO.set(tableInfo);
    }

    public static String getTableInfo() {
        return TABLE_INFO.get();
    }
    public static void remove() {
        TABLE_INFO.remove();
    }

}
MybatisConfig.java

声明对应的配置类去实现对表名的修改

通过拦截器机制线程上下文传递,优雅地实现了逻辑表名到物理表名的动态映射。

MybatisConfig搭建了处理流水线,MybatisConfiguration为特定表配置了换名规则,而具体的表名信息则由业务代码通过TableInfoContext在关键时刻传递。整个过程对业务代码入侵极小,是分库分表场景下的经典解决方案。

MybatisConfiguration.java

复制代码
package com.tianji.learning.config;

import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.tianji.learning.utils.TableInfoContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

/**
 * mybatis配置类
 *
 * @author ax
 */
@Configuration
public class MybatisConfiguration {
    @Bean
    public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() {
        Map<String, TableNameHandler> map = new HashMap<>(1);
        map.put("points_board", (sql, tableName) -> TableInfoContext.getTableInfo());
        return new DynamicTableNameInnerInterceptor(map);
    }
}

Mybatis-plus的配置类

java 复制代码
package com.tianji.common.autoconfigure.mybatis;


import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnClass({MybatisPlusInterceptor.class, BaseMapper.class})
public class MybatisConfig {

    /**
     * @see MyBatisAutoFillInterceptor 通过自定义拦截器来实现自动注入creater和updater
     * @deprecated 存在任务更新数据导致updater写入0或null的问题,暂时废弃
     */
    // @Bean
    // @ConditionalOnMissingBean
    public BaseMetaObjectHandler baseMetaObjectHandler() {
        return new BaseMetaObjectHandler();
    }

    @Bean
    @ConditionalOnMissingBean
    public MybatisPlusInterceptor mybatisPlusInterceptor(@Autowired(required = false) DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInnerInterceptor.setMaxLimit(200L);
        interceptor.addInnerInterceptor(paginationInnerInterceptor);
        interceptor.addInnerInterceptor(new MyBatisAutoFillInterceptor());
        if (dynamicTableNameInnerInterceptor != null) {
            interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
        }
        return interceptor;
    }
}
PointsBoardPersistentHandler.java

这里还借助了xxljob的分片广播,主要应用于多实例部署时,可以实现多实例分片,类似于取模运算的思路,每个实例负责不同的部分。

java 复制代码
    @XxlJob("savePointsBoard2DB")
    public void savePointsBoard2DB() {
        //1.获取上个月的时间
        LocalDateTime time = LocalDateTime.now().minusMonths(1);
        //2.查询赛季id
        // 2.1拼接Redis当中的key
        String key = RedisConstants.POINTS_BOARDS_KEY_PREFIX + time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
        // 2.2查询数据
        PointsBoardQuery pointsBoardQuery = new PointsBoardQuery();
        int index = XxlJobHelper.getShardIndex();
        int total = XxlJobHelper.getShardTotal();
        pointsBoardQuery.setPageNo(index + 1);
        pointsBoardQuery.setPageSize(100);
        // 2.3计算动态表名,存入ThreadLocal中
        Integer seasonId = pointsBoardSeasonService.querySeasonIdByTime(time);
        TableInfoContext.setTableInfo("points_board_" + seasonId);
        while (true) {
            List<PointsBoard> pointsBoards =
                    pointsBoardService.queryCurrentBoardList(key, pointsBoardQuery);
            if (CollUtils.isEmpty(pointsBoards)) {
                break;
            }
            // 持久化到数据库
            mapper.saveDb(pointsBoards, TableInfoContext.getTableInfo());
            //翻页
            pointsBoardQuery.setPageNo(pointsBoardQuery.getPageNo() + total);
        }
        TableInfoContext.remove();
    }
queryCurrentBoardList方法
复制代码
    @Override
    public List<PointsBoard> queryCurrentBoardList(String key, PointsBoardQuery query) {
        Integer pageNo = query.getPageNo();
        Integer pageSize = query.getPageSize();
        int from = (pageNo - 1) * pageSize;
        Set<ZSetOperations.TypedTuple<String>> typedTuples =
                redisTemplate.opsForZSet().reverseRangeWithScores(key, from, from + pageSize - 1);
        int rank = from + 1;
        if (CollUtils.isEmpty(typedTuples)) {
            return CollUtils.emptyList();
        }
        List<PointsBoard> list = new ArrayList<>(typedTuples.size());
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            String value = tuple.getValue();
            Double score = tuple.getScore();
            if (score == null || value == null) {
                continue;
            }
            PointsBoard board = new PointsBoard();
            board.setUserId(Long.valueOf(value));
            board.setPoints(score.intValue());
            board.setRank(rank++);
            list.add(board);
        }
        return list;
    }
PointsBoardMapper.java
复制代码
    void saveDb(@Param("list") List<PointsBoard> pointsBoards, @Param("tableName") String tableName);
PointsBoardMapper.xml

这里是将五个字段修改为三个字段的映射,rank排名映射为id,user_id与points则是普通的映射。

复制代码
    <insert id="saveDb">
        insert into ${tableName} (id, user_id, points) values
        <foreach collection="list" item="item" separator=",">
            (#{item.rank}, #{item.userId}, #{item.points})
        </foreach>
    </insert>

Redis中del和unlink区别

在Redis中,DELUNLINK命令都用于删除键,但它们在实现机制和对系统性能的影响上有显著区别:

1. 同步 vs 异步

  • DEL 命令同步删除 。直接删除键及其关联的数据,操作会立即释放内存。如果删除的键对应大型数据结构(如包含数百万元素的哈希或列表),DEL 可能会阻塞主线程,导致其他请求延迟。
  • UNLINK 命令异步删除。首先将键从键空间(keyspace)中移除(逻辑删除),后续的内存回收由后台线程处理。命令立即返回,不会阻塞主线程,适合删除大对象。

2. 性能影响

  • DEL:删除大键时可能引发明显延迟,影响Redis的响应时间。
  • UNLINK:几乎无阻塞,适合高吞吐场景,尤其适用于需要频繁删除大键的情况。

3. 使用场景

  • DEL:适合删除小键或对内存释放时效性要求高的场景(如避免内存不足)。
  • UNLINK:推荐在大多数情况下使用,尤其是删除大键或需要低延迟的场景。

4. 返回值

  • 两者均返回被删除键的数量,但UNLINK返回时数据可能尚未完全释放。

5. 版本要求

  • UNLINKRedis 4.0 引入,需确保版本支持;DEL 在所有版本中可用。

示例对比

bash 复制代码
# 同步删除,可能阻塞主线程
DEL large_key

# 异步删除,立即返回,后台清理
UNLINK large_key

总结

特性 DEL UNLINK
删除方式 同步 异步
阻塞主线程 是(大键时)
适用场景 小键或需立即释放内存 大键或高并发场景
版本支持 所有版本 Redis 4.0+

建议:优先

相关推荐
不穿格子的程序员2 小时前
Redis篇3——Redis深度剖析:内存数据的“不死之身”——RDB、AOF与混合持久化
数据库·redis·缓存·数据持久化·aof·rdb
hgz07102 小时前
Spring Boot Starter机制
java·spring boot·后端
daxiang120922052 小时前
Spring boot服务启动报错 java.lang.StackOverflowError 原因分析
java·spring boot·后端
我家领养了个白胖胖2 小时前
极简集成大模型!Spring AI Alibaba ChatClient 快速上手指南
java·后端·ai编程
jiayong232 小时前
Markdown编辑完全指南
java·编辑器
heartbeat..2 小时前
深入理解 Redisson:分布式锁原理、特性与生产级应用(Java 版)
java·分布式·线程·redisson·
一代明君Kevin学长2 小时前
快速自定义一个带进度监控的文件资源类
java·前端·后端·python·文件上传·文件服务·文件流
未来之窗软件服务3 小时前
幽冥大陆(四十九)PHP打造Java的Jar实践——东方仙盟筑基期
java·php·jar·仙盟创梦ide·东方仙盟·东方仙盟sdk·东方仙盟一体化
普通网友3 小时前
深入探讨Linux驱动开发:字符设备驱动开发与测试_linux 驱动开发设备号(2)
java·linux·驱动开发