老板说“搞个实时排行榜”,我用Redis ZSet + SpringBoot 2小时搞定,性能直接拉满
一、需求来了:百万用户的积分排行榜,要实时!
兄弟们,你们遇到过这种需求吗?产品经理跑过来说:“我们要做一个用户积分排行榜,要实时更新的那种,用户一有积分变动,榜单马上就能看到。”
我当时心里一紧:用户量百万级,积分实时变动,还要毫秒级响应——这不就是排行榜的经典场景吗?
如果直接用MySQL做排序,ORDER BY score DESC LIMIT 10,在百万数据下,即使有索引,查询也要几十毫秒甚至上百毫秒。而且每次用户积分变动都要重新计算排名,数据库根本扛不住。
我当时第一反应就是:用Redis的有序集合(ZSet)。ZSet底层是跳表(skiplist),插入、更新、查询排名的时间复杂度都是O(log N),百万级数据轻松应对。
今天就把这个完整方案从零到一复盘出来,包含源码、配置、API和踩坑点。
二、技术选型:为什么是Redis ZSet?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MySQL ORDER BY | 实现简单 | 百万级数据排序慢,频繁更新压力大 | 数据量小、非实时 |
| Redis ZSet | 读写快(O(log N)),天然支持排序 | 内存占用 | 实时排行榜首选 |
| Elasticsearch | 功能强大,支持复杂聚合 | 太重,运维成本高 | 大数据分析场景 |
Redis ZSet(有序集合)的每个元素都关联一个double类型的分数,按分数从小到大排序。它天然支持:
-
ZADD:添加/更新元素分数 -
ZREVRANK:获取元素排名(分数从高到低) -
ZREVRANGE:按分数从高到低获取指定范围的元素
一句话总结:排行榜核心数据放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: 5
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
3.4 MySQL建表脚本
MySQL只存储用户基础信息,排行榜实时排序完全交给Redis ZSet,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 接口
SysUserScoreMapper.java
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
*/
@Mapper
public 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
@RequiredArgsConstructor
public 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);
}
}
@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;
}
@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(@RequestBody SysUserScoreQuery 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(@RequestParam String userId, @RequestParam BigDecimal 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(@RequestParam String 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配置(解决序列化乱码)
@Configuration
public 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;
}
}
四、性能实测:百万数据毫秒级响应
| 操作 | Redis命令 | 时间复杂度 | 百万数据实测 |
|---|---|---|---|
| 插入/更新积分 | ZADD | O(log N) | < 1ms |
| 积分累加 | ZINCRBY | O(log N) | < 1ms |
| 查用户排名 | ZREVRANK | O(log N) | < 1ms |
| 查TOP榜单 | ZREVRANGE | O(log N + M) | < 1ms |
百万级数据下,ZSet的增删改查基本都能做到10ms以内。
内存评估:100万条ZSet,单条userId为短字符串,占用内存大概几十MB级别。
五、踩坑与避坑指南
坑1:排名从0开始
Redis ZSet的rank返回的是下标(从0开始),业务展示排名需要+1。
// ❌ 错误:直接返回rank return rank; // 第一名返回0 // ✅ 正确:+1后返回 return rank + 1; // 第一名返回1
坑2:分数相同时的排序规则
Redis ZSet分数相同时,按照元素的字典序排序。如果业务需要自定义同分规则(如先达到该分数的排前面),需要额外处理。
解决方案:将分数设计为score + 时间戳/1000000000的组合,保证分数相同时先达到的排前面。
坑3:数据一致性问题
更新时同时写MySQL和Redis,极端场景下Redis宕机会出现不一致。
生产解决方案:
-
定时任务:定期将MySQL的score全量刷入ZSet做兜底同步
-
或者使用Canal监听MySQL binlog,异步更新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过期时间自动清理历史榜单。
六、生产环境部署建议
6.1 Redis配置建议
# redis.conf maxmemory 2gb maxmemory-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 | 查询用户排名(1 开始) |
| POST /rank/list | 用户积分排名分页查询 |
| GET /rank/top?start=0&end=9&reverse=true | 获取 TOP10 排行榜 |
浙公网安备 33010602011771号