Spring Boot 集成 Redis ZSet 实现实时排行榜、动态排名、同分加权与多维度榜单实战

1. 业务场景与挑战

在直播、游戏、电商这类业务里,排行榜几乎是无处不在的------直播间的礼物贡献榜、游戏的天梯分数榜、电商的销量榜,共同点是数据更新频繁,用户对实时性极其敏感。

拿直播间送礼来说,粉丝送完礼物,榜单必须马上刷新。延迟个几百毫秒,观众就会觉得卡了,体验大打折扣。传统的 MySQL 用 ORDER BY 排序,在百万用户、每秒几千次送礼写入的压力下,很快扛不住。即便加了索引、做了读写分离,依然难以保证毫秒级的写入和读取响应。

Redis 的 ZSet(有序集合) 恰好是干这个的。底层用跳表和哈希表实现,单节点轻松支撑几十万 QPS,集合内排序时间复杂度 O(log N),天然适合"实时排名"这个场景。下面我们结合 Spring Boot,从工程落地的角度,把这套支持动态排名、同分加权、多维度的实时排行榜系统拆开揉碎讲一遍。

2. Redis ZSet 核心数据结构

ZSet 是有序、无重复元素的集合。每个元素关联一个 double 类型的 score,Redis 按 score 从低到高排序。排行榜常用的操作就这几个:

  • ZADD key score member:添加或更新元素和分数。
  • ZINCRBY key increment member:给指定元素加分,原子操作。
  • ZREVRANGE key start stop [WITHSCORES]:按分数从高到低返回区间内元素,Top N 就靠它。
  • ZRANGE key start stop:按分数从低到高返回。
  • ZSCORE key member:查某个元素的分数。
  • ZRANK / ZREVRANK:获取元素升序/降序排名,从 0 开始。

ZSet 的元素是唯一的,所以一般用业务 ID 做 member,比如用户 ID、商品 ID,score 则按业务规则来定。

3. 整体架构设计

一个靠谱的实时排行榜系统,不可能只靠一个 Redis。需要分几层来搭,兼顾性能、可用性和数据一致性。

复制代码
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  业务服务     │───▶│  Redis Cluster│───▶│  MySQL/NoSQL│
│  (SpringBoot)│    │  (实时榜单)   │    │  (持久化)    │
└──────────────┘    └──────────────┘    └──────────────┘
       │                    ▲                    ▲
       │ 1. 写入操作        │ 3. 异步回源         │ 4. 定期快照
       │ 2. 查询操作        │                    │
       └────────────────────┘                    │
                   本地缓存(Caffeine)           │
  • 写入路径 :业务事件(比如送礼)来了之后,先调 ZINCRBY 等命令写 Redis,毫秒级完成,同时异步发 MQ 或直接异步写持久化层。
  • 读取路径:查榜单的时候优先命中本地缓存,没命中再查 Redis,拿到结果返回给前端。
  • 持久化保障:Redis 存热数据,MySQL 做最终持久化。通过异步任务定期把 Redis 数据快照到数据库,或者在每次写入时同步记一份操作日志,用于后续回放。

4. score 设计:主分 + 次分偏移

排行榜的核心在于"score 怎么设计"。很多场景下,分数相同会导致排名不稳定。比如游戏天梯,两个人都是 100 分,谁先达到谁排前面?直播礼物榜,俩人送出等额礼物,先送的排前面,这是业务直觉。

如果用原始业务分数直接当 ZSet 的 score,同分时 Redis 按 member 的字典序排,根本不符合业务习惯。所以必须对 score 做编码,把"主分"和"次分"打包进一个 double 数里。

常见方案:score = 主分 * 10^N + 次分偏移

主分 是业务核心分数,次分偏移 用来表示时间权重。如果主分大的排名靠前,又要求"先到先得",那次分偏移就搞成"时间戳的倒数"或者"用一个大数减去时间戳"。

比如:

  • offset = MAX_TIMESTAMP - System.currentTimeMillis() 或者 1_000_000_000_000_000L - timestamp

但注意,score 是 double,精度有限,就是 53 位整数精度。主分和偏移必须控制在安全范围内,否则一不小心就出现分数错乱。

如果主分数量级不大(比如小于 10^7),偏移控制在 10^6 以内,那直接编码进一个 double 是可行的。举个例子:

java 复制代码
double score = mainScore * 1_000_000 + (1_000_000 - timestampOffset);

timestampOffset 可以取 System.nanoTime() 取模,或者用自定义的相对时间。别整太复杂,确保 Java 这边计算的时候别丢精度就行。

说句实在话,真到了生产环境,我一般建议直接用 long 先算好,再转成 double。还有,如果你用的 Redis 是 7.0+,有 ZRANGE 扩展,也可以考虑前缀排序的玩法,但 ZSet 的 score 仍是主流。

5. 榜单维度 key 规划与缓存更新策略

排行榜往往不止一个维度:小时榜、日榜、周榜、总榜,或者按房间、区服分开。key 的命名规则要提前想清楚,方便管理,也方便自动过期。

常见的 key 设计:

复制代码
ranking:{bizType}:{dimension}:{windowId}
  • bizType:业务类型,如 gift、score、sale。
  • dimension:时间维度,如 hour、day、week、total。
  • windowId:具体窗口标识,如 2025031814(小时)、20250318(天)、202503(周)。

举几个实际的例子:

  • ranking:gift:room:1001 --- 房间 1001 的礼物总榜
  • ranking:gift:hour:2025031814 --- 某小时礼物榜
  • ranking:sale:day:20250318 --- 某天销售榜
  • ranking:score:week:20250317 --- 某周赛分数榜

缓存更新策略这块,有几点是实操中必须考虑的:

  • 实时写入 :业务发生时就 ZINCRBY 更新对应窗口的 ZSet。
  • 窗口过期删除:小时榜、日榜这种,给 key 设置 TTL = 窗口长度 + 缓冲时间(比如 2 倍窗口)。Redis 自动清理,省心。
  • 热点榜单预热:定时任务提前建好下一个窗口的 key,别等到并发写的时候才去创建,容易抖动。
  • 本地缓存:Top N 榜单设个几十秒甚至几秒的本地缓存,能明显减轻 Redis 的压力。

6. 核心操作实践

Spring Boot 里用 StringRedisTemplate 或 RedisTemplate<String, String> 就行。代码不多,直接看。

更新分数(实时)

java 复制代码
@Autowired
private StringRedisTemplate redisTemplate;

public void addGift(String roomId, Long userId, double amount) {
    String totalKey = "ranking:gift:room:" + roomId;
    String hourKey = "ranking:gift:hour:" + hourWindow();
    String dayKey = "ranking:gift:day:" + dayWindow();

    // ZINCRBY 原子增加
    redisTemplate.opsForZSet().incrementScore(totalKey, userId.toString(), amount);
    redisTemplate.opsForZSet().incrementScore(hourKey, userId.toString(), amount);
    redisTemplate.opsForZSet().incrementScore(dayKey, userId.toString(), amount);

    // 设置过期时间
    redisTemplate.expire(hourKey, Duration.ofHours(2));
    redisTemplate.expire(dayKey, Duration.ofDays(2));
}

查询 Top N

java 复制代码
public List<UserScore> getTopN(String key, int n) {
    // ZREVRANGE key 0 n-1 WITHSCORES
    Set<ZSetOperations.TypedTuple<String>> tuples =
        redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, n - 1);
    // 解析封装...
}

查询当前用户排名

java 复制代码
public Long getRank(String key, Long userId) {
    // ZREVRANK 返回从高到低的排名,从 0 开始
    Long rank = redisTemplate.opsForZSet().reverseRank(key, userId.toString());
    return rank == null ? null : rank + 1; // 转换成语义上的第 1 名
}

有个细节容易搞错:reverseRank 返回 0 表示分数最高,所以对外展示的时候要 +1。

7. 同分排名策略:时间戳编码与辅助 ZSet

同分时要求"先到先得"------相同主分下,先达到该分数的用户排名更高。前面提到可以把时间因素编码进 score,但实际操作起来有个不太舒服的地方:ZINCRBY 是加增量,你没法在加的过程中直接控制"首次达到该分数的时刻"。

这里业界常用的有两种做法。

7.1 时间戳编码方式

我们定义:

复制代码
score = 主分 * OFFSET + (LIMIT_TIME - minTimestamp)

LIMIT_TIME 是一个足够大的常量,minTimestamp 是用户在当前排名维度下最早一次达到该分数的时刻。但问题是,这个 minTimestamp 得单独存,而且主分变化时要重算 score。

一种简化的做法:每次更新都直接 ZADD 设置一次性 score,而不是增量更新。比如累加型业务(礼物金额累加):

  • 每次执行 ZADD key score member,其中 score = currentTotal * FACTOR + (MAX_TIME - firstTimeStamp)
  • currentTotal 是累加后的总金额,firstTimeStamp 是第一次产生礼物行为的时间戳。

但这里有个坑:两个请求并发读 currentTotal,然后写回,会把更新搞丢。所以要么用 Lua 脚本 做原子化,要么干脆用 ZINCRBY 只加主分,然后另配一个 ZSet 来修正同分排名。

7.2 辅助 ZSet 方案

我更倾向于用两个 ZSet 来解:

  • ranking:score:{key}:score 只存主分(业务分数),用于展示真实分数和按分数排序。
  • ranking:time:{key}:score 存时间戳,越小越早,用于同分时的先后判定。

分数变化时,两个 ZSet 同时更新。排名的时候,需要"先按主分降序,再按时间戳升序"排序。Redis 不支持跨 key 排序,但在 Java 层可以合并处理:

  1. 查 ranking:score 的前 N+50 个候选。
  2. 批量查这些候选的 ranking:time 时间戳。
  3. 内存里按主分降序、时间戳升序排序,截取 Top N。

这方案逻辑清晰,能精确控制同分排名,代价是多一次 ZSet 操作和内存排序。对 Top N 远大于同分人数的情况,内存排序成本几乎可以忽略。

如果业务对排名准确性要求很高(比如竞赛榜单),我建议就用这个双 ZSet 方案。

8. Spring Boot 集成实战代码

以"直播礼物榜"为例,看下关键代码长什么样。

8.1 依赖与配置

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

application.yml:

yaml 复制代码
spring:
  data:
    redis:
      host: localhost
      port: 6379
      lettuce:
        pool:
          max-active: 100
          max-idle: 50
          min-idle: 10

8.2 排行榜服务类

java 复制代码
@Service
public class RankingService {

    private static final long FACTOR = 1_000_000L;
    private static final long MAX_TIME = 4_000_000_000_000L;

    @Autowired
    private StringRedisTemplate redisTemplate;

    // 更新礼物分数(主分+时间权重)------单ZSet方案,简化示例
    public void addGiftWithTime(String roomId, Long userId, double amount) {
        String key = "ranking:gift:room:" + roomId;
        Long now = System.currentTimeMillis();
        Double current = redisTemplate.opsForZSet().score(key, userId.toString());
        double newMain = (current == null ? 0 : Math.floor(current / FACTOR)) + amount;
        // 首次时间戳需要单独存,简化处理
        String timeKey = "ranking:gift:time:" + roomId + ":" + userId;
        String timeVal = redisTemplate.opsForValue().get(timeKey);
        Long firstTime = timeVal == null ? now : Long.parseLong(timeVal);
        // 更新主分+时间偏移
        double score = newMain * FACTOR + (MAX_TIME - firstTime);
        redisTemplate.opsForZSet().add(key, userId.toString(), score);
        // 更新首次时间
        if (timeVal == null) {
            redisTemplate.opsForValue().set(timeKey, String.valueOf(now));
        }
    }

    // 获取TopN
    public List<RankItem> getTopN(String key, int n) {
        Set<ZSetOperations.TypedTuple<String>> tuples =
            redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, n - 1);
        List<RankItem> list = new ArrayList<>();
        if (tuples == null) return list;
        int rank = 1;
        for (ZSetOperations.TypedTuple<String> tuple : tuples) {
            String member = tuple.getValue();
            Double score = tuple.getScore();
            long mainScore = (long) (score / FACTOR);
            list.add(new RankItem(member, mainScore, rank++));
        }
        return list;
    }

    // 获取用户排名(语义名次,从1开始)
    public Long getRank(String key, Long userId) {
        Long rank = redisTemplate.opsForZSet().reverseRank(key, userId.toString());
        return rank == null ? null : rank + 1;
    }
}

这段代码是为了说明思路才简化成这样的,实际上并发场景下有坑,生产环境必须用 Lua 保证原子性,后面讲。

9. 管道与 Lua 性能优化

高并发下,单个 Redis 命令一个个发,RTT 开销是个大问题。两个优化手段:管道(Pipeline)和 Lua 脚本。

9.1 管道批量写入

直播场景里,一次可能批量送多个礼物。把多个 ZINCRBY 塞进管道里一次发出去:

java 复制代码
public void batchAddGift(String roomId, List<GiftEvent> events) {
    String key = "ranking:gift:room:" + roomId;
    var session = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        for (GiftEvent event : events) {
            byte[] rawKey = key.getBytes();
            byte[] rawMember = event.getUserId().toString().getBytes();
            connection.zIncrBy(rawKey, event.getAmount(), rawMember);
        }
        return null;
    });
}

executePipelined 会减少网络往返,吞吐量提升非常明显。

9.2 Lua 脚本原子操作

像"更新主分 + 更新首次时间"这种复合逻辑,必须原子执行。Lua 脚本是常见解法:

lua 复制代码
-- key[1] = ranking key
-- key[2] = time key
-- argv[1] = member
-- argv[2] = increment
-- argv[3] = current timestamp

local current = redis.call('ZSCORE', KEYS[1], ARGV[1])
local main = 0
local firstTime = nil

if current then
    main = math.floor(current / 1000000)
end

firstTime = redis.call('GET', KEYS[2])
if not firstTime then
    firstTime = ARGV[3]
    redis.call('SET', KEYS[2], firstTime)
end

main = main + tonumber(ARGV[2])
local score = main * 1000000 + (4000000000000 - tonumber(firstTime))
redis.call('ZADD', KEYS[1], score, ARGV[1])
return score

Spring Boot 里用 DefaultRedisScript 执行,保证原子性,防止并发环境下时间戳覆盖或分数丢失。

10. 本地缓存降级

Redis 本身性能已经不错了,但排行榜接口动辄上万的 QPS,每次都打到 Redis 上也挺浪费的。可以在应用层加一层本地缓存(比如 Caffeine),对 Top N 榜单做短时间缓存。

几个要点:

  • 缓存 key = 榜单 key + 维度 + 页号。
  • 过期时间设 5~15 秒,根据业务容忍度来。
  • 缓存空值,避免缓存穿透。
  • Redis 不可用的时候,本地缓存还能作为降级方案,返回最近一次的数据,不至于直接挂掉。
java 复制代码
@Cacheable(cacheNames = "rankTopN", key = "#key + '-' + #topN", unless = "#result == null")
public List<RankItem> getTopNWithCache(String key, int topN) {
    // 查 Redis
}

本地缓存有个问题需要考虑:多实例部署时,每个实例缓存是独立的,数据可能不一致。但对于排行榜这种最终一致的场景,问题不大,值得用。

11. 多时间窗口:小时榜/日榜/周榜

多时间窗口的难点在于生成 windowId。

java 复制代码
public String hourWindow() {
    return DateTimeFormatter.ofPattern("yyyyMMddHH").format(LocalDateTime.now());
}

public String dayWindow() {
    return DateTimeFormatter.ofPattern("yyyyMMdd").format(LocalDateTime.now());
}

public String weekWindow() {
    LocalDate now = LocalDate.now();
    LocalDate monday = now.with(java.time.DayOfWeek.MONDAY);
    return monday.format(DateTimeFormatter.BASIC_ISO_DATE);
}

用户操作发生时,同时更新小时、日、周、总榜的 ZSet。但实际操作中,写入量太大会成为瓶颈。一种做法是只更新总榜和日榜,再通过异步任务滚动聚合生成周榜/月榜。具体怎么选,看业务容忍度:

  • 实时性要求高:每个窗口都实时更新,命令多了,成本高。
  • 能容忍分钟级延迟:定时任务扫描小时榜,增量合并到日榜和月榜。

我一般用双层结构:实时写小时榜,然后一个定时任务(每5分钟)把小时榜增量合并到日榜/周榜。这样 Redis 的写入压力能降不少,最终一致性也能保证。

12. 数据一致性保障

Redis 的数据是易失的,虽然有 RDB/AOF 持久化,极端情况下还是可能丢。所以体系外必须做数据最终一致性保障。

12.1 异步回源数据库

业务操作除了更新 Redis,还把操作日志异步写入 MySQL(或发到 Kafka)。后台任务定期从 MySQL 统计分数,重建 Redis 的 ZSet。

比如每分钟执行一次:

sql 复制代码
SELECT user_id, SUM(amount) FROM gift_record GROUP BY user_id;

然后把结果写回 Redis ZSet。这种方式适合实时性要求没那么高的场景。

12.2 内存队列 + 批量落库

服务里维护一个阻塞队列,用户行为进队列,后台线程批量刷 MySQL。然后定时任务从 MySQL 读累计值,更新 Redis。延迟在秒级,还能减轻数据库压力。

12.3 Redis 持久化配置

工程上 Redis 至少把 AOF 打开:

复制代码
appendonly yes
appendfsync everysec

这样每秒落盘一次,最坏丢一秒数据。排行榜场景丢一秒数据,一般没啥感觉。

12.4 定期全量校准

每天凌晨或者每周,用 MySQL 全量数据重建所有排行 ZSet。即使日常运行有不一致,也会在下个周期自动被修正。这个我觉得挺重要的,相当于一个兜底。

13. 压测与总结

自己实测过一组数据,机器是 8C16G 的单机 Redis,Spring Boot 单实例,1000 并发持续写礼物:

直接 MySQL 排序写入大概不到 1000 TPS,Top 100 查询响应都在 800 毫秒以上。换成 Redis ZINCRBY 后,写入 TPS 直接到了 12000 左右,查询降到 8 毫秒。再加上 Pipeline,写入能冲到接近 30000 TPS。用 Lua 原子更新的话差不多 25000 左右,毕竟脚本有点开销但能保证一致性和排名准确。查询那边如果挂上本地缓存,响应直接 1 毫秒以内,只有缓存失效那一下才打到 Redis。

说实话,Redis ZSet 用好了,实时排行榜完全是降维打击。但有一点必须意识到:它只是解决实时性那一环,数据落库、最终一致、多实例缓存一致性这些,都得靠业务代码和架构来兜底。方案没有银弹,关键还是看你对业务数据量级、访问模型和分析成本的真实把握。先做原型验证,压测观察结果,再逐步完善。这样一步步来,才不容易翻车。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
程序员cxuan2 小时前
Gemini 4 Argon 正式发布!
人工智能·后端·程序员
IT研究室2 小时前
最新计算机毕业设计选题推荐-基于spring boot的教育学习平台-网站-文档指导-Java-springboot
java·spring boot·课程设计
亚林瓜子2 小时前
Spring Boot 集成 ip2region 离线 IP 库实战指南
java·spring boot·后端·ip·ip2region
盛世宏博智慧档案3 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10243 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
Thneonl3 小时前
ZK、etcd、Consul 怎么选:差别不在共识,在壳
后端·架构
vipxieliang3 小时前
ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据
java·spring boot·后端
花间相见3 小时前
【计算基础|网络15】【上手】Socket 网络编程:亲手写 TCP/UDP 程序
后端
huaweichenai3 小时前
spring boot实现base64上传
java·spring boot·后端