天机学堂-排行榜功能-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+

建议:优先

相关推荐
华如锦15 分钟前
四:从零搭建一个RAG
java·开发语言·人工智能·python·机器学习·spring cloud·计算机视觉
Tony_yitao17 分钟前
22.华为OD机试真题:数组拼接(Java实现,100分通关)
java·算法·华为od·algorithm
JavaGuru_LiuYu19 分钟前
Spring Boot 整合 SSE(Server-Sent Events)
java·spring boot·后端·sse
爬山算法22 分钟前
Hibernate(26)什么是Hibernate的透明持久化?
java·后端·hibernate
彭于晏Yan24 分钟前
Springboot实现数据脱敏
java·spring boot·后端
luming-0229 分钟前
java报错解决:sun.net.utils不存
java·经验分享·bug·.net·intellij-idea
北海有初拥37 分钟前
Python基础语法万字详解
java·开发语言·python
alonewolf_9941 分钟前
Spring IOC容器扩展点全景:深入探索与实践演练
java·后端·spring
super_lzb43 分钟前
springboot打war包时将外部配置文件打入到war包内
java·spring boot·后端·maven
毛小茛1 小时前
芋道管理系统学习——项目结构
java·学习