做过的项目里,排行榜永远是需求方一脸轻松、开发方心里打鼓的那个功能。首页要“热销榜”,社区要“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(M+logN),M是返回条数。
为什么不用List或者普通Set?List虽然有序,但按分数排序需要自己维护位置,插入删除都是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,否则相同分数的member,ZSET会按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 List<RankItem> getTopProducts() { // 1. 先读快照缓存 String snapshot = redisTemplate.opsForValue().get(SNAPSHOT_KEY); if (StringUtils.hasText(snapshot)) { return JSON.parseArray(snapshot, RankItem.class); } // 2. 快照不存在或已过期,进入重建流程 return rebuildTopProducts(); } private List<RankItem> 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,带分数 Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet() .reverseRangeWithScores(SCORE_KEY, 0, TOP_N - 1); List<RankItem> items = new ArrayList<>(TOP_N); if (tuples != null) { for (ZSetOperations.TypedTuple<String> 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返回 Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet() .reverseRangeWithScores(SCORE_KEY, 0, TOP_N - 1); List<RankItem> items = new ArrayList<>(TOP_N); if (tuples != null) { for (ZSetOperations.TypedTuple<String> 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(Map<String, Long> scoreDeltaMap) { // 使用pipeline批量提交,减少RTT redisTemplate.executePipelined((RedisCallback<Object>) 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到70MB,5百万就是250到350MB。如果你的ZSET成员数到了这个量级,裁剪都救不回来,就需要考虑更激进的淘汰策略了。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 榜单顺序抖动 | score缺少第二排序键 | 观察ZSET中score相同的成员数量 | 分数拼接时间因子 |
| 缓存过期瞬间接口变慢 | 大量请求同时重建 | 看Redis慢日志和CPU监控 | 互斥锁 + 双重检查 |
| Redis CPU周期性尖峰 | 多个榜单同时重建 | 检查各key过期时间分布 | TTL加随机偏移 |
| ZSET内存持续增长 | 没有裁剪 | 用MEMORY USAGE命令查看 | 定期ZREMRANGEBYRANK |
| 榜单和实际数据对不上 | 快照缓存未刷新 | 对比快照时间和当前时间 | 调整TTL,或改用版本号 |
| 高热度商品排名很靠后 | score计算精度溢出 | 打印score值,检查是否超过2^53 | 重新设计分数结构 |
| 缓存重建期间请求失败 | 降级逻辑不完善 | 查看应用日志有无异常 | 等待后读旧缓存,兜底查ZSET |
5.5 关于监控与告警的补充建议
最后说一个容易忽略的环节,排行榜是线上核心接口,必须配套监控。我建议至少盯三个指标:快照缓存的命中率,正常应该接近100%;ZSET的成员数量和内存,用于提前预判大Key;Redis的CPU使用率和慢日志,用于发现排序查询的性能退化。
监控没有太复杂的技巧,关键是把指标落到告警上。比如快照命中率低于90%就说明缓存策略有严重问题,不能再拖。ZSET成员数量超过预估阈值就触发预警,提醒该做裁剪了。这些告警写起来不难,但在关键时刻能救命。
我个人在实际操作中还有一个习惯:任何排行榜上线前,都会用线上真实数据量压一遍,重点看缓存过期时间点附近Redis的CPU曲线是否平稳。如果尖峰明显,就继续调大TTL随机偏移或者增加重建的互斥粒度。这套“ZSET实时打分 + 榜单快照缓存 + 双TTL兜底”的组合,我用了很久,方向是对的,剩下的就是在各种业务细节里不断调优。