架构设计
收录于 工程治理体系
从知乎热榜一个细节,倒推热榜系统在排什么
一次刷知乎热榜时冒出来的小疑问,会逼着你先想清楚:热榜到底在排什么,是总量、增速,还是此刻最值得被注意的内容。
最近刷知乎时,我被一个很小的细节绊了一下。
我先是在热榜里看到一个问题,点进去以后,又顺手翻了下面几条已经很热的内容。回到列表时,我脑子里冒出来的第一个问题不是“这个回答怎么火了”,而是另一件事:
热榜这种系统,到底是在排什么?
这里先把边界说清楚:我没有看到知乎公开说明“同一个问题下多个回答会同时作为热榜条目出现”是一个稳定规则,所以这篇不建立在这个结论上。我想记下来的,其实只是一个更朴素的触发点:
热榜给用户看到的是一个列表,但平台内部未必只在排一个单层对象。
用户感知到的是“这个问题很热”,但系统内部很可能还要同时处理内容层、问题层,甚至更高一层聚合对象的热度。
公开能确认到的那部分,其实也只到这里:知乎热榜会参考搜索行为、搜索频次及其变化、内容整体浏览、分享、收藏等信号持续计算更新;但更细的条目组织规则并没有被公开讲透。
这个问题有意思的地方在于,它表面上像排序,往下想却很快会碰到业务定义。
排的是“问题”,还是“回答”? 看的是总量,还是短时间内的增长? 老内容和新内容怎么平衡? 热榜想表达的是“历史上最热的内容”,还是“此刻正在变热、值得被注意的内容”?
也正因为这个细节,我后来觉得热榜特别适合拿来想一件事:很多技术方案最初都是被业务目标逼出来的。
这篇不是在猜知乎真实实现,我只是想借这个现象,倒推一套热榜系统设计时最先要回答的问题。
先别急着想 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 反而都只是后面的实现手段。