
作为后端开发,你是不是也遇到过这种糟心情况?上周我们团队上线了电商平台的 “618 预售商品排行榜”,结果刚推出去 1 小时,接口直接崩了 —— 监控显示响应延迟飙到 3 秒以上,数据库 CPU 占用率冲到 95%,运维同事紧急发告警,运营那边催得团团转,最后只能临时降级成静态页面才稳住场面。
如果你也做过实时排行榜功能,肯定懂这种 “上线即翻车” 的尴尬。今天就跟你掏心窝子聊聊,我们是怎么用 Redis 把这个 “烫手山芋” 变成 “性能利器” 的,从问题排查到最终落地,每一步都给你讲透,避免你再踩我们踩过的坑。
先说说我们遇到的 “坑”:为什么 MySQL 撑不住排行榜?
可能有刚入行的朋友会问:“直接用 MySQL 做排行榜不行吗?写个 ORDER BY 商品销量 / 热度的 SQL,再加个索引,不就搞定了?” 实则最开始我们也是这么想的,但实际跑起来才发现,这里面的坑比想象中多。
先给你说说我们的业务背景:这个排行榜是实时更新的,用户点击商品、加购、收藏都会实时影响排名,而且页面每 10 秒会自动刷新一次。整个平台有近 10 万件预售商品,日均 UV 大致 50 万,峰值时段(列如晚上 8 点)UV 能到 150 万。
最开始用 MySQL 实现时,我们写的 SQL 是这样的:
SELECT product_id, product_name, sales_count, heat_score
FROM product_pre_sale
ORDER BY heat_score DESC, sales_count DESC
LIMIT 0, 50;
虽然给heat_score和sales_count加了联合索引,但问题还是来了:
- 查询效率越来越低:刚开始数据量少的时候,查询还能控制在 200ms 左右,但随着商品数据增加到 10 万条,加上实时更新导致索引频繁失效,查询时间直接涨到 1.2 秒,峰值时甚至超过 3 秒;
- 数据库压力扛不住:每 10 秒一次刷新,50 万用户同时在线的话,相当于每秒有 5 万次查询请求打在数据库上,单表 QPS 直接爆表,CPU 占用率飙升,还会影响其他业务的 SQL 执行;
- 数据一致性难保证:由于要实时更新heat_score,我们用了许多 UPDATE 语句,加上查询频繁,很容易出现行锁等待,甚至偶尔会出现数据更新后查询不到的情况。
后来我们排查日志发现,90% 的数据库压力都来自这个排行榜查询。这时候才意识到:高并发实时排行榜,真的不能只靠 MySQL 硬抗,必须找更轻量、更快的方案 —— 这就想到了 Redis。
Redis 做排行榜的核心逻辑:为什么选 Zset?
说到 Redis 的数据结构,许多朋友会想到 String、Hash、List,但做排行榜,Zset(有序集合)才是 “天选之子”。为什么这么说?
先给你简单回顾下 Zset 的特性:它里面的每个元素(member)都对应一个分数(score),Redis 会自动根据 score 的大小对元素排序,而且支持按 score 范围查询、获取指定排名的元素,这些特性刚好契合排行榜的需求 —— 列如我们的商品 ID 就是 member,heat_score就是 score,要获取 Top50 的商品,直接用 Zset 的命令就能实现,不用自己做排序。
不过在落地前,我们也踩过一个小坑:最开始想直接用heat_score作为 score,但后来发现,商品的排名不仅要看heat_score,还要看sales_count(列如两个商品热度分一样,销量高的排前面)。这时候该怎么办?
后来查了 Redis 官方文档和一些大厂的实践案例,找到了解决方案:把多个排序维度合并成一个 score。具体来说,我们把sales_count乘以 1000000(由于heat_score是整数,最大不超过 10000),再加上heat_score,这样 score 就变成了 “销量1000000 + 热度分” 的格式。列如商品 A 销量 100、热度 8888,score 就是 1001000000+8888=100008888;商品 B 销量 99、热度 9999,score 就是 99*1000000+9999=99009999。这样排序时,先比销量(score 的高位),再比热度(score 的低位),刚好满足我们的业务需求。
另外,Zset 还有两个命令特别关键,必须跟你重点说:
- ZADD:用来添加或更新元素的 score。列如用户点击商品后,要给heat_score加 1,就可以用ZINCRBY product_ranking 1 product_id(ZINCRBY 是 ZADD 的增量版本,更适合实时更新场景);
- ZREVRANGE:按 score 从大到小排序,获取指定范围的元素。列如要获取 Top50 的商品,就用ZREVRANGE product_ranking 0 49 WITHCOUNTS,WITHCOUNTS 参数会同时返回 score,方便我们后续处理。
这里还要提醒你一个细节:Redis 的 Zset 是基于跳表(Skip List)实现的,插入、删除、查询的时间复杂度都是 O (logN),即使数据量达到 100 万,性能也依然很稳定。我们后来做压测时发现,同样是查询 Top50,Redis 比 MySQL 快了近 200 倍,而且单实例就能轻松扛住每秒 10 万次的查询请求。
落地 3 步走:从方案设计到避坑指南
确定用 Redis Zset 后,我们并没有直接上线,而是花了 2 天时间做方案设计和压测,最后总结出 3 个关键步骤,确保上线后万无一失。
第一步:数据同步 —— 怎么保证 Redis 和 MySQL 数据一致?
许多朋友用 Redis 时都会遇到一个问题:Redis 是缓存,MySQL 是数据库,怎么保证两者的数据同步?特别是实时更新的场景,很容易出现 Redis 和 MySQL 数据不一致的情况。
我们的解决方案是 “写扩散 + 定时校验”:
- 写扩散:当商品的heat_score或sales_count发生变化时,先更新 MySQL 数据库,再调用 Redis 的 ZINCRBY 命令更新 score。这里要注意,必定要用 “先更库,再更缓存” 的顺序,避免出现缓存更新成功但数据库更新失败的情况。如果更新数据库失败,直接返回错误,不更新 Redis;
- 定时校验:思考到网络抖动、服务重启等异常情况,可能会导致 Redis 和 MySQL 数据不一致,我们写了一个定时任务,每天凌晨 3 点(流量最低的时候)从 MySQL 读取所有商品的heat_score和sales_count,重新计算 score,然后用 ZADD 命令覆盖 Redis 中的数据。另外,每小时还会随机抽查 100 个商品,对比 Redis 和 MySQL 的 score,如果差异超过 1%,就触发一次增量同步;
- 缓存过期:为了避免 Redis 中的数据长期不更新导致失效,我们给 Zset 设置了过期时间 —— 不过不是直接给整个 key 设过期,而是给每个商品的 member 设置过期时间(用 ZADD 的 EX 参数),这样即使某个商品长期没有热度,也会自动从排行榜中消失,避免占用 Redis 内存。
第二步:性能优化 —— 怎么让接口响应更快?
虽然 Redis 比 MySQL 快许多,但在高并发场景下,还是有优化空间。我们主要做了 3 个优化:
- 减少 Redis 查询次数:最开始我们的逻辑是,先调用 ZREVRANGE 获取 Top50 的商品 ID 和 score,再调用 50 次 HGET 命令获取每个商品的名称、图片、价格等信息(这些信息存在 Hash 结构里)。后来发现,这样会导致 51 次 Redis 调用,网络开销很大。优化后,我们把商品的基本信息(名称、图片、价格)用 JSON 格式拼接成字符串,作为 Zset 的 member 的附加值(列如 member 是 “product_id:json_str”),这样一次 ZREVRANGE 就能获取所有需要的信息,调用次数从 51 次减少到 1 次,接口延迟直接降了 40%;
- 添加本地缓存:思考到排行榜每 10 秒才更新一次,我们在应用服务器上加了本地缓存(用 Caffeine),把 ZREVRANGE 的结果缓存 5 秒。这样,5 秒内的查询请求不用每次都去 Redis 查,直接从本地缓存获取,Redis 的 QPS 直接降了一半。这里要注意,本地缓存的过期时间要比 Redis 的更新频率短(列如 Redis 每 10 秒更新,本地缓存 5 秒过期),避免出现本地缓存数据过期的情况;
- Redis 集群部署:为了避免单点故障,我们把 Redis 部署成主从集群,主节点负责写操作(ZINCRBY、ZADD),从节点负责读操作(ZREVRANGE),然后用哨兵(Sentinel)做故障转移。这样即使主节点挂了,从节点能立刻顶上,而且读操作分散到多个从节点,性能也更好。压测时,我们模拟了主节点宕机的场景,从故障检测到切换完成只用了 3 秒,完全不影响业务。
第三步:异常处理 —— 怎么应对突发情况?
做技术的都知道,线上环境永远充满意外,所以必须做好异常处理。我们主要思考了 3 种情况:
- Redis 宕机:如果 Redis 集群全部宕机,怎么办?我们做了降级方案 —— 直接从 MySQL 查询 Top50,但会把查询频率从 10 秒一次改成 60 秒一次,而且只返回前 20 名(减少数据库压力),同时在页面上提示 “当前网络繁忙,排行榜数据延迟更新”。另外,我们还配置了 Redis 的监控告警,一旦发现 Redis 不可用,会立刻通知运维团队处理;
- 数据倾斜:如果某个商品突然爆火,heat_score飙升,会不会导致 Zset 的 score 分布不均匀,影响查询性能?我们查了 Redis 的官方文档,发现 Zset 的跳表结构对数据倾斜不敏感,但为了保险起见,我们还是做了限制 —— 每个商品的heat_score每天最多增加 10000(超过后不再累加),避免单个商品的 score 过高导致排序异常;
- 接口超时:为了防止 Redis 查询超时影响整个接口,我们给 Redis 客户端设置了超时时间(200ms),如果超过 200ms 还没返回结果,就触发降级逻辑,返回上一次缓存的排行榜数据。同时,把超时日志记录到 ELK 中,后续分析超时缘由(列如网络问题、Redis 性能问题)。
最终效果:从 3 秒到 50ms,我们学到了什么?
上线后,我们做了一周的监控,结果比预期还好:
- 接口延迟:从原来的 3 秒以上降到了 50ms 以内,99% 的请求响应时间都在 30ms 左右;
- 数据库压力:排行榜相关的查询请求从数据库转移到 Redis 后,MySQL 的 CPU 占用率从 95% 降到了 30% 以下,QPS 也恢复到正常水平;
- 稳定性:一周内没有出现一次接口崩溃或数据不一致的情况,即使在峰值时段(150 万 UV),Redis 的性能也很稳定。
回顾整个过程,我们总结出 3 个经验,想跟你分享:
- 技术选型要贴合业务场景:不是所有场景都需要用 Redis,也不是所有 Redis 数据结构都适合做排行榜。列如如果是离线排行榜(每天更新一次),用 MySQL + 定时任务可能更简单;但如果是实时高并发排行榜,Zset 就是最优选择;
- 不要忽视细节:列如多维度排序的 score 计算、数据同步的顺序、本地缓存的过期时间,这些细节看似小事,但直接影响最终的性能和稳定性;
- 必定要做压测和降级:上线前必定要模拟高并发场景做压测,找出性能瓶颈;同时做好降级方案,万一出现异常,能保证业务不中断。
最后,想问问你:你在项目中用过 Redis 做排行榜吗?有没有遇到过什么坑?或者有更好的实现方案?欢迎在评论区留言分享,咱们一起交流学习,少走弯路!如果这篇文章对你有协助,也别忘了点赞收藏,下次做排行榜时直接拿出来参考~


