最近刷知乎时,我被一个很小的细节绊了一下。

我先是在热榜里看到一个问题,点进去以后,又顺手翻了下面几条已经很热的内容。回到列表时,我脑子里冒出来的第一个问题不是“这个回答怎么火了”,而是另一件事:

热榜这种系统,到底是在排什么?

这里先把边界说清楚:我没有看到知乎公开说明“同一个问题下多个回答会同时作为热榜条目出现”是一个稳定规则,所以这篇不建立在这个结论上。我想记下来的,其实只是一个更朴素的触发点:

热榜给用户看到的是一个列表,但平台内部未必只在排一个单层对象。

用户感知到的是“这个问题很热”,但系统内部很可能还要同时处理内容层、问题层,甚至更高一层聚合对象的热度。

公开能确认到的那部分,其实也只到这里:知乎热榜会参考搜索行为、搜索频次及其变化、内容整体浏览、分享、收藏等信号持续计算更新;但更细的条目组织规则并没有被公开讲透。

这个问题有意思的地方在于,它表面上像排序,往下想却很快会碰到业务定义。

排的是“问题”,还是“回答”? 看的是总量,还是短时间内的增长? 老内容和新内容怎么平衡? 热榜想表达的是“历史上最热的内容”,还是“此刻正在变热、值得被注意的内容”?

也正因为这个细节,我后来觉得热榜特别适合拿来想一件事:很多技术方案最初都是被业务目标逼出来的。

这篇不是在猜知乎真实实现,我只是想借这个现象,倒推一套热榜系统设计时最先要回答的问题。

先别急着想 Redis、Kafka、Flink,先想这个榜想表达什么

很多系统一讨论到榜单,第一反应就是技术选型:

  • Redis 能不能做
  • 要不要上 Kafka
  • 需不需要 Flink
  • 数据落 MySQL 还是 ES

这些问题当然重要,但如果一开始就问成这样,思路其实很容易跑偏。

因为热榜首先要回答的,是一个比“存哪儿、算多快”更前面的业务问题:

这个榜单,到底想让用户看到什么?

比如知乎热榜这种产品,它大概率不只是想展示“历史累计热度最高的内容”,而更像是在回答:

此刻,哪些内容最值得被注意。

这句话看起来没什么,但它一旦成立,后面的技术选择就都会变。

如果你只按累计浏览量排,那很多老内容会长期赖在榜上。 如果你只看一分钟内的增速,榜单又会抖得很厉害。 如果你既想让真正热的内容留下来,又想给新内容机会,那背后就远不止一个简单排序字段——它需要一整套业务目标的翻译。

所以我如果真要想这类系统,第一步一般会先把下面几个问题问清楚。

1. 榜单对象到底是什么

是“问题”、是“回答”、是“文章”,还是更抽象的“内容单元”?

如果一个热榜产品会分别捕捉同一问题下的多个回答,那榜单对象很可能就不只停在“问题”这一层,也会下探到“回答”这一层。但从用户感知上,它又像是在说“这个问题整体正在变热”。

这说明系统里多半不只一层热度,至少会同时存在:

  • 内容自身的热度
  • 话题或问题维度的热度

对象一旦没想清楚,后面很多技术讨论都会变形。因为你以为自己在做一个榜,实际上可能在混着算两层不同的东西。

2. 热度到底由什么组成

浏览、点赞、评论、收藏、转发、停留时长,这些动作是不是都算热度?如果算,它们权重是否一样?

显然不会一样。

一次浏览和一次高质量评论,在业务价值上通常不是一回事。热度很少是系统里天然就有的一个字段,它更像是一个经过翻译后的综合分。

3. 要不要做时间衰减

这是热榜和总榜最不一样的地方之一。

总榜看累计值,热榜看“现在有多热”。

所以热榜系统几乎一定会遇到一个问题:旧热度怎么衰减,新热度怎么放大?

如果不处理时间,热榜很容易退化成“历史大户榜”;如果时间权重过重,又会变成一个疯狂抖动的增速榜。

4. 这个榜到底要多实时

这不是一个修辞问题,是一个成本问题。

每秒更新、每分钟更新、还是更长时间刷新,背后对应的是完全不同的计算复杂度、系统成本和产品体验。

很多时候,“秒级实时”听起来很高级,但真正值不值得,是业务先决定的,不是技术先决定的。

如果需求很朴素,Redis ZSET 依然是很自然的起点

虽然上面聊了很多业务判断,但如果让我在一个相对简单的场景里先落地,我大概率还是会优先想到 Redis ZSET。

因为如果需求只是这样:

  • 已经有一个明确热度分
  • 需要查 Top N
  • 需要查某个对象的排名
  • 刷新要快,读要尽量实时

那 ZSET 几乎就是非常顺手的起点。

它的价值不在于“最先进”,而在于工程上非常直接:

  • 写入简单
  • 查询高效
  • 实现成本低
  • 很适合先把榜单跑起来

我一直挺相信一件事:不是所有系统一上来都值得上重型架构。业务还在早期时,先用一个足够靠谱、足够快上线的方案把真实问题跑出来,通常比一开始就追求“完整正确”更成熟。

所以如果规则比较固定、规模也还没到极端量级,ZSET 完全可以是第一阶段的方案。

先跑起来。

如果把第一版再画得落地一点,甚至可以简单成这样:

flowchart LR
    E[浏览/点赞/评论等行为] --> S[应用服务算热度增量]
    S --> Z[Redis ZSET]
    Z --> Q[榜单查询接口]

服务层每收到一次行为,就按一套很粗的权重规则算一个增量,然后直接写进 ZSET。比如先用这种非常朴素的口径:

浏览 +1
点赞 +3
评论 +5
收藏 +4
分享 +8

对应到 Redis 命令,大概就是这几类最核心的动作:

ZINCRBY hot_rank 5 answer_123
ZREVRANGE hot_rank 0 99 WITHSCORES
ZREVRANK hot_rank answer_123

这套东西并不高级,但它很有用,因为它能先把最小闭环跑起来:事件进来,分数更新,榜单能查。

但一旦你真把“热榜”想深,它就不再只是一个 ZSET

问题在于它更擅长做“给我一个最终分数,我来帮你排好序”,不擅长做“这个最终分数到底应该怎么从连续行为里算出来”。

而热榜最麻烦的地方,恰恰在后者。

问题就在这。

1.热榜通常不只是比谁总量大

如果一个榜要表达“最近更热”,那它就不是单纯的累计排名。

这时候系统要维护的,其实是一个动态热度:

  • 某段时间窗口内的行为量
  • 行为类型之间的不同权重
  • 旧数据的衰减
  • 新数据的放大

也就是说,ZSET 最适合承接“结果”,但热榜往往更难的是“结果之前的计算”。

2. 榜单对象可能天然就是多层的

最开始那个浏览过程,就是把这个问题顶出来的触发点。

如果一个平台真的会分别捕捉同一问题下的多个回答,那系统至少在“回答”这一层有热度计算;但从产品体验和运营视角,它又很可能同时关心“问题”这一层是不是也在变热。

这样一来,系统就很可能要同时支撑:

  • 回答热榜
  • 问题热榜
  • 话题热榜
  • 某个垂类下的子热榜

一旦走到这里,问题就已经不是“一个有序集合怎么排”,而更像是“多维对象的热度系统怎么计算和发布”。

3. 真实热榜一定会碰到“新内容”和“稳定性”的平衡

如果完全按累计数据算,老内容天然优势太大。 如果过度强调短时增长,榜单又会非常不稳定。

所以真实热榜都得在几件事之间找平衡:

  • 要体现热度
  • 要允许新内容冲上来
  • 要避免榜单被偶发流量刷穿
  • 要保留一定稳定性,不至于频繁抖动

一旦你开始认真想这些问题,热榜设计就已经从“排序结构设计”进入“热度模型设计”了。

如果继续往工程实现走,我更愿意把它拆成三层

当我把热榜看成“一个持续计算当前热度的系统”,而不是“一个排序表”之后,工程思路会比较自然地收敛成三层:

第一层:事件层

这一层先不关心榜单结果,只关心行为本身。

比如浏览、点赞、评论、收藏、分享,这些都先被记录成标准化事件,进入统一事件流。

这样做的价值很现实:

  • 业务写入更轻,不需要每次行为都同步改榜单
  • 遇到热点流量时更容易削峰
  • 事件流后面还可以复用给推荐、分析、风控、运营

这一层的核心思想其实很简单:先把行为留下来,再去算结果。

第二层:计算层

这一层才是热榜的大脑。

它要做的远不止简单求和,它得持续回答一件事:

这个对象此刻到底有多热?

这里面往往会包括:

  • 不同行为的权重换算
  • 时间窗口内的聚合
  • 时间衰减
  • 异常行为过滤
  • 不同维度的 Top N 计算

如果业务复杂度已经明显上来,流式计算框架会比直接在缓存层硬算更自然。不是因为“上 Flink 更高级”,而是因为这个问题本身已经更像连续事件处理,而不是一次静态排序。

第三层:结果层

对前端和接口来说,最后关心的还是那几个结果:

  • 当前 Top 10 / Top 50 / Top 100 是谁
  • 某个对象现在大概在什么位置
  • 榜单多久刷新一次

所以计算层产出的结果,完全可以再落回 Redis 这类高性能存储里,对外只负责高效读。

这也是我比较喜欢的一种分工方式:

  • 事件层负责留下行为
  • 计算层负责把行为翻译成热度
  • 结果层负责把榜单高效发出去

如果要把架构再落具体一点,我会把 CK 放在旁路,而不是主链路

如果只写到这里,确实还是偏“想法”。真往工程里落,我脑子里更像下面这张图:

flowchart LR
    U[用户行为\n浏览/点赞/评论/收藏/分享] --> APP[业务应用]
    APP --> K[Kafka 事件流]
    K --> F[Flink 热度计算]
    F --> R[Redis 热榜结果]
    R --> API[热榜查询接口]
    API --> C[客户端]

    K --> CK[ClickHouse 历史事件/聚合分析]
    F --> CK
    CK --> OPS[运营分析/权重校准/回刷校验]

    F --> SNAP[热榜快照/分层结果]
    SNAP --> R

这张图里我会刻意把 ClickHouse 放在旁路,而不是在线热榜主链里。

原因很简单:Redis 更适合扛在线查询结果,Flink 更适合持续算“现在有多热”,而 CK 更像是给这套系统补上另一类能力:

  • 长时间窗口的数据分析
  • 热度权重的回看和校准
  • 某次异常波动的复盘
  • 热榜快照对比和回刷校验
  • 给运营和策略侧提供离线分析口径

换句话说,如果只问“当前 Top 50 是谁”,CK 通常不是第一响应者;但如果继续问“为什么这条内容昨天能冲上来”“过去一周这个话题的热度曲线怎么变的”“这轮权重调整有没有把榜单带歪”,那 CK 这种分析型存储就会很顺手。

所以我如果真做这类系统,不太会把 CK 理解成“在线热榜数据库”,而更愿意把它放在热榜系统的分析和治理侧。这样主链路不会被拖重,后面的回看、调参和运营分析也有地方落。

如果真做这种系统,我反而会克制地避免“绝对实时”

很多人一谈热榜,会下意识觉得:

热榜一定要绝对实时,越实时越高级。

但如果真站在产品和系统设计角度,我反而会对这件事比较克制。

因为热榜不是单纯比拼计算速度的系统,它更像是在几件事之间找平衡:

  • 实时性
  • 稳定性
  • 可解释性
  • 可运营性

对用户来说,100 毫秒更新和 10 秒更新,感知差异未必有想象中那么大;但对系统来说,这背后可能是完全不同的一套复杂度。

所以如果让我设计,我更可能会倾向一种“准实时”的节奏:

  • 行为实时采集
  • 热度按秒级或分钟级滚动计算
  • 榜单按固定节奏发布

这样做对用户感知影响未必大,但榜单会稳定很多,系统也更容易被治理和运营体系接住。

有时候技术上能做到,不代表业务上就真的需要。

最后再回到最开始那个小观察

最开始让我想到这篇的,只是一个很小的浏览瞬间:

我在刷热榜时,突然意识到自己其实没法只把它当成一个简单列表来看。

但顺着这个问题往下走,我发现热榜类系统有意思的地方恰恰就在这儿:

你以为它是在排一个列表,最后却会发现它背后其实在回答很多更前面的问题:

  • 榜单对象是什么
  • 热度该怎么算
  • 新旧内容怎么平衡
  • 计算要多实时
  • 结果怎么稳定地对外发布

这也是我喜欢从业务现象倒推技术设计的原因。

因为很多时候,技术本身并不是最难的。难的是,你能不能先问对问题:

这个榜到底想表达什么? 它更在意总量、增速,还是“此刻值得被注意”? 用户看到的这个排序,背后到底对应的是哪一种业务判断?

这些问题想清楚之后,Redis、Kafka、Flink 反而都只是后面的实现手段。