高并发排行榜方案:Redis ZSET + 快照缓存 + 双TTL

发布时间:2026/9/14 2:20:27
高并发排行榜方案:Redis ZSET + 快照缓存 + 双TTL 做过的项目里排行榜永远是需求方一脸轻松、开发方心里打鼓的那个功能。首页要“热销榜”社区要“24小时热议榜”直播间要“送礼榜”看起来都是一句话的事真上手做才发现坑全在细节里数据量一大实时统计扛不住缓存加上了榜单又不新鲜好不容易看起来正常大促一来缓存穿透数据库直接被打爆。这篇文章我就把一套在线上稳定跑了很久的方案拆开讲清楚核心就是“Redis ZSET实时打分 榜单快照缓存 双TTL兜底”既满足高并发读取又能把榜单新鲜度控制在秒级到分钟级。适合正在设计排行榜接口、或者已经被缓存穿透和热点问题折磨过的后端开发者。1. 排行榜需求的本质读写不对称的典型场景1.1 为什么排行榜让很多团队翻车排行榜的第一性原理是“读写严重不对称”。底层数据可能每分钟都有大量用户行为在写入但榜单的读取频率往往是写入的几十倍甚至上百倍。一个日活百万的电商App商品评分、销量、收藏量每秒都在变但用户刷首页看“热销榜”的请求量高峰时期每秒几千次很常见。如果每次请求都实时聚合数据数据库根本扛不住。哪怕你用Redis存了原始数据几千个并发同时执行ZREVRANGE加上复杂计算Redis的CPU和网卡也会很快被打满。我见过最离谱的一次事故就是团队把当天所有商品的得分都维护在一个ZSET里每次请求都用ZREVRANGE取出前50名再联合查询数据库把商品详情拼出来。看起来没毛病等到一次秒杀活动流量上来Redis单实例CPU直接飙到99%接口P99延迟从30毫秒涨到了2秒。这个案例暴露出的问题不是Redis不行而是方案太天真。排行榜的读取路径应该是“极简的、可缓存的”而不是每次请求都重复做一次TopN计算。理解这一点后面所有设计都顺了。1.2 三种常规方案选型与对比我梳理了排行榜最常见的三种落地方式各有各的适用场景。方案核心思路优点缺点适用场景实时计算每次请求直接对行为数据聚合算出TopN数据绝对最新数据库/计算压力巨大QPS稍高就扛不住数据量小、QPS低的后台报表定时任务预热定时用离线任务算好榜单写入Redis请求只读读取极快可控性强榜单延迟取决于任务周期实时性差榜单窗口固定分钟级新鲜度可接受ZSET实时打分 榜单快照缓存写入行为实时更新Redis ZSET榜单结果缓存为字符串定期重建兼顾实时性和读取性能可扩展性好需要多维护一层缓存有一致性问题要处理高并发线上业务榜单新鲜度要求秒级到分钟级方案一基本只适合内部系统。方案二适合每日榜单、每周榜单这类窗口固定的场景。方案三是我要重点展开的也是大多数中大型业务的正确解。它的核心思路不复杂用ZSET承接高频写入实时记录得分变化把算好的榜单结果快照单独存一份读取请求只打快照缓存不直接打ZSET快照定期过期重建既能保证新鲜度又能把读取成本降到最低。2. Redis ZSET才是排行榜的底座2.1 ZSET底层结构和工作原理ZSET有序集合是Redis专门为“带权重的排序”设计的数据结构。它内部同时用到了哈希表和跳跃表哈希表负责O(1)查找member对应的score跳跃表负责按score有序排列member。所以ZSET能同时支持“给某个成员加分”和“按分数范围取TopN”这两个排行榜最核心的操作时间复杂度分别是O(logN)和O(MlogN)M是返回条数。为什么不用List或者普通SetList虽然有序但按分数排序需要自己维护位置插入删除都是O(N)Set去重方便但完全无序。ZSET把“集合去重”和“有序排序”合二为一天生就是干这个的。ZSET里每个member是唯一的score可以是整数或浮点数。需要注意的是Redis底层用double存储score对于超过2^53的整数会丢失精度。这个细节在分数设计时非常关键后面我会专门讲。2.2 分数设计稳定性与精度缺一不可排行榜最怕两件事分数并列导致排序不稳定分数设计不合理导致精度丢失。先说精度。Redis的score是double类型能精确表示的整数范围是-2^53到2^53也就是大约9千万亿。如果你的分数方案算出来的数值会超过这个范围排序就可能出现诡异问题。我见过有人直接把“销量”和“时间戳”拼在一起做score销量级一大超过精度上限两个不相干的商品排出了相同的名次排查了整整一天。再说排序稳定性。业务上通常要求“得分相同的情况下先达到该得分的排在前面”。比如两个商品热度值都是10000先达到的排前面。这时候不能只把热度值当score否则相同分数的memberZSET会按member的字典序排序结果完全不可控。我的常用做法是把score设计成一个大整数score hot_value * 10^8 (timestamp - BASE_TIMESTAMP)。其中BASE_TIMESTAMP是一个固定的历史基准时间戳比如2020-01-01对应的时间。这样分数里高位是业务热度低位是时间先后热度优先热度相同时间早者优先。这里有个计算细节要说明。假设当前时间戳是1800000000基准时间戳是1580000000差值大概是220000000也就是2.2亿8位十进制刚好够用。那么只要hot_value不超过10^(15-8)即1000万score就不会突破2^53精度上限。绝大多数业务的单商品热度值都到不了千万级这个方案是安全的。如果你担心边界可以把10^8改成10^7精度余量更大只是时间排序的区分粒度会从秒级变成秒级略粗一些。2.3 核心命令组合与使用技巧ZSET的常用命令不多但组合起来能玩出很多花样。# 商品001热度加10 ZINCRBY product:rank:score 10 product:001 # 获取前100名带分数 ZREVRANGE product:rank:score 0 99 WITHSCORES # 获取某个商品当前排名从0开始 ZREVRANK product:rank:score product:001 # 裁剪ZSET只保留前500名防止内存无限增长 ZREMRANGEBYRANK product:rank:score 0 -501 # 获取分数在某个区间内的商品数 ZCOUNT product:rank:score 1000 inf实操中有两个很容易被忽略的点。第一ZINCRBY的返回值是更新后的分数很多场景可以直接拿这个分数去判断是否触发榜单刷新省一次ZSCORE查询。第二ZREMRANGEBYRANK是裁剪ZSET的常用手段可以定期把排名1000名之外的数据清掉。排行榜本身就是头部效应极强的场景尾部数据留着既占内存又没有实际意义。3. 分层缓存架构让读请求远离计算3.1 实时层、快照层、兜底层三层设计完整的排行榜缓存方案我习惯分成三层来看。第一层是实时层就是那个持续接收打分写入的ZSET它负责数据的实时性。用户产生行为异步任务或者消息队列消费后调用ZINCRBY这里几乎没有延迟。第二层是快照层也就是榜单结果缓存它是一份已经计算好的前N名列表序列化成JSON或者自定义二进制格式存在Redis字符串里。读取请求99%都打在这一层一个GET命令就能拿到完整榜单。第三层是兜底层当快照缓存过期但重建还没完成时用互斥锁保证只有一个线程去重建其他请求先用旧快照降级返回。为什么不能省掉第二层、直接读ZSET我在1.1里提过ZREVRANGE本身很快但如果每个请求都执行Redis需要做一次O(logN M)的查找N是ZSET里的成员数M是返回条数。当member数量百万级、请求QPS几千上万时Redis单实例的CPU会持续高位。快照缓存把查询从“ZSET的TopN计算”降级成了“字符串的GET”性能差距是数量级的。我用一组数字说明假设ZSET里有50万个member一次ZREVRANGE取100条耗时通常在0.2毫秒到0.5毫秒之间。一次GET耗时在0.05毫秒以内。单个请求看起来差距不大但QPS到5000时纯ZSET方案每秒要消耗1到2.5秒的CPU时间而快照缓存方案只需要0.25秒。差距足以影响单台Redis的容量规划。3.2 榜单快照缓存的TTL设计思路快照缓存的核心参数是过期时间TTL。TTL太短重建频繁浪费计算资源TTL太长榜单新鲜度差需求方不满意。我的默认值是60秒加一个随机偏移。核心逻辑是给所有榜单key设置同一个过期时间会导致缓存雪崩集中在同一秒重建可能把压力全打到一个时间点上。随机偏移的作用就是打散重建请求。// TTL基础值60秒随机增加0到5秒 int ttl 60 ThreadLocalRandom.current().nextInt(5);虽然这个偏移量很小但架不住请求量大效果非常明显。Redis不是数据库它虽然抗压能力强但一个时间点上大量key同时过期重建会形成CPU毛刺这个毛刺在高并发下可能引发连锁问题。也有人问我能不能不设置过期时间而是用主动通知的方式数据一变就刷新缓存对于排行榜来说我不推荐。排行榜的写入频率实在太高了如果每次打分都触发榜单重算相当于又把实时计算的压力引了回来。用TTL定期刷新本质上是把“每次写入都算”变成了“每秒最多算一次”性价比高得多。3.3 代码落地基于Spring Boot和RedisTemplate的实现接下来是完整实现以Spring Boot RedisTemplate为例语言层面也适用其他语言核心逻辑一致。先定义一个排行榜服务的骨架Service public class ProductRankService { private static final String SCORE_KEY product:rank:score; private static final String SNAPSHOT_KEY product:rank:top100; private static final String REBUILD_LOCK_KEY product:rank:lock; private static final int TOP_N 100; private static final long TTL_BASE 60L; private static final long BASE_TIMESTAMP 1580000000L; // 2020-01-26 private static final long SCORE_TIME_FACTOR 100_000_000L; Autowired private StringRedisTemplate redisTemplate; public void recordScore(String productId, long scoreDelta) { redisTemplate.opsForZSet().incrementScore(SCORE_KEY, productId, scoreDelta); } }然后是核心的榜单获取逻辑public ListRankItem getTopProducts() { // 1. 先读快照缓存 String snapshot redisTemplate.opsForValue().get(SNAPSHOT_KEY); if (StringUtils.hasText(snapshot)) { return JSON.parseArray(snapshot, RankItem.class); } // 2. 快照不存在或已过期进入重建流程 return rebuildTopProducts(); } private ListRankItem rebuildTopProducts() { // 3. 尝试获取互斥锁防止大量请求同时重建 Boolean locked redisTemplate.opsForValue() .setIfAbsent(REBUILD_LOCK_KEY, 1, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 4. 双重检查可能上一个线程已经重建好了 String snapshot redisTemplate.opsForValue().get(SNAPSHOT_KEY); if (StringUtils.hasText(snapshot)) { return JSON.parseArray(snapshot, RankItem.class); } // 5. 从ZSET取TopN带分数 SetZSetOperations.TypedTupleString tuples redisTemplate.opsForZSet() .reverseRangeWithScores(SCORE_KEY, 0, TOP_N - 1); ListRankItem items new ArrayList(TOP_N); if (tuples ! null) { for (ZSetOperations.TypedTupleString tuple : tuples) { if (tuple.getValue() null || tuple.getScore() null) { continue; } items.add(new RankItem(tuple.getValue(), tuple.getScore().longValue())); } } // 6. 序列化写入快照缓存 String json JSON.toJSONString(items); int ttl 60 ThreadLocalRandom.current().nextInt(5); redisTemplate.opsForValue().set(SNAPSHOT_KEY, json, Duration.ofSeconds(ttl)); return items; } finally { // 7. 释放锁 redisTemplate.delete(REBUILD_LOCK_KEY); } } // 8. 没抢到锁的请求短暂等待后读取旧快照或者直接查ZSET降级 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } String staleSnapshot redisTemplate.opsForValue().get(SNAPSHOT_KEY); if (StringUtils.hasText(staleSnapshot)) { return JSON.parseArray(staleSnapshot, RankItem.class); } // 9. 极端情况旧快照也没了直接查ZSET返回 SetZSetOperations.TypedTupleString tuples redisTemplate.opsForZSet() .reverseRangeWithScores(SCORE_KEY, 0, TOP_N - 1); ListRankItem items new ArrayList(TOP_N); if (tuples ! null) { for (ZSetOperations.TypedTupleString tuple : tuples) { if (tuple.getValue() null || tuple.getScore() null) { continue; } items.add(new RankItem(tuple.getValue(), tuple.getScore().longValue())); } } return items; }这段代码里有几个关键点值得展开说。第一步先读缓存这是入口路径最短。第三步的互斥锁是整个防击穿设计的核心保证缓存过期瞬间只有少量请求去重建而不是所有请求一拥而上。第四步双重检查很有必要因为Redis setIfAbsent只是竞争锁拿到锁的线程在真正重建前上一个线程可能已经重建完了直接再读一次缓存能省掉一次无效计算。第七步释放锁放在finally里防止重建异常时锁不释放把后续线程全部阻塞住。第八步是降级逻辑没抢到锁的请求先睡50毫秒等持锁线程大概率已经重建完成如果还没完成就用旧缓存返回绝不因为缓存重建失败而让用户看到500。这套逻辑下来99.9%的请求都只执行一次GET命令只有极少数请求会走到重建分支Redis的压力能压到非常低。3.4 分数写入的批量化与异步化如果业务行为量特别大每次行为都同步调用Redis的ZINCRBY那Redis的网络IO会成为瓶颈。更合理的做法是行为事件先进入消息队列消费端批量聚合后再写入Redis。public void batchRecordScores(MapString, Long scoreDeltaMap) { // 使用pipeline批量提交减少RTT redisTemplate.executePipelined((RedisCallbackObject) connection - { scoreDeltaMap.forEach((productId, delta) - { byte[] key redisTemplate.getKeySerializer().serialize(SCORE_KEY); byte[] member redisTemplate.getStringSerializer().serialize(productId); connection.zIncrBy(key, delta, member); }); return null; }); }批量写入的好处有两个。一是把网络的多次往返合并成一次吞吐量成倍提升二是减少了对Redis锁和CPU的竞争。消费端聚合的时间窗口我一般取1到2秒既不会让分数延迟太大又能有效削峰。4. 缓存一致性从强一致到最终一致的工程取舍4.1 为什么排行榜只需要最终一致排行榜的缓存一致性是很多新手最容易纠结的地方。他们想在缓存和真实数据之间做到“强一致”结果是引入了极其复杂的同步机制最后还经常出bug。我直接说结论排行榜业务压根不需要强一致。一个用户看到的榜单是5秒前的还是30秒前的对业务影响几乎没有。只要你保证最终能看到新数据新鲜度在分钟级以内就算合格。想通这一点方案会简单很多。既然只需要最终一致缓存更新策略就用最简单可靠的“TTL过期重建”。每次快照过期重建一次用新数据覆盖旧数据。这期间用户读到的旧数据可能和ZSET当前的真实排名有出入但差值在秒级用户无感。4.2 双TTL和版本号两种不同的更新抓手在实际项目里我见过两类有效的更新策略。一类是“双TTL”。第一个TTL是快照的过期时间控制数据刷新频率第二个TTL是锁的过期时间控制重建过程中的并发保护时长。两个TTL各司其职快照TTL决定新鲜度锁TTL决定稳定性。我在3.3的代码里就是这么设计的快照60到65秒过期锁10秒自动释放即使持锁线程卡住锁也会自动消失不会永久阻塞后续请求。另一类是“版本号”。在快照旁边存一个版本号key每次重建时版本号加1。读取时先拿版本号版本号不匹配就触发一次重建。这种方式比TTL更精确但实现复杂度高一些需要多一次GET操作。我只有在榜单数据对新鲜度要求特别严格、TTL那一套不能满足的场景才会用。4.3 缓存穿透、击穿、雪崩的同步应对这三个问题是所有缓存方案的必修课排行榜也不例外。缓存穿透指查询一个不存在的key每次都打到数据库或ZSET。对排行榜来说由于榜单里的member都是真实存在的穿透概率不高但接口如果支持“按商品查排名”用户恶意传一个不存在的商品ID就会穿透。处理办法是空值缓存把空结果也缓存几十秒或者用布隆过滤器挡一层。缓存击穿指一个热点key过期瞬间大量请求同时打到后端。前面用互斥锁已经解决了核心就一句话只有一个线程去重建其他线程等待或降级。缓存雪崩指大量key在同一时间过期导致集中重建。解法是过期时间加随机偏移这个3.2里已经讲到了。对单个排行榜来说key数量不多雪崩风险不大但如果一个业务同时有十几个榜单大家还共用同一个基础TTL就得特别小心。下面这张表总结了我对每个问题的应对手法问题原因应对方案缓存穿透请求不存在的数据空值缓存布隆过滤器缓存击穿热点key过期瞬间高并发重建互斥锁重建持锁线程双重检查缓存雪崩大量key同一时间过期TTL加随机偏移错峰过期数据不一致缓存与ZSET存在时间差接受最终一致TTL控制刷新频率5. 实战排查排行榜缓存最容易踩的坑5.1 排行榜分数并列导致排序抖动刚上线排行榜的时候业务方找过我好几次说榜单顺序“不稳定”同一个商品一会儿第3名一会儿第5名。一开始我以为是缓存的问题查了半天发现是score设计的问题。当时的分数只用了热度值没有拼接时间因子。两个商品热度值相同ZSET就按member字典序排而member是商品ID纯字母数字排序结果看起来毫无规律每次重建顺序还可能不一样。后来改成hot * 10^8 (timestamp - BASE_TIMESTAMP)的分数结构相同热度按时间先后排顺序就稳定了。这个问题的本质是“排序条件不完整”。任何排行榜在分数相等时都必须有第二排序键否则结果不可控。时间戳是最常用的第二键也可以用业务上更有意义的字段比如“最近一次成交时间”。5.2 缓存重建期的性能毛刺有一次线上监控发现Redis的CPU每隔60秒左右会出现一次明显尖峰持续时间1到2秒。定位下来就是缓存重建导致的ZREVRANGE操作。排查看下来重建本身并不慢问题出在重建时对ZSET的全量TopN查询叠加了其他业务对ZSET的实时写入大量Redis命令在同一时间点竞争同一个key导致CPU飙高。解决方案有两个。一是把重建的触发时间尽量分散TTL随机化不要让所有榜单在同一时间重建。二是ZREVRANGE取出的条数不要贪多业务展示只需要100条就取100条而不是取1000条再在应用层截断。多取的每一条都是Redis不必要的计算。5.3 ZSET大Key和内存失控排行榜的ZSET如果不做限制会越滚越大最终成为大Key。Redis的key太大不只是占内存还会在扩容、持久化、主从同步时产生性能问题。我的习惯是定期裁剪。用一个定时任务每小时执行一次# 只保留前500名尾部数据全部清理 ZREMRANGEBYRANK product:rank:score 0 -501需要注意裁剪对实时性会有轻微影响因为被裁掉的数据如果又产生了新的行为ZINCRBY会重新把它加入集合。所以裁剪要定期循环执行而不是裁一次就完事。另外要监控ZSET的内存增长趋势。用一个简单的估算公式member数量乘以单个member消耗的字节数。商品ID如果是一串20位左右的长ID加上Redis对象的固定开销单个member大约消耗50到70字节。100万个member就是50到70MB5百万就是250到350MB。如果你的ZSET成员数到了这个量级裁剪都救不回来就需要考虑更激进的淘汰策略了。5.4 常见问题速查表现象可能原因排查方法解决方案榜单顺序抖动score缺少第二排序键观察ZSET中score相同的成员数量分数拼接时间因子缓存过期瞬间接口变慢大量请求同时重建看Redis慢日志和CPU监控互斥锁 双重检查Redis CPU周期性尖峰多个榜单同时重建检查各key过期时间分布TTL加随机偏移ZSET内存持续增长没有裁剪用MEMORY USAGE命令查看定期ZREMRANGEBYRANK榜单和实际数据对不上快照缓存未刷新对比快照时间和当前时间调整TTL或改用版本号高热度商品排名很靠后score计算精度溢出打印score值检查是否超过2^53重新设计分数结构缓存重建期间请求失败降级逻辑不完善查看应用日志有无异常等待后读旧缓存兜底查ZSET5.5 关于监控与告警的补充建议最后说一个容易忽略的环节排行榜是线上核心接口必须配套监控。我建议至少盯三个指标快照缓存的命中率正常应该接近100%ZSET的成员数量和内存用于提前预判大KeyRedis的CPU使用率和慢日志用于发现排序查询的性能退化。监控没有太复杂的技巧关键是把指标落到告警上。比如快照命中率低于90%就说明缓存策略有严重问题不能再拖。ZSET成员数量超过预估阈值就触发预警提醒该做裁剪了。这些告警写起来不难但在关键时刻能救命。我个人在实际操作中还有一个习惯任何排行榜上线前都会用线上真实数据量压一遍重点看缓存过期时间点附近Redis的CPU曲线是否平稳。如果尖峰明显就继续调大TTL随机偏移或者增加重建的互斥粒度。这套“ZSET实时打分 榜单快照缓存 双TTL兜底”的组合我用了很久方向是对的剩下的就是在各种业务细节里不断调优。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询