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

📅 发布时间:2026/10/14 11:16:40
Spring Boot 集成 Redis ZSet 实现实时排行榜、动态排名、同分加权与多维度榜单实战
1. 业务场景与挑战在直播、游戏、电商这类业务里排行榜几乎是无处不在的——直播间的礼物贡献榜、游戏的天梯分数榜、电商的销量榜共同点是数据更新频繁用户对实时性极其敏感。拿直播间送礼来说粉丝送完礼物榜单必须马上刷新。延迟个几百毫秒观众就会觉得卡了体验大打折扣。传统的 MySQL 用ORDER BY排序在百万用户、每秒几千次送礼写入的压力下很快扛不住。即便加了索引、做了读写分离依然难以保证毫秒级的写入和读取响应。Redis 的ZSet有序集合恰好是干这个的。底层用跳表和哈希表实现单节点轻松支撑几十万 QPS集合内排序时间复杂度 O(log N)天然适合“实时排名”这个场景。下面我们结合 Spring Boot从工程落地的角度把这套支持动态排名、同分加权、多维度的实时排行榜系统拆开揉碎讲一遍。2. Redis ZSet 核心数据结构ZSet 是有序、无重复元素的集合。每个元素关联一个double类型的 scoreRedis 按 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、商品 IDscore 则按业务规则来定。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 是可行的。举个例子doublescoremainScore*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或RedisTemplateString, String就行。代码不多直接看。更新分数实时AutowiredprivateStringRedisTemplateredisTemplate;publicvoidaddGift(StringroomId,LonguserId,doubleamount){StringtotalKeyranking:gift:room:roomId;StringhourKeyranking:gift:hour:hourWindow();StringdayKeyranking: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 NpublicListUserScoregetTopN(Stringkey,intn){// ZREVRANGE key 0 n-1 WITHSCORESSetZSetOperations.TypedTupleStringtuplesredisTemplate.opsForZSet().reverseRangeWithScores(key,0,n-1);// 解析封装...}查询当前用户排名publicLonggetRank(Stringkey,LonguserId){// ZREVRANK 返回从高到低的排名从 0 开始LongrankredisTemplate.opsForZSet().reverseRank(key,userId.toString());returnranknull?null:rank1;// 转换成语义上的第 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 层可以合并处理查ranking:score的前 N50 个候选。批量查这些候选的ranking:time时间戳。内存里按主分降序、时间戳升序排序截取 Top N。这方案逻辑清晰能精确控制同分排名代价是多一次 ZSet 操作和内存排序。对 Top N 远大于同分人数的情况内存排序成本几乎可以忽略。如果业务对排名准确性要求很高比如竞赛榜单我建议就用这个双 ZSet 方案。8. Spring Boot 集成实战代码以“直播礼物榜”为例看下关键代码长什么样。8.1 依赖与配置dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependencydependencygroupIdorg.apache.commons/groupIdartifactIdcommons-pool2/artifactId/dependencyapplication.ymlspring:data:redis:host:localhostport:6379lettuce:pool:max-active:100max-idle:50min-idle:108.2 排行榜服务类ServicepublicclassRankingService{privatestaticfinallongFACTOR1_000_000L;privatestaticfinallongMAX_TIME4_000_000_000_000L;AutowiredprivateStringRedisTemplateredisTemplate;// 更新礼物分数主分时间权重——单ZSet方案简化示例publicvoidaddGiftWithTime(StringroomId,LonguserId,doubleamount){Stringkeyranking:gift:room:roomId;LongnowSystem.currentTimeMillis();DoublecurrentredisTemplate.opsForZSet().score(key,userId.toString());doublenewMain(currentnull?0:Math.floor(current/FACTOR))amount;// 首次时间戳需要单独存简化处理StringtimeKeyranking:gift:time:roomId:userId;StringtimeValredisTemplate.opsForValue().get(timeKey);LongfirstTimetimeValnull?now:Long.parseLong(timeVal);// 更新主分时间偏移doublescorenewMain*FACTOR(MAX_TIME-firstTime);redisTemplate.opsForZSet().add(key,userId.toString(),score);// 更新首次时间if(timeValnull){redisTemplate.opsForValue().set(timeKey,String.valueOf(now));}}// 获取TopNpublicListRankItemgetTopN(Stringkey,intn){SetZSetOperations.TypedTupleStringtuplesredisTemplate.opsForZSet().reverseRangeWithScores(key,0,n-1);ListRankItemlistnewArrayList();if(tuplesnull)returnlist;intrank1;for(ZSetOperations.TypedTupleStringtuple:tuples){Stringmembertuple.getValue();Doublescoretuple.getScore();longmainScore(long)(score/FACTOR);list.add(newRankItem(member,mainScore,rank));}returnlist;}// 获取用户排名语义名次从1开始publicLonggetRank(Stringkey,LonguserId){LongrankredisTemplate.opsForZSet().reverseRank(key,userId.toString());returnranknull?null:rank1;}}这段代码是为了说明思路才简化成这样的实际上并发场景下有坑生产环境必须用 Lua 保证原子性后面讲。9. 管道与 Lua 性能优化高并发下单个 Redis 命令一个个发RTT 开销是个大问题。两个优化手段管道Pipeline和 Lua 脚本。9.1 管道批量写入直播场景里一次可能批量送多个礼物。把多个ZINCRBY塞进管道里一次发出去publicvoidbatchAddGift(StringroomId,ListGiftEventevents){Stringkeyranking:gift:room:roomId;varsessionredisTemplate.executePipelined((RedisCallbackObject)connection-{for(GiftEventevent:events){byte[]rawKeykey.getBytes();byte[]rawMemberevent.getUserId().toString().getBytes();connection.zIncrBy(rawKey,event.getAmount(),rawMember);}returnnull;});}executePipelined会减少网络往返吞吐量提升非常明显。9.2 Lua 脚本原子操作像“更新主分 更新首次时间”这种复合逻辑必须原子执行。Lua 脚本是常见解法-- key[1] ranking key-- key[2] time key-- argv[1] member-- argv[2] increment-- argv[3] current timestamplocalcurrentredis.call(ZSCORE,KEYS[1],ARGV[1])localmain0localfirstTimenilifcurrentthenmainmath.floor(current/1000000)endfirstTimeredis.call(GET,KEYS[2])ifnotfirstTimethenfirstTimeARGV[3]redis.call(SET,KEYS[2],firstTime)endmainmaintonumber(ARGV[2])localscoremain*1000000(4000000000000-tonumber(firstTime))redis.call(ZADD,KEYS[1],score,ARGV[1])returnscoreSpring Boot 里用DefaultRedisScript执行保证原子性防止并发环境下时间戳覆盖或分数丢失。10. 本地缓存降级Redis 本身性能已经不错了但排行榜接口动辄上万的 QPS每次都打到 Redis 上也挺浪费的。可以在应用层加一层本地缓存比如 Caffeine对 Top N 榜单做短时间缓存。几个要点缓存 key 榜单 key 维度 页号。过期时间设 5~15 秒根据业务容忍度来。缓存空值避免缓存穿透。Redis 不可用的时候本地缓存还能作为降级方案返回最近一次的数据不至于直接挂掉。Cacheable(cacheNamesrankTopN,key#key - #topN,unless#result null)publicListRankItemgetTopNWithCache(Stringkey,inttopN){// 查 Redis}本地缓存有个问题需要考虑多实例部署时每个实例缓存是独立的数据可能不一致。但对于排行榜这种最终一致的场景问题不大值得用。11. 多时间窗口小时榜/日榜/周榜多时间窗口的难点在于生成windowId。publicStringhourWindow(){returnDateTimeFormatter.ofPattern(yyyyMMddHH).format(LocalDateTime.now());}publicStringdayWindow(){returnDateTimeFormatter.ofPattern(yyyyMMdd).format(LocalDateTime.now());}publicStringweekWindow(){LocalDatenowLocalDate.now();LocalDatemondaynow.with(java.time.DayOfWeek.MONDAY);returnmonday.format(DateTimeFormatter.BASIC_ISO_DATE);}用户操作发生时同时更新小时、日、周、总榜的 ZSet。但实际操作中写入量太大会成为瓶颈。一种做法是只更新总榜和日榜再通过异步任务滚动聚合生成周榜/月榜。具体怎么选看业务容忍度实时性要求高每个窗口都实时更新命令多了成本高。能容忍分钟级延迟定时任务扫描小时榜增量合并到日榜和月榜。我一般用双层结构实时写小时榜然后一个定时任务每5分钟把小时榜增量合并到日榜/周榜。这样 Redis 的写入压力能降不少最终一致性也能保证。12. 数据一致性保障Redis 的数据是易失的虽然有 RDB/AOF 持久化极端情况下还是可能丢。所以体系外必须做数据最终一致性保障。12.1 异步回源数据库业务操作除了更新 Redis还把操作日志异步写入 MySQL或发到 Kafka。后台任务定期从 MySQL 统计分数重建 Redis 的 ZSet。比如每分钟执行一次SELECTuser_id,SUM(amount)FROMgift_recordGROUPBYuser_id;然后把结果写回 Redis ZSet。这种方式适合实时性要求没那么高的场景。12.2 内存队列 批量落库服务里维护一个阻塞队列用户行为进队列后台线程批量刷 MySQL。然后定时任务从 MySQL 读累计值更新 Redis。延迟在秒级还能减轻数据库压力。12.3 Redis 持久化配置工程上 Redis 至少把 AOF 打开appendonly yes appendfsync everysec这样每秒落盘一次最坏丢一秒数据。排行榜场景丢一秒数据一般没啥感觉。12.4 定期全量校准每天凌晨或者每周用 MySQL 全量数据重建所有排行 ZSet。即使日常运行有不一致也会在下个周期自动被修正。这个我觉得挺重要的相当于一个兜底。13. 压测与总结自己实测过一组数据机器是 8C16G 的单机 RedisSpring Boot 单实例1000 并发持续写礼物直接 MySQL 排序写入大概不到 1000 TPSTop 100 查询响应都在 800 毫秒以上。换成 RedisZINCRBY后写入 TPS 直接到了 12000 左右查询降到 8 毫秒。再加上 Pipeline写入能冲到接近 30000 TPS。用 Lua 原子更新的话差不多 25000 左右毕竟脚本有点开销但能保证一致性和排名准确。查询那边如果挂上本地缓存响应直接 1 毫秒以内只有缓存失效那一下才打到 Redis。说实话Redis ZSet 用好了实时排行榜完全是降维打击。但有一点必须意识到它只是解决实时性那一环数据落库、最终一致、多实例缓存一致性这些都得靠业务代码和架构来兜底。方案没有银弹关键还是看你对业务数据量级、访问模型和分析成本的真实把握。先做原型验证压测观察结果再逐步完善。这样一步步来才不容易翻车。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/