接口
一 实时排行榜
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中,DEL和UNLINK命令都用于删除键,但它们在实现机制和对系统性能的影响上有显著区别:
1. 同步 vs 异步
DEL命令 :同步删除 。直接删除键及其关联的数据,操作会立即释放内存。如果删除的键对应大型数据结构(如包含数百万元素的哈希或列表),DEL可能会阻塞主线程,导致其他请求延迟。UNLINK命令 :异步删除。首先将键从键空间(keyspace)中移除(逻辑删除),后续的内存回收由后台线程处理。命令立即返回,不会阻塞主线程,适合删除大对象。
2. 性能影响
DEL:删除大键时可能引发明显延迟,影响Redis的响应时间。UNLINK:几乎无阻塞,适合高吞吐场景,尤其适用于需要频繁删除大键的情况。
3. 使用场景
DEL:适合删除小键或对内存释放时效性要求高的场景(如避免内存不足)。UNLINK:推荐在大多数情况下使用,尤其是删除大键或需要低延迟的场景。
4. 返回值
- 两者均返回被删除键的数量,但
UNLINK返回时数据可能尚未完全释放。
5. 版本要求
UNLINK自 Redis 4.0 引入,需确保版本支持;DEL在所有版本中可用。
示例对比
bash
# 同步删除,可能阻塞主线程
DEL large_key
# 异步删除,立即返回,后台清理
UNLINK large_key
总结
| 特性 | DEL |
UNLINK |
|---|---|---|
| 删除方式 | 同步 | 异步 |
| 阻塞主线程 | 是(大键时) | 否 |
| 适用场景 | 小键或需立即释放内存 | 大键或高并发场景 |
| 版本支持 | 所有版本 | Redis 4.0+ |
建议:优先