【后端开发】(场景题)如果让我设计一个千万级“热帖榜”,我不会一上来就用 Redis ZSet
文章目录
🔥 个人主页:铁皮哥(欢迎关注)
📌 作者简介:28届校招生,后端开发/Agent 方向在学
📚 学习内容:Java、Python、计算机视觉、大语言模型、Agent开发
📝 专栏内容:从零开始的Claude Code零代码生活(持续更新中)
✨不只背八股,更想搞懂为什么这样设计
前言
如果有上亿用户,怎么设计一个实时排行榜?
这个问题看起来很常规,很多人的第一反应应该都是 Redis ZSet。我一开始也是这么想的,毕竟排行榜和 ZSet 几乎已经绑定在一起了:一个成员对应一个分数,按分数排序,查 Top N 也方便。
但真要把这个答案拿到面试里讲,只说“用 Redis ZSet”肯定不够。
因为面试官很可能继续问:
为什么不用 MySQL 排序?
ZSet 里数据太多怎么办?
首页排行榜被高频访问,会不会出现热 key?
点赞、评论、收藏都影响分数,更新链路怎么设计?
Redis 挂了之后,榜单数据怎么恢复?
新发布的内容怎么和老内容竞争?
这些问题如果没有想清楚,答案就会变成一句工具选型:排行榜用 Redis。
假设我们有一个社区系统,里面有大量帖子。每篇帖子都会产生浏览、点赞、评论、收藏等行为,系统需要根据这些行为计算热度分数,并在首页展示实时热榜。
这个场景比单纯的“分数排行榜”更接近真实业务。因为热帖榜不只是查一个 Top 100,它还涉及写入频率、热度计算、缓存命中、榜单裁剪、异常降级和数据恢复。
所以这篇文章会沿着一个问题往下推:
一个帖子从被发布,到被用户点赞评论,再到进入热榜,系统背后应该怎么维护它的排名?
一、热帖榜不是简单 order by like_count desc
做排行榜之前,先别急着选 Redis 还是 MySQL。
我觉得更重要的是先问清楚:这个榜到底在排什么?
如果只是一个很简单的点赞榜,那确实可以按点赞数倒序排:
SELECT id, title, like_count
FROM post
WHERE status = 1
ORDER BY like_count DESC
LIMIT 100;
这条 SQL 看起来没什么问题,数据量小的时候也确实能跑。
但内容社区里的“热帖榜”通常不会这么简单。一个帖子热不热,不能只看点赞数。比如有的帖子点赞很多,但是发布时间已经很久;有的帖子评论很多,说明讨论度高;有的帖子收藏很多,说明内容质量可能不错;还有一些帖子浏览量很高,但互动很少,可能只是标题吸引人。
所以热度分数一般会是一个综合值。
举个简化版例子:
hot_score = 点赞数 * 3
+ 评论数 * 4
+ 收藏数 * 5
+ 浏览量 * 0.1
- 时间衰减
这个公式不一定适合所有业务,只是为了说明一件事:热帖榜排的不是某一个字段,而是一个会被频繁更新的分数。
用户点一次赞,分数会变。
有人评论,分数会变。
帖子被收藏,分数会变。
时间过去了,分数也会变。
这时候问题就变复杂了。因为系统不只是在“查排行榜”,还在不断“维护排行榜”。
最开始,我们当然可以把 hot_score 存在 MySQL 里。
表结构可能长这样:
CREATE TABLE post (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
like_count INT,
comment_count INT,
collect_count INT,
view_count INT,
hot_score DOUBLE,
status TINYINT,
create_time DATETIME,
update_time DATETIME,
INDEX idx_hot_score (hot_score)
);
查询热榜也很直接:
SELECT id, title, hot_score
FROM post
WHERE status = 1
ORDER BY hot_score DESC
LIMIT 100;
如果是一个刚上线的小社区,这个方案完全可以用。
不要一上来就把架构画得特别复杂。数据量不大、访问量不高的时候,MySQL 排序可能就是性价比最高的方案。开发简单,数据一致,排查问题也方便。
真正的问题出现在数据量和更新频率上来之后。
假设现在有几千万篇帖子,首页每次打开都要查热榜。同时,用户还在不断点赞、评论、收藏、浏览。这个时候 MySQL 要同时处理两类压力:
高频写入:不断更新帖子的互动计数和 hot_score
高频读取:不断查询 hot_score 排名前 100 的帖子
这两件事单独看都还好,放在一起就容易出问题。
一方面,hot_score 频繁变化,索引也要跟着维护。
另一方面,首页热榜是一个高频查询,很多用户都会反复访问同一个 Top 100。
如果每次请求都让 MySQL 去执行排序查询,数据库会慢慢变成一个排行榜计算器。
这不是 MySQL 不行,而是它更适合保存帖子详情、互动计数、最终结果这类数据。实时排行榜这种高频变化、高频读取的结构,长期压在 MySQL 上并不划算。
这里还有一个容易被忽略的问题:热榜不一定要求强实时。
比如用户刚点完赞,榜单没有必要在 10 毫秒内立刻变化。大多数内容社区里,热榜延迟 1 秒、3 秒,甚至 10 秒,用户感知都不明显。
但用户打开首页时,热榜必须返回得很快。
这说明读写两边的要求不一样:
写入侧:可以接受短暂延迟
读取侧:必须足够快,最好稳定命中缓存
理解这一点之后,后面的设计方向就清楚了。
我们没必要让每一次点赞都同步触发一次完整排序,也没必要让每一次首页访问都打到 MySQL。更合理的做法是:MySQL 负责保存事实数据,排行榜交给更适合排序和快速读取的结构来维护。
这就引出了 Redis ZSet。
二、Redis ZSet 能解决排序,但别把它当万能药
接着上面的思路走。
我们已经知道,热帖榜的核心动作其实就两个:
更新某篇帖子的热度分数
查询当前热度最高的前 N 篇帖子
这两个动作放到 Redis ZSet 里会比较自然。
ZSet 可以理解成一个带分数的集合。每个成员都有一个 score,Redis 会按照 score 维护顺序。放到热帖榜里,结构大概是这样:
key: hot_rank:global
member: postId
score: hot_score
比如帖子 1001 当前热度分是 9823,就可以写成:
ZADD hot_rank:global 9823 post_1001
查全站热榜前 100:
ZREVRANGE hot_rank:global 0 99 WITHSCORES
查某篇帖子当前排第几:
ZREVRANK hot_rank:global post_1001
这几个命令已经能覆盖排行榜最常见的读写需求了。
如果某个帖子又被点赞了,重新计算它的 hot_score,再执行一次 ZADD 就行。ZSet 里的 member 是唯一的,同一个 postId 再次写入时,会更新对应的 score。
从使用体验上看,ZSet 确实很适合排行榜。
它把“分数”和“排序”这两件事封装好了,我们不用每次查询时再临时排序。
但只答到这里,面试官很容易继续追问。
比如:
所有帖子都放进一个 ZSet 吗?
这个 ZSet 会不会越来越大?
首页热榜每秒被查很多次,会不会打爆一个 key?
Redis 里的数据丢了怎么办?
hot_score 和 MySQL 里的计数不一致怎么办?
这些问题才是这个场景真正麻烦的地方。
ZSet 解决的是“有序集合”这个数据结构问题。它没有自动帮我们解决容量、热点、恢复和一致性。
所以我不会把全站所有帖子都塞进一个巨大的 hot_rank:global 里。
假设社区里有几千万篇帖子,其中大部分帖子可能发布后就没什么互动。如果这些冷门帖子也全部进入全局榜,Redis 里会存大量几乎不会被查询到的数据。
而我们真正频繁访问的,其实只是前几页:
首页 Top 100
分类 Top 100
小时榜 Top 100
日榜 Top 100
用户很少会翻到全站热榜第 50 万名。
既然如此,就没有必要让 Redis 维护所有帖子的大排名。
更合理的做法是只让“有机会进入榜单”的帖子进入 ZSet。比如帖子达到某个互动阈值后,才写入候选榜:
点赞数 >= 5
或 评论数 >= 3
或 收藏数 >= 2
或 最近 1 小时浏览量明显上升
另一个问题是热 key。
全站热榜通常会被首页频繁访问。如果所有请求都查:
ZREVRANGE hot_rank:global 0 99 WITHSCORES
那 hot_rank:global 就会变成一个非常明显的热点 key。
Redis 本身很快,但快不代表可以无脑打。尤其是首页这种请求量很集中的接口,如果每次都穿透到 Redis,压力会非常集中。
所以读榜单的时候,通常还会再加一层本地缓存。
比如服务内存里缓存一份 Top 100,设置一个很短的 TTL:
本地缓存:hot_rank:global:top100
TTL:1 ~ 3 秒
这样大部分首页请求可以直接从应用内存返回,只有缓存过期时才去 Redis 拉一次最新榜单。
这个延迟对热榜来说通常可以接受。
用户不会因为榜单晚 2 秒刷新就觉得系统有问题,但如果首页接口被 Redis 热 key 拖慢,体验会很明显。
还要考虑一个容易被忽略的点:热度分数不是只增不减。
点赞、收藏、评论会让分数上升,时间衰减会让分数下降。
如果一个帖子两天前很火,但现在没人讨论,它应该慢慢从热榜掉下去。
这意味着不能只在用户互动时更新分数。
比如这个公式里有时间衰减:
hot_score = 点赞数 * 3
+ 评论数 * 4
+ 收藏数 * 5
+ 浏览量 * 0.1
- 时间衰减
如果没有新的点赞评论触发更新,老帖子可能会一直挂在榜上。
所以需要一个定时任务,周期性刷新候选榜里的帖子分数。
可以简单设计成:
每隔 1 分钟扫描候选榜 Top 1w
重新计算 hot_score
写回 Redis ZSet
裁剪掉排名太靠后的帖子
这里不要全量扫描所有帖子。
全量扫描几千万数据再重算热度,成本太高,也没必要。
我们只关心候选集和近期活跃内容。冷门帖子没有互动,也没机会突然冲进热榜,可以继续待在 MySQL 里。
还有恢复问题。
Redis 很适合做实时榜单,但不能让它成为唯一数据源。
如果某天 Redis 数据被清掉,或者集群发生故障,系统至少要知道怎么恢复榜单。
所以 MySQL 里应该保留两类数据:
帖子基础数据:post 表
互动计数:like_count、comment_count、collect_count、view_count
榜单快照:rank_snapshot 表
rank_snapshot 可以定期保存当前榜单结果,比如每分钟或每五分钟保存一次:
CREATE TABLE rank_snapshot (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
rank_type VARCHAR(64),
post_id BIGINT,
hot_score DOUBLE,
rank_no INT,
snapshot_time DATETIME,
INDEX idx_rank_type_time (rank_type, snapshot_time)
);
Redis 正常时,榜单从 ZSet 读。
Redis 异常时,先从最近一次快照里读一份旧榜单返回。
这不是最实时的结果,但比首页直接挂掉要好。
恢复 Redis 时,也可以从最近的快照开始重建,再结合最近一段时间的互动事件做补偿。
三、我的设计:分层榜单 + 异步更新 + 可恢复
前面已经把问题铺开了。
MySQL 可以保存帖子和互动数据,但不适合一直做实时排序。Redis ZSet 适合维护有序集合,但如果只靠一个全局大 ZSet,后面会遇到容量、热点、恢复这些问题。
所以这一节直接给一个我会采用的设计。
先看整体链路:
用户行为
↓
计数更新
↓
发送事件
↓
异步计算热度分
↓
更新 Redis 榜单
↓
定期保存快照
这个设计的核心思路是:用户操作链路尽量短,排行榜计算放到异步任务里,Redis 只维护有价值的候选榜单,MySQL 负责保存事实数据和兜底快照。
3.1 写入链路:用户点赞时,不要同步重排榜单
假设用户给帖子点了一个赞。
最直接的写法可能是:
更新点赞数
重新计算 hot_score
更新 Redis ZSet
返回点赞成功
这个流程在小系统里没问题,但流量上来后会有几个隐患。
第一,点赞接口会变重。
用户只是点了个赞,接口却要顺带处理热度计算和榜单更新。后面如果热度公式变复杂,比如要考虑用户权重、反作弊、时间衰减,点赞接口会越来越难维护。
第二,排行榜没必要和点赞接口强绑定。
用户关心的是“点赞有没有成功”,不一定关心“热榜有没有立刻刷新”。热榜晚几秒变化,通常可以接受。
所以我会把它拆成两段。
同步链路只做必要动作:
1. 校验用户是否已经点赞
2. 写入点赞记录
3. 更新帖子点赞计数
4. 发送一条帖子互动事件
5. 返回点赞成功
事件内容可以很简单:
{
"postId": 1001,
"userId": 9527,
"eventType": "LIKE",
"eventTime": "2026-04-30 20:15:00"
}
后面由消费者处理排行榜更新:
消费帖子互动事件
↓
聚合一小段时间内的互动数据
↓
重新计算 hot_score
↓
判断是否进入候选榜
↓
写入 Redis ZSet
这里最好不要来一条事件就立刻重算一次。
热门帖子可能一分钟内收到很多点赞和评论。如果每条事件都触发一次 ZADD,Redis 更新会非常频繁。更好的方式是做短时间聚合,比如 1 秒或 3 秒聚合一次。
post_1001 在 3 秒内收到:
点赞 +20
评论 +5
收藏 +2
消费者合并后只更新一次 hot_score
这样既能保证榜单接近实时,也能减少大量重复更新。
这类设计在面试里可以说成“允许秒级延迟,用异步聚合换吞吐”。
3.2 榜单分层:不要让一个 key 承担所有流量
如果只设计一个榜单:
hot_rank:global
那所有内容都会往这里写,所有首页请求也会从这里读。时间久了,这个 key 会越来越大,也会越来越热。
我更倾向于把榜单拆成几类:
hot_rank:global 全站热榜
hot_rank:category:{id} 分类热榜
hot_rank:recent:{yyyyMMddHH} 小时热榜
hot_rank:day:{yyyyMMdd} 日榜
全站榜负责展示真正的高热内容。
分类榜负责承接不同频道,比如 Java、数据库、分布式、面试经验。
小时榜负责给新内容机会。
日榜可以用于运营页、榜单页,也可以辅助全站榜更新。
这几个榜单的作用不一样,更新策略也可以不一样。
比如新帖子刚发布时,不直接进入全站榜,而是先进入小时榜:
新帖子发布
↓
进入当前小时榜
↓
根据近 1 小时互动计算热度
↓
表现较好再进入日榜或全站候选榜
这样做有一个好处:新帖子不会一开始就和历史爆款硬碰硬。
如果只有一个全站榜,老帖子因为积累了大量互动,很容易长期霸榜。新内容即使短时间内很活跃,也很难冲上去。
小时榜相当于给新内容一个冷启动通道。
3.3 读链路:首页热榜要尽量读缓存
读榜单时,我会把链路设计成三层:
本地缓存
↓
Redis ZSet
↓
MySQL 快照
首页请求先查应用本地缓存,比如 Caffeine:
cacheKey: hot_rank:global:top100
value: 帖子列表
ttl: 1 ~ 3 秒
命中本地缓存时,直接返回结果。
本地缓存过期后,再去 Redis 读取:
ZREVRANGE hot_rank:global 0 99 WITHSCORES
拿到 postId 列表后,再批量查询帖子详情。帖子详情本身也可以走缓存,避免每次都回表查 MySQL。
如果 Redis 异常,就从 MySQL 的快照表里取最近一次结果:
SELECT post_id, hot_score, rank_no
FROM rank_snapshot
WHERE rank_type = 'global'
AND snapshot_time = (
SELECT MAX(snapshot_time)
FROM rank_snapshot
WHERE rank_type = 'global'
)
ORDER BY rank_no ASC
LIMIT 100;
这个结果可能不是最新的,但可以保证页面不空。
排行榜这种场景,降级数据旧一点可以接受;接口直接报错,用户感知会更明显。
3.4 榜单裁剪:Redis 只保存值得排序的数据
还有一个很现实的问题:Redis 内存不是无限的。
如果全站有几千万篇帖子,没有必要全部放进 hot_rank:global。大多数帖子没有互动,也不会出现在首页热榜。
所以可以设置进入榜单的门槛:
点赞数 >= 5
或 评论数 >= 3
或 收藏数 >= 2
或 最近 1 小时浏览量 >= 100
达到门槛后,才进入候选榜。
同时定期裁剪 ZSet:
ZREMRANGEBYRANK hot_rank:global 0 -100001
这里的意思是只保留分数最高的 10 万条左右,排名更靠后的内容直接移出实时榜。
当然,具体保留 10 万、50 万还是 100 万,要看业务规模和内存预算。文章里不需要把数字写死,可以说“根据业务规模配置”。
冷门帖子被移出 ZSet 不代表数据没了。它的点赞数、评论数、收藏数还在 MySQL 里。后面如果又有新的互动,达到门槛后可以重新进入候选榜。
3.5 可恢复:Redis 挂了,榜单不能跟着失忆
Redis 负责实时榜单,但它不应该是唯一的数据来源。
我会做两件事。
第一,MySQL 保存帖子互动计数。
post_id
like_count
comment_count
collect_count
view_count
hot_score
update_time
这些是事实数据。Redis 里的 score 可以重新计算,但事实数据不能丢。
第二,定期保存榜单快照。
比如每分钟把全站榜、分类榜、小时榜的 Top N 保存到 rank_snapshot 表里:
rank_type
rank_key
post_id
hot_score
rank_no
snapshot_time
当 Redis 故障时,读接口可以先返回最近一次快照。
当 Redis 恢复后,可以用两步重建:
1. 从最近一次 rank_snapshot 加载榜单基础数据
2. 回放最近几分钟的互动事件,修正 hot_score
如果 MQ 支持消息保留,这一步会更稳。
即使没有做到非常精确,也可以接受短时间内榜单略有偏差,后续定时任务会继续修正。
这个地方体现的是系统设计里的取舍:排行榜追求的是高可用和最终一致,不适合为了绝对实时把主链路搞得很重。
场景题
如果面试官问我:
现在有一个内容社区,帖子量级在千万级。每篇帖子都有浏览、点赞、评论、收藏等行为。现在要设计一个实时热帖榜,你会怎么做?
我会这样回答:
我会从写入、读取和兜底三个方面设计。
首先,热帖榜不会只按点赞数排,我会设计一个综合热度分,把点赞、评论、收藏、浏览量和发布时间都考虑进去。比如评论和收藏权重大一点,浏览权重低一点,再加时间衰减,避免老帖子一直霸榜。
存储上,MySQL 负责保存帖子本身和各种互动计数,实时榜单用 Redis ZSet 来维护。ZSet 里用帖子 ID 作为成员,热度分作为 score,这样查 Top N 和查某篇帖子的排名都比较方便。
用户点赞时,点赞接口只负责完成点赞、更新计数,然后发一条事件到 MQ。后面由消费者异步计算热度分,再更新 Redis。这样接口比较轻,热榜允许几秒延迟,没必要强一致。
对于热门帖子,我会做事件聚合。比如同一篇帖子 1 到 3 秒内收到很多点赞和评论,不需要每次都更新 Redis,可以合并后更新一次,减少写压力。
读取链路上,首页热榜是热点数据,我会在应用层加一层本地缓存,比如缓存 Top 100,设置 1 到 3 秒过期。请求先进本地缓存,没命中再查 Redis。这样能避免所有请求都打到同一个 Redis key 上。
考虑到大部分帖子没有互动,没必要参与实时排序。可以设置候选门槛,只有达到一定点赞、评论、收藏或者近期浏览量的帖子,才进入 Redis 榜单。Redis 里的榜单也会定期裁剪,只保留前面一部分。
榜单也可以分层做,比如全站榜、分类榜、小时榜、日榜。小时榜主要解决新帖子冷启动问题,不然新帖子很难和历史老帖竞争。
最后还要考虑兜底。Redis 不能作为唯一数据源,MySQL 里要保存互动计数和榜单快照。如果 Redis 出问题,先返回最近一次快照;恢复后再根据快照和最近的互动事件重建榜单。
写在文后
期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!
更多推荐

所有评论(0)