接口
一 实时排行榜
1.查询赛季列表功能
| 参数 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /boards/seasons/list |
| 请求参数 | 无 |
| 返回值 | [ { "id": "110", // 赛季id "name": "第一赛季", // 赛季名称 "beginTime": "2023-05-01", // 赛季开始时间 "endTime": "2023-05-31", // 赛季结束时间 } ] |
PointsBoardSeasonController.java
/** * 查询赛季列表 * @return */@ApiOperation("查询赛季列表")@GetMapping("/list")publicList<PointsBoardSeason>list(){returnpointsBoardSeasonService.list();}2.实时排行榜功能(基于Zset改造之前的代码)
RedisConstants.java
/** * 积分排行榜的前缀 boards:202501 */StringPOINTS_BOARDS_KEY_PREFIX="boards:";PointsRecordServiceImpl.java
@OverridepublicvoidaddPointsRecord(LonguserId,intpoint,PointsRecordTypetype){//判断该积分类型是否有上限 type.maxPoints是否大于0if(point<=0){return;}intmaxPoints=type.getMaxPoints();LocalDateTimenow=LocalDateTime.now();if(maxPoints>0){LocalDateTimedayStartTime=DateUtils.getDayStartTime(now);LocalDateTimedayEndTime=DateUtils.getDayEndTime(now);//如果有上限 查询该用户 该积分类型 今日已得积分 points_record 条件userId typeQueryWrapper<PointsRecord>wrapper=newQueryWrapper<>();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);//当前用户该积分类型 已得积分intcurrentPoints=0;if(map!=null&&map.containsKey("totalPoints")){BigDecimaltotalPoints=(BigDecimal)map.get("totalPoints");currentPoints=totalPoints.intValue();}//判断已得积分是否超过上限if(currentPoints>=maxPoints){//说明已得积分 达到上限return;}// 此时的point标识能得得积分if(currentPoints+point>maxPoints){point=maxPoints-currentPoints;}}//保存积分PointsRecordrecord=newPointsRecord();record.setUserId(userId);record.setType(type);record.setPoints(point);this.save(record);// 累计积分添加到Redis当中)(改造部分)Stringkey=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
@ApiOperation("查询学霸积分榜-当前赛季和历史赛季都可用")@GetMappingpublicPointsBoardVOqueryPointsBoardList(PointsBoardQueryquery){returnpointsBoardService.queryPointsBoardList(query);}IPointsBoardService.java
PointsBoardVOqueryPointsBoardList(PointsBoardQueryquery);PointsBoardServiceImpl.java
@OverridepublicPointsBoardVOqueryPointsBoardList(PointsBoardQueryquery){// 获取当前登录用户idLonguserId=UserContext.getUser();// 判断是查当前赛季还是历史赛季 query.seasonbooleanisCurrent=query.getSeason()==null||query.getSeason()==0;LocalDatenow=LocalDate.now();Stringformat=now.format(DateTimeFormatter.ofPattern("yyyyMM"));Stringkey=RedisConstants.POINTS_BOARD_KEY_PREFIX+format;Longseason=query.getSeason();// 查询我的排名和积分PointsBoardboard=isCurrent?queryMyCurrentBoard(key):queryMyHistoryBoard(season);// 分页查询赛季列表List<PointsBoard>list=isCurrent?queryCurrentBoard(key,query.getPageNo(),query.getPageSize()):queryHistoryBoard(query);// 封装vo返回PointsBoardVOvo=newPointsBoardVO();vo.setRank(board.getRank());//我的排名vo.setPoints(board.getPoints());//我的积分//封装用户id集合 调用用户服务 获取用户信息 转mapSet<Long>uids=list.stream().map(PointsBoard::getUserId).collect(Collectors.toSet());List<UserDTO>userDTOS=userClient.queryUserByIds(uids);if(userDTOS.isEmpty()){thrownewBizIllegalException("用户不存在");}//转map key:用户id value 用户名称Map<Long,String>userDtoMap=userDTOS.stream().collect(Collectors.toMap(UserDTO::getId,c->c.getName()));List<PointsBoardItemVO>voList=newArrayList<>();for(PointsBoardpointsBoard:list){PointsBoardItemVOitemVO=newPointsBoardItemVO();itemVO.setName(userDtoMap.get(pointsBoard.getUserId()));itemVO.setPoints(pointsBoard.getPoints());itemVO.setRank(pointsBoard.getRank());voList.add(itemVO);}vo.setBoardList(voList);returnvo;}/** * 查询历史赛季排行榜列表 * * @param query * @return */privateList<PointsBoard>queryHistoryBoard(PointsBoardQueryquery){if(query.getPageNo()<=0||query.getPageSize()<=0){thrownewBadRequestException("非法参数");}intoffset=query.getPageNo()-1;List<PointsBoard>list=this.lambdaQuery().eq(PointsBoard::getSeason,query.getSeason()).orderByAsc(PointsBoard::getPoints).last("LIMIT "+query.getPageSize()+" OFFSET "+offset).list();returnlist;}/** * 查询当前赛季排行榜列表 * * @param key * @param pageNo 页码 * @param pageSize 条数 * @return */privateList<PointsBoard>queryCurrentBoard(Stringkey,IntegerpageNo,IntegerpageSize){// 计算start和stop 下标都是从零开始intstart=(pageNo-1)*pageSize;intend=start+pageSize-1;// 利用zrevrange 按分数倒序 分页查询Set<ZSetOperations.TypedTuple<String>>typedTuples=redisTemplate.opsForZSet().reverseRangeByScoreWithScores(key,start,end);if(CollUtils.isEmpty(typedTuples)){returnCollUtils.emptyList();}intrank=start+1;List<PointsBoard>list=newArrayList<>();//封装结果返回for(ZSetOperations.TypedTuple<String>typedTuple:typedTuples){Stringvalue=typedTuple.getValue();//用户idDoublescore=typedTuple.getScore();//总积分值if(StringUtils.isBlank(value)||score==null){continue;}PointsBoardboard=newPointsBoard();board.setUserId(Long.valueOf(value));board.setPoints(score.intValue());board.setRank(rank++);list.add(board);}returnlist;}/** * 查询历史赛季我的积分和排名 * * @param season * @return */privatePointsBoardqueryMyHistoryBoard(Longseason){LonguserId=UserContext.getUser();if(season==null){thrownewBadRequestException("非法参数");}PointsBoardone=this.lambdaQuery().eq(PointsBoard::getSeason,season).eq(PointsBoard::getUserId,userId).one();returnone;}/** * 查询当前赛季我的积分和排名 * * @param key * @return */privatePointsBoardqueryMyCurrentBoard(Stringkey){// 获取当前登录用户idLonguserId=UserContext.getUser();// 从Redis中获取分值Doublescore=redisTemplate.opsForZSet().score(key,userId.toString());// 获取排名 从0开始 需要加一Longrank=redisTemplate.opsForZSet().reverseRank(key,userId.toString());PointsBoardboard=newPointsBoard();board.setRank(rank==null?0:rank.intValue()+1);board.setPoints(score==null?0:score.intValue());returnboard;}二 历史排行榜
1.定时任务生成榜单表
PointsBoardPersistentHandler.java
@XxlJob("createTableJob")publicvoidcreatePointBoardTableOfLastSeason(){//1.获取上个月的时间LocalDateTimetime=LocalDateTime.now().minusMonths(1);//2.查询赛季idIntegerseasonId=pointsBoardSeasonService.querySeasonIdByTime(time);//3.创建积分榜单表if(seasonId==null){thrownewBadRequestException("当前赛季不存在");}pointsBoardService.createPointsBoardTable(seasonId);}IPointsBoardSeasonService.java+IPointsBoardService.java
IntegerquerySeasonIdByTime(LocalDateTimetime);voidcreatePointsBoardTable(IntegerseasonId);PointsBoardSeasonServiceImpl.java+PointsBoardServiceImpl.java
@OverridepublicIntegerquerySeasonIdByTime(LocalDateTimetime){Optional<PointsBoardSeason>pointsBoardSeason=lambdaQuery().le(PointsBoardSeason::getBeginTime,time).ge(PointsBoardSeason::getEndTime,time).oneOpt();returnpointsBoardSeason.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
<insertid="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的配置类
packagecom.tianji.common.autoconfigure.mybatis;importcom.baomidou.mybatisplus.annotation.DbType;importcom.baomidou.mybatisplus.core.mapper.BaseMapper;importcom.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;importcom.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;importcom.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.autoconfigure.condition.ConditionalOnClass;importorg.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;@Configuration@ConditionalOnClass({MybatisPlusInterceptor.class,BaseMapper.class})publicclassMybatisConfig{/** * @see MyBatisAutoFillInterceptor 通过自定义拦截器来实现自动注入creater和updater * @deprecated 存在任务更新数据导致updater写入0或null的问题,暂时废弃 */// @Bean// @ConditionalOnMissingBeanpublicBaseMetaObjectHandlerbaseMetaObjectHandler(){returnnewBaseMetaObjectHandler();}@Bean@ConditionalOnMissingBeanpublicMybatisPlusInterceptormybatisPlusInterceptor(@Autowired(required=false)DynamicTableNameInnerInterceptordynamicTableNameInnerInterceptor){MybatisPlusInterceptorinterceptor=newMybatisPlusInterceptor();PaginationInnerInterceptorpaginationInnerInterceptor=newPaginationInnerInterceptor(DbType.MYSQL);paginationInnerInterceptor.setMaxLimit(200L);interceptor.addInnerInterceptor(paginationInnerInterceptor);interceptor.addInnerInterceptor(newMyBatisAutoFillInterceptor());if(dynamicTableNameInnerInterceptor!=null){interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);}returninterceptor;}}PointsBoardPersistentHandler.java
这里还借助了xxljob的分片广播,主要应用于多实例部署时,可以实现多实例分片,类似于取模运算的思路,每个实例负责不同的部分。
@XxlJob("savePointsBoard2DB")publicvoidsavePointsBoard2DB(){//1.获取上个月的时间LocalDateTimetime=LocalDateTime.now().minusMonths(1);//2.查询赛季id// 2.1拼接Redis当中的keyStringkey=RedisConstants.POINTS_BOARDS_KEY_PREFIX+time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);// 2.2查询数据PointsBoardQuerypointsBoardQuery=newPointsBoardQuery();intindex=XxlJobHelper.getShardIndex();inttotal=XxlJobHelper.getShardTotal();pointsBoardQuery.setPageNo(index+1);pointsBoardQuery.setPageSize(100);// 2.3计算动态表名,存入ThreadLocal中IntegerseasonId=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在所有版本中可用。
示例对比
# 同步删除,可能阻塞主线程DEL large_key# 异步删除,立即返回,后台清理UNLINK large_key总结
| 特性 | DEL | UNLINK |
|---|---|---|
| 删除方式 | 同步 | 异步 |
| 阻塞主线程 | 是(大键时) | 否 |
| 适用场景 | 小键或需立即释放内存 | 大键或高并发场景 |
| 版本支持 | 所有版本 | Redis 4.0+ |
建议:优先