🔥 个人主页:铁皮哥(欢迎关注)
📌 作者简介: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 出问题,先返回最近一次快照;恢复后再根据快照和最近的互动事件重建榜单。

写在文后

期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐