一、需求来了:百万用户的积分排行榜,要实时!
兄弟们,你们遇到过这种需求吗?产品经理跑过来说:“我们要做一个用户积分排行榜,要实时更新的那种,用户一有积分变动,榜单马上就能看到。”
我当时心里一紧:用户量百万级,积分实时变动,还要毫秒级响应——这不就是排行榜的经典场景吗?
如果直接用MySQL做排序,ORDER BY score DESC LIMIT 10,在百万数据下,即使有索引,查询也要几十毫秒甚至上百毫秒。而且每次用户积分变动都要重新计算排名,数据库根本扛不住。
我当时第一反应就是:用Redis的有序集合(ZSet)。ZSet底层是跳表(skiplist),插入、更新、查询排名的时间复杂度都是O(log N),百万级数据轻松应对。
今天就把这个完整方案从零到一复盘出来,包含源码、配置、API和踩坑点。
二、技术选型:为什么是Redis ZSet?
Redis ZSet(有序集合)的每个元素都关联一个double类型的分数,按分数从小到大排序。它天然支持:
一句话总结:排行榜核心数据放Redis,MySQL只做用户基础信息持久化,不把排序计算放数据库。
三、项目实战:完整代码实现
spring-boot-redis-rank-learn/├── src/main/java/com/mate/cloud/redis/rank/│ ├── constants/│ │ └── UserRankConstants.java # Redis Key常量│ ├── entity/│ │ └── SysUserScore.java # 用户积分实体│ ├── mapper/│ │ └── SysUserScoreMapper.java # MyBatis-Plus Mapper│ ├── service/│ │ ├── UserScoreRankService.java # 服务接口│ │ └── impl/│ │ └── UserScoreRankServiceImpl.java # 核心实现│ └── controller/│ └── UserScoreRankController.java # REST API└── src/main/resources/ ├── application.yml # 配置文件 └── mapper/ └── SysUserScoreMapper.xml # MyBatis XML
3.2 Maven依赖
<dependencies> <!-- Spring Boot Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Boot Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot-starter</artifactId> <version>3.5.5</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency></dependencies>
3.3 application.yml 配置
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${MYSQL_HOST:mate-mysql}:3306/learn?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true username: root password: root data: redis: host: 127.0.0.1 port: 6379 database: 0 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5mybatis-plus: mapper-locations: classpath:mapper/**/*.xml configuration: map-underscore-to-camel-case: true
3.4 MySQL建表脚本
CREATE DATABASE IF NOT EXISTS rank_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;USE rank_demo;CREATE TABLE `sys_user_score` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键id', `user_id` VARCHAR(64) NOT NULL COMMENT '用户唯一id', `user_name` VARCHAR(64) NOT NULL COMMENT '用户名称', `score` DECIMAL(18, 2) NOT NULL DEFAULT 0.00 COMMENT '用户积分', `is_del` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0:否 1:是', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', `update_by` varchar(50) DEFAULT NULL COMMENT '修改人', UNIQUE KEY uk_user_id (`user_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户积分表';
3.5 实体类 SysUserScore
package com.mate.cloud.redis.rank.entity;import com.mate.cloud.mybatis.bases.BasesModel;import lombok.Data;import lombok.EqualsAndHashCode;import java.io.Serial;import java.math.BigDecimal;/** * 用户积分 * * @author: MI * @email: 448341911@qq.com * @createTime: 2026/8/22 11:36 * @updateUser: MI * @updateTime: 2026/8/22 11:36 * @updateRemark: 修改内容 * @version: 1.0 */@Data@EqualsAndHashCode(callSuper = true)public class SysUserScore extends BasesModel<SysUserScore> { @Serial private static final long serialVersionUID = 4576620049851639248L; /** * 用户id */ private String userId; /** * 用户名 */ private String userName; /** * 用户积分 */ private BigDecimal score;}
3.6 Redis常量
package com.mate.cloud.redis.rank.constants;public class UserRankConstants { /** redis score key:积分排行榜 */ public static final String RANK_USER_SCORE = "rank:user:score";}
3.7 Mapper 接口
package com.mate.cloud.redis.rank.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.mate.cloud.redis.rank.entity.SysUserScore;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Param;import java.math.BigDecimal;import java.util.List;/** * 用户积分Mapper接口 * * @author: MI * @email: 448341911@qq.com * @createTime: 2026/8/22 11:51 * @updateUser: MI * @updateTime: 2026/8/22 11:51 * @updateRemark: 修改内容 * @version: 1.0 */@Mapperpublic interface SysUserScoreMapper extends BaseMapper<SysUserScore> { /** * 批量新增用户积分数据(百万初始化使用) * * @param list 实体列表 * @return 影响行数 */ int batchInsert(@Param("list") List<SysUserScore> list); /** * 根据userId更新分数 */ int updateScoreByUserId(@Param("userId") String userId, @Param("score") BigDecimal score);}
3.8 Service 接口
package com.mate.cloud.redis.rank.service;import com.baomidou.mybatisplus.core.metadata.IPage;import com.baomidou.mybatisplus.extension.service.IService;import com.mate.cloud.redis.rank.entity.SysUserScore;import com.mate.cloud.redis.rank.query.SysUserScoreQuery;import com.mate.cloud.redis.rank.vo.SysUserScoreVO;import org.springframework.data.redis.core.ZSetOperations;import java.math.BigDecimal;import java.util.Set;/** * 用户积分排行榜Service接口 * * @author: MI * @email: 448341911@qq.com * @createTime: 2026/8/22 11:51 * @updateUser: MI * @updateTime: 2026/8/22 11:51 * @updateRemark: 修改内容 * @version: 1.0 */public interface UserScoreRankService extends IService<SysUserScore> { /** * 清空排行榜redis数据 */ void clearRank(); /** * 生成测试数据,同时写入mysql + redis zset * * @param count 数据量 */ void generateTestData(int count); /** * 更新用户积分:覆盖模式 * * @param userId 用户id * @param score 分数 */ void updateUserScore(String userId, double score); /** * 用户积分累加 * * @param userId 用户id * @param addScore 增加的分数 */ void addUserScore(String userId, double addScore); /** * 获取用户排名 从1开始;reverseRank=true:分数越高排名越靠前 * * @param userId 用户id * @return 排名,null代表不在榜单 */ Long getUserRank(String userId); /** * 获取榜单分页数据 * * @param start 起始下标 0 * @param end 结束下标 * @param reverse true:降序(高分在前) false升序 * @return 用户+分数集合 */ Set<ZSetOperations.TypedTuple<String>> getTopUsers(long start, long end, boolean reverse); /** * 根据userId更新分数 */ int updateScoreByUserId(String userId, BigDecimal score); /** * 用户积分排行榜分页查询 * * @param query * @return */ IPage<SysUserScoreVO> listPage(SysUserScoreQuery query);}
3.9 Service核心实现
package com.mate.cloud.redis.rank.service.impl;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;import com.baomidou.mybatisplus.core.metadata.IPage;import com.baomidou.mybatisplus.core.toolkit.Wrappers;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.mate.cloud.core.util.BeanMapper;import com.mate.cloud.redis.rank.constants.UserRankConstants;import com.mate.cloud.redis.rank.entity.SysUserScore;import com.mate.cloud.redis.rank.mapper.SysUserScoreMapper;import com.mate.cloud.redis.rank.query.SysUserScoreQuery;import com.mate.cloud.redis.rank.service.UserScoreRankService;import com.mate.cloud.redis.rank.vo.SysUserScoreVO;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.core.ZSetOperations;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;import java.util.*;/** * 用户积分排行实现 * * @author: MI * @email: 448341911@qq.com * @createTime: 2026/8/22 11:44 * @updateUser: MI * @updateTime: 2026/8/22 11:44 * @updateRemark: 修改内容 * @version: 1.0 */@Slf4j@Service@RequiredArgsConstructorpublic class UserScoreRankServiceImpl extends ServiceImpl<SysUserScoreMapper, SysUserScore> implements UserScoreRankService { private final RedisTemplate<String, Object> redisTemplate; @Override public void clearRank() { redisTemplate.delete(UserRankConstants.RANK_USER_SCORE); } @Override @Transactional(rollbackFor = Exception.class) public void generateTestData(int count) { // 清理缓存 clearRank(); // 每批次大小,可调整1000‑2000 final int BATCH_SIZE = 1000; int loop = count / BATCH_SIZE; int remain = count % BATCH_SIZE; for (int i = 0; i < loop; i++) { List<SysUserScore> dbList = new ArrayList<>(BATCH_SIZE); Set<ZSetOperations.TypedTuple<Object>> redisTupleSet = new HashSet<>(BATCH_SIZE); for (int j = 0; j < BATCH_SIZE; j++) { String userId = "u_" + UUID.randomUUID().toString().substring(0, 12); String userName = "用户_" + (i * BATCH_SIZE + j); double score = Math.random() * 100000; SysUserScore userScore = new SysUserScore(); userScore.setUserId(userId); userScore.setUserName(userName); userScore.setScore(BigDecimal.valueOf(score)); dbList.add(userScore); //组装redis zset批量元组 ZSetOperations.TypedTuple<Object> tuple = ZSetOperations.TypedTuple.of(userId, score); redisTupleSet.add(tuple); } //mysql批量插入 baseMapper.batchInsert(dbList); //redis zset批量添加 redisTemplate.opsForZSet().add(UserRankConstants.RANK_USER_SCORE, redisTupleSet); } } /** * 覆盖设置分数 */ @Override @Transactional(rollbackFor = Exception.class) public void updateUserScore(String userId, double score) { //1.更新mysql SysUserScore userScore = new SysUserScore(); userScore.setUserId(userId); userScore.setScore(BigDecimal.valueOf(score)); baseMapper.updateById(userScore); //2.覆盖redis zset分数 redisTemplate.opsForZSet().add(UserRankConstants.RANK_USER_SCORE, userId, score); } /** * 分数累加 */ @Override @Transactional(rollbackFor = Exception.class) public void addUserScore(String userId, double addScore) {// String redisKey = String.format(UserRankConstants.RANK_USER_SCORE, userId, addScore); // redis zset 原子累加 Double newScore = redisTemplate.opsForZSet().incrementScore(UserRankConstants.RANK_USER_SCORE, userId, addScore); BigDecimal scoreVal = BigDecimal.valueOf(newScore); log.info("分数累加 score:{}", scoreVal); // 更新mysql持久化 updateUserScore(userId, scoreVal); } private void updateUserScore(String userId, BigDecimal scoreVal) { // 根据userId查询记录 SysUserScore exist = baseMapper.selectOne(Wrappers.<SysUserScore>lambdaQuery() .eq(SysUserScore::getUserId, userId)); if (exist != null) { //存在:更新分数 exist.setScore(scoreVal); baseMapper.updateById(exist); } else { //不存在:新增 SysUserScore userScore = new SysUserScore(); userScore.setUserId(userId); userScore.setScore(scoreVal); baseMapper.insert(userScore); } } /** * 获取用户排名(从1开始) */ @Override public Long getUserRank(String userId) { // zrevrank:分数越大,排名数字越小(第一名=0下标,返回值+1转为业务排名从1开始) Long rank = redisTemplate.opsForZSet().reverseRank(UserRankConstants.RANK_USER_SCORE, userId); if (rank == null) { return null; } return rank + 1; // 下标0 → 排名1 } @Override public Set<ZSetOperations.TypedTuple<String>> getTopUsers(long start, long end, boolean reverse) { ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet(); if (reverse) { // 降序:高分在前 return (Set) zSetOps.reverseRangeWithScores(UserRankConstants.RANK_USER_SCORE, start, end); } else { // 升序:低分在前 return (Set) zSetOps.rangeWithScores(UserRankConstants.RANK_USER_SCORE, start, end); } } @Override public int updateScoreByUserId(String userId, BigDecimal score) { return baseMapper.updateScoreByUserId(userId, score); } @Override public IPage<SysUserScoreVO> listPage(SysUserScoreQuery query) { LambdaQueryWrapper<SysUserScore> queryWrapper = Wrappers.lambdaQuery(); Page<SysUserScore> page = new Page<>(query.getCurrent(), query.getSize()); // 分页查询 IPage<SysUserScore> sysUserScorePage = baseMapper.selectPage(page, queryWrapper); // entity → VO转换 List<SysUserScore> sysUserScoreList = sysUserScorePage.getRecords(); List<SysUserScoreVO> userScoreVOList = BeanMapper.convert(sysUserScoreList, SysUserScoreVO.class); // 构建VO分页对象,复制分页元信息:当前页、页大小、总条数、总页数,设置转换后的VO集合 Page<SysUserScoreVO> voPage = new Page<>(query.getCurrent(), query.getSize(), sysUserScorePage.getTotal()); return voPage.setRecords(userScoreVOList); }}
关键点说明:
incrementScore是原子操作,Redis保证并发安全
先更新Redis再更新MySQL,保证实时性
reverseRank返回的是下标(从0开始),业务排名需要+1
4.0 Controller
package com.mate.cloud.redis.rank.controller;import cn.hutool.core.util.StrUtil;import com.baomidou.mybatisplus.core.metadata.IPage;import com.baomidou.mybatisplus.core.toolkit.Wrappers;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.mate.cloud.protocol.response.BaseResponse;import com.mate.cloud.protocol.web.WebException;import com.mate.cloud.protocol.web.controller.AdminBaseController;import com.mate.cloud.redis.rank.query.SysUserScoreQuery;import com.mate.cloud.redis.rank.service.UserScoreRankService;import com.mate.cloud.redis.rank.vo.SysUserScoreVO;import io.swagger.v3.oas.annotations.Operation;import jakarta.servlet.http.HttpServletRequest;import lombok.RequiredArgsConstructor;import org.springdoc.core.annotations.ParameterObject;import org.springframework.data.redis.core.ZSetOperations;import org.springframework.web.bind.annotation.*;import java.math.BigDecimal;import java.util.List;import java.util.Set;import java.util.stream.Collector;import java.util.stream.Collectors;/** * 用户积分排行榜 * * @author astupidcoder * @since 2021-06-26 */@RestController@RequiredArgsConstructor@RequestMapping("/rank")public class UserScoreRankController extends AdminBaseController { private final UserScoreRankService userScoreRankService; /** * 分页查询用户积分排行榜 * * @param page * @param sysDict * @return */ @PostMapping("/list") public BaseResponse<IPage<SysUserScoreVO>> list(@RequestBodySysUserScoreQuery query) { return successBodyResponse(userScoreRankService.listPage(query)); } /** * 初始化测试数据 */ @PostMapping("/init") public BaseResponse<String> initData(@RequestParam int count) { userScoreRankService.clearRank(); userScoreRankService.generateTestData(count); return successBodyResponse("初始化" + count + "条数据成功"); } /** * 覆盖更新用户积分 */ @PostMapping("/update") public BaseResponse updateScore(@RequestParamString userId, @RequestParamBigDecimal score) { return successBodyCondition(userScoreRankService.updateScoreByUserId(userId, score) >= 1); } /** * 积分累加 */ @PostMapping("/add") public BaseResponse<String> addScore(@RequestParam String userId, @RequestParam double addScore) throws WebException { if (userId == null) { _throwEx("userId不能为空"); //来自BaseRest } userScoreRankService.addUserScore(userId, addScore); return successCodeResponse(); //响应代理 } /** * 查询用户排名 */ @GetMapping("/rank") public BaseResponse<String> getRank(@RequestParamString userId) { Long rank = userScoreRankService.getUserRank(userId); return successMsgResponse(rank == null ? "未上榜" : "当前排名:" + rank); } /** * 获取榜单分页,reverse=true降序(高分在前) * start=0 end=9 获取top10 */ @GetMapping("/top") public BaseResponse<List<String>> getTop(@RequestParam(defaultValue = "0") int start, @RequestParam(defaultValue = "9") int end, @RequestParam(defaultValue = "true") boolean reverse) { Set<ZSetOperations.TypedTuple<String>> topUsers = userScoreRankService.getTopUsers(start, end, reverse); Collector<String, ?, List<String>> stringListCollector = Collectors.toList(); return successBodyResponse(topUsers.stream() .map(t -> t.getValue() + "(积分:" + String.format("%.2f", t.getScore()) + ")") .collect(stringListCollector)); }}
4.1 RedisTemplate配置(解决序列化乱码)
@Configurationpublic class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }}
四、性能实测:百万数据毫秒级响应
百万级数据下,ZSet的增删改查基本都能做到10ms以内。
内存评估:100万条ZSet,单条userId为短字符串,占用内存大概几十MB级别。
五、踩坑与避坑指南
坑1:排名从0开始
Redis ZSet的rank返回的是下标(从0开始),业务展示排名需要+1。
// ❌ 错误:直接返回rankreturn rank; // 第一名返回0// ✅ 正确:+1后返回return rank + 1; // 第一名返回1
坑2:分数相同时的排序规则
Redis ZSet分数相同时,按照元素的字典序排序。如果业务需要自定义同分规则(如先达到该分数的排前面),需要额外处理。
解决方案:将分数设计为score + 时间戳/1000000000的组合,保证分数相同时先达到的排前面。
坑3:数据一致性问题
更新时同时写MySQL和Redis,极端场景下Redis宕机会出现不一致。
生产解决方案:
坑4:不要一次性获取全部数据
// ❌ 危险:百万数据一次性拉取,内存爆炸Set<String> all = redisTemplate.opsForZSet().range(key, 0, -1);// ✅ 正确:分页获取Set<String> page = redisTemplate.opsForZSet().reverseRange(key, start, end);
坑5:ZSet的Key设计
日榜、周榜、总榜需要不同的ZSet Key:
rank:day:2026-08-22 # 日榜rank:week:2026-34 # 周榜rank:total # 总榜
搭配Redis过期时间自动清理历史榜单。
六、生产环境部署建议
# redis.confmaxmemory 2gbmaxmemory-policy allkeys-lru # 内存满时淘汰冷数据
6.2 降级方案
Redis不可用时,降级读取MySQL做排序(性能差,仅作为最后兜底)public List<SysUserScore> getTopFromMySQL(int limit){ return baseMapper.selectList( new LambdaQueryWrapper<SysUserScore>() .orderByDesc(SysUserScore::getScore) .last("LIMIT " + limit) );}
6.3 API接口汇总
| |
|---|
| POST /rank/init?count=1000000 | |
| POST /rank/update?userId=u_xxx&score=8888 | |
| POST /rank/add?userId=u_xxx&addScore=100 | |
| GET /rank/rank?userId=u_xxx | |
| |
| GET /rank/top?start=0&end=9&reverse=true | |
七、总结
| |
|---|
| Redis ZSet天然支持排序,O(log N)复杂度,百万数据毫秒响应 |
| Redis存排行榜实时数据,MySQL存用户基础信息做兜底 |
| ZADD更新、ZREVRANK查排名、ZREVRANGE查TOP榜单 |
| |
| rank从0开始需+1;同分按字典序;大榜必须分页;做好Redis-MySQL一致性兜底 |
一句话总结:Redis ZSet + SpringBoot,2小时搞定百万级实时排行榜,性能拉满,成本可控。
兄弟们,你们在生产环境中做过排行榜功能吗?遇到过同分排序、数据一致性、大榜分页这些问题吗?