当前位置:首页>排行榜>老板说“搞个实时排行榜”,我用Redis ZSet + SpringBoot 2小时搞定,性能直接拉满

老板说“搞个实时排行榜”,我用Redis ZSet + SpringBoot 2小时搞定,性能直接拉满

  • 更新时间 2026-08-23 03:46:11
老板说“搞个实时排行榜”,我用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:      host127.0.0.1      port6379      database0      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(64NOT NULL COMMENT '用户唯一id',    `user_name` VARCHAR(64NOT NULL COMMENT '用户名称',    `score` DECIMAL(182NOT 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(50DEFAULT NULL COMMENT '创建人',    `update_by` varchar(50DEFAULT 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(012);                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);    }}

关键点说明:

  1. incrementScore是原子操作,Redis保证并发安全

  2. 先更新Redis再更新MySQL,保证实时性

  3. 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<StringinitData(@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<StringaddScore(@RequestParam String userId, @RequestParam double addScore) throws WebException {        if (userId == null) {            _throwEx("userId不能为空"); //来自BaseRest        }        userScoreRankService.addUserScore(userId, addScore);        return successCodeResponse(); //响应代理    }    /**     * 查询用户排名     */    @GetMapping("/rank")    public BaseResponse<StringgetRank(@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;    }}

四、性能实测:百万数据毫秒级响应

操作
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。

// ❌ 错误:直接返回rankreturn 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过期时间自动清理历史榜单。

六、生产环境部署建议

# 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
查询用户排名(1 开始)
POST /rank/list
用户积分排名分页查询
GET /rank/top?start=0&end=9&reverse=true
获取 TOP10 排行榜

七、总结

核心要点
说明
技术选型
Redis ZSet天然支持排序,O(log N)复杂度,百万数据毫秒响应
数据架构
Redis存排行榜实时数据,MySQL存用户基础信息做兜底
核心操作
ZADD更新、ZREVRANK查排名、ZREVRANGE查TOP榜单
原子性
ZINCRBY保证积分累加的原子性,无需额外加锁
避坑要点
rank从0开始需+1;同分按字典序;大榜必须分页;做好Redis-MySQL一致性兜底

一句话总结:Redis ZSet + SpringBoot,2小时搞定百万级实时排行榜,性能拉满,成本可控。

兄弟们,你们在生产环境中做过排行榜功能吗?遇到过同分排序、数据一致性、大榜分页这些问题吗?

最新文章

随机文章