今天我们来深入拆解一个系统设计面试中的经典高频问题:如何为类似抖音这样的平台,设计一个能承载亿级数据量的实时热门视频排行榜?
乍看之下,这似乎只是按播放量排序的简单问题。但一旦将场景放大到抖音的量级,并叠加实时更新、多时间窗口统计等严苛要求,挑战便陡然升级。这不仅仅关乎基础的数据结构(如堆、排序),更涉及海量数据流处理、系统水平扩展、故障恢复以及成本权衡等多个维度的综合考量。
接下来,我们将以面试解题的逻辑展开,从最基础的单机实现切入,逐步暴露瓶颈、寻找解决方案,最终演进出一套能够抵御亿级数据冲击、同时保证高实时性与稳定性的排行榜架构。
1. 需求梳理
假设我们面临一个海量的抖音视频观看数据流(本质上是源源不断的 VideoID 记录)。系统需要在任意时间点,准确统计出特定时间窗口内(例如过去1小时、1天、1个月或整个历史周期)播放次数最多的前 K 个视频及其计数。为了将问题推向极致,我们假设平台某些短视频每天的播放量高达700亿次,且每秒都有超过1小时时长的新视频被上传。在此背景下,如何设计系统以满足前述需求?
对于系统设计问题,第一步始终是根据面试官的要求,清晰地梳理出功能性需求与非功能性需求。
1.1 功能需求
- 核心需求
- 客户端能够查询指定时间周期内,排名前K(例如最多1000个)的视频榜单。
- 支持的时间周期是固定的,例如:最近1小时、最近1天、最近1个月,以及全时段总榜。
- 非核心需求(本次设计暂不考虑)
- 不支持查询任意起止时间段的榜单。
- 所有查询默认从当前时刻向前回溯。
1.2 非功能需求
- 核心需求
- 数据延迟:从用户观看行为发生,到该行为被统计进排行榜,延迟不得超过1分钟。
- 精确性:结果必须是精确的,不允许使用近似计算。
- 高吞吐:系统需要能够处理海量的视频观看事件。
- 海量视频:系统需要支持海量的视频总数。
- 低延迟:查询排行榜接口的响应时间,需在几十毫秒(例如10-100ms)内完成。
- 成本可控:系统应当是经济高效的,不能依赖无限堆砌机器来解决问题。
明确这些量化指标,尤其是几十毫秒内返回结果的要求,对后续的技术选型至关重要。这一条件基本排除了所有查询时实时计算的方案,迫使我们必须优先考虑预计算或缓存方案。我们可以将需求整理成如下表格:

1.3 规模估算
现在,我们来估算两个对设计影响巨大的核心指标:每秒观看次数(TPS/QPS)和系统总视频数。前者决定了写入吞吐量,后者决定了存储规模。
首先估算吞吐量:
# 700亿次日播放 / (约10万秒/天)
70B views/day / (100k seconds/day) = 700k tps
70万TPS! 这个量级意味着我们必须从一开始就考虑将流量分发到多台机器上进行处理。
接下来估算存储。首先确定视频总量:
# (每秒上传1小时内容 / 平均每个视频6分钟) * 每天约10万秒 = 每天100万新视频
Videos/Day = 1 hour content/second / (6 minutes content/video) * (100k seconds/day) = 1M videos/day
# 每天100万 * 每年365天 * 假设平台运营10年 = 约36亿视频
Total Videos = 1M videos/day * 365 days/year * 10 years ≈ 3.6B videos
基于视频总量,可以粗略估算,如果仅为每个视频ID存储一个计数值,需要多少空间:
# 约40亿视频 * (每个ID 8字节 + 每个计数值 8字节) = 64 GB
Naive Storage = 4B videos * (8 bytes/ID + 8 bytes/count) = 64 GB
64GB的数据量,通过合理的设计,完全可以放入内存中处理,尤其是在采用多机分布式部署时。通过这些估算,我们能对设计方案的可行性做出初步判断,明确哪些架构成为可能,哪些被直接排除,这是系统设计中最关键的思考环节。
2. 底层设计
梳理完需求后,可以向面试官介绍接下来的底层设计。
2.1 核心实体定义
在该排行榜系统中,核心业务实体非常清晰:
- 视频 (Video):被统计和排行的主体。
- 观看 (View):用户的观看行为事件。
- 时间窗口 (Time Window):统计排行榜的时间范围,如1小时、1天等。
从概念层面看,这部分相对直接,我们可以将更多时间留给更具挑战性的架构设计。
2.2 系统接口设计
API设计将引导后续讨论。在本场景下,API相当基础,我们只需要一个接口来获取Top K视频榜单。
// 发起GET请求,查询指定时间窗口(window)的Top K(k)视频
GET /views/top?window={WINDOW}&k={K}
// 响应体
Response:
{
"videos": [
{
"videoId": "...", // 视频ID
"views": "..." // 观看次数
}
// ... more videos
]
}
这个部分没有过多设计点,属于常规操作。在面试中可简单介绍,然后将重点放在上层架构的讨论上。
3. 上层设计
面试官:“实体和接口定义清楚了。那么从架构层面看,你会如何着手解决这个问题?”
常规的设计思路是:先构建一个最小可行系统满足基本功能,再逐步优化性能以满足非功能需求。你可以这样回答:
- 首先,为最简单的‘全时段总榜’设计一个基础但不可扩展的单机解决方案。
- 然后,分析这个基础方案的核心问题,并逐步解决,如单点故障和写入扩展性。
- 接着,在可扩展的架构上,增加对‘滑动时间窗口’(如1小时、1天)的支持。
- 最后,深入探讨剩余的瓶颈,直至时间耗尽。
3.1 基础解决方案(单机版)
我们从运行在单台服务器上的全时段总榜方案开始。可以在内存中维护两个核心数据结构:
- 一个巨大的哈希表(
HashMap),Key是VideoID,Value是它的观看次数。
- 一个最小堆(
Min-Heap),容量为K(如1000),用于实时维护当前观看次数最多的Top K个视频。
遍历数十亿视频来找出最大值是不可行的,因此维护Top-K堆至关重要。绝大多数观看事件都不会影响这个堆,因为它们的计数值远低于Top 1000的门槛。处理流程如下:
- 观看事件(View)从数据流(如Kafka)中被消费。
- 对于每一个
VideoID,以原子方式在哈希表中将其计数值加一。
- 获取更新后的计数值,与堆顶元素(即Top K中的最小值)比较。
- 如果新计数值大于堆顶元素,则将堆顶元素移除,并将当前视频ID和新计数值插入堆中,然后调整堆结构。
- 客户端查询时,直接从这个堆中获取数据。

这个基础方案在单机上实现简单,但存在两个致命问题:
- 吞吐量瓶颈:单机处理能力远低于估算的70万TPS。
- 单点故障:主机宕机将导致服务完全不可用,所有内存数据丢失。
下面我们逐一分析并优化。
3.2 解决单点故障问题
为了让系统具备容错能力,我们需要优雅地处理节点故障。主要有以下三种思路:
3.2.1 数据库计数
将哈希表和堆的状态持久化到数据库中。这样,计算服务变为无状态,若主机故障,可启动新实例并从数据库加载状态继续处理。

此方案看似简单,但并未彻底解决问题,只是将问题转移至数据库:
- 性能瓶颈:每次计数更新都需至少一次数据库往返,对于70万TPS场景完全不可接受。
- 并发问题:更新计数值和更新堆这两个操作,在数据库层面很难高效实现原子性,易出现数据竞争。
- 索引开销:为快速找到Top K,需在数十亿记录上维护基于计数值的索引,每次写入都需更新,开销巨大。
此方案因性能问题基本可被排除。
3.2.2 多副本策略
针对上述问题,一个直接的思路是为计数器服务维护多个副本。每个副本独立消费完整数据流,计算并维护自己的哈希表与堆。

在介绍方案后,最好对比分析其优缺点,以体现思考的全面性:
优点:
- 读取可扩展:客户端可从任意副本读取数据,分担读取压力。
- 高可用:当一个副本故障时,可将其从负载均衡器中移除,服务不中断。
缺点:
- 资源浪费:每个副本都处理全量数据,硬件成本成倍增加。
- 恢复困难:若实例彻底宕机,需启动新实例并从数据流最开始消费以追赶进度,此“追赶”过程可能非常漫长。
3.2.3 带快照的副本
这是方案二的优化版。我们依然维护多个副本,但会定期将每个副本的内存状态(哈希表和堆)快照,并存储到持久化对象存储(如Blob Storage)中。

当新实例启动时,它首先从对象存储加载最新快照到内存,然后从Kafka中该快照对应的时间点开始消费数据流,直至追上实时进度。
快照极大缩短了恢复时间,但需关注“追赶”期间的吞吐量。如果系统处理能力是140万TPS,而实时流入是70万TPS,则每积压1秒数据,就需要1秒时间来恢复。此外,快照本身可能是数GB的大文件,执行快照操作的开销及保证快照内部数据一致性,也是需考虑的技术细节。
副本+快照机制显著提升了系统容错能力。接下来,解决写入吞吐量问题。
3.3 扩展写入吞吐量
面试官:“副本解决了高可用问题,但每个副本仍在处理全量70万TPS数据,这在单机上仍不现实。写入瓶颈该如何突破?”
写入扩展的首选方案是分片或分区。
3.3.1 按ID固定分区
最基础的思路是创建P个分片(Shard),每个分片只处理一部分视频的数据。通过一个简单哈希函数,如 shard_index = hash(video_id) % P,决定观看事件应被路由到哪个分片。
这样,每个分片仅处理 700k / P 的流量。每个分片内部仍是“哈希表+堆”结构,并采用“副本+快照”模式保证高可用。
但现在我们有了P个独立的Top-K堆,客户端如何获取全局Top-K?需要引入一个新的聚合服务(Top-K Service)。该服务负责查询所有P个分片的Top-K堆,在内存中合并排序,最终返回全局Top-K结果给客户端。

此方案仍存问题:
- 扩展性受限:分区固定(
% P),若想增加分片数量(如从10到20),需进行大规模数据迁移,非常复杂。
- 聚合开销:若P值很大,聚合服务需进行大量RPC调用,其本身可能成为瓶颈。
3.3.2 弹性分区(推荐)
为支持动态扩缩容,可使用一致性哈希代替简单取模运算。每个分片在一致性哈希环上负责一个区段。当需要扩容时,新分片启动,并从环上左右相邻分片的快照中读取、过滤出属于自己的数据进行初始化。

此方案需要服务注册与发现中心(如ZooKeeper或etcd)来维护分片与哈希环的映射关系,以便聚合服务和数据流消费者知晓应查询哪些分片。增减分片时也需要相应的协调机制。
至此,我们已设计出具备容错和可扩展性的全时段总榜解决方案。但尚未满足所有功能需求。
4. 扩展性设计
4.1 处理时间窗口
面试官:“OK。现在我们有了可扩展的总榜方案。但最终还需支持最近1小时、1天、1个月的榜单。这种滑动时间窗口需求,你打算如何实现?”
滑动窗口是此类系统中最复杂的部分。对于总榜,只需不断累加计数。但对于时间窗口,还需在数据过期时将其计数减掉。最佳方案是从基础但不完善的方案入手,分析问题,逐步优化。
4.1.1 微桶策略
可为每个视频维护以分钟为单位的计数桶,例如一个 Map<[VideoID, MinuteTimestamp], Count>。
当计算最近1小时榜单时,将此视频过去60个分钟桶的计数值相加。同时,为每个时间窗口(1小时、1天、1个月)维护独立的Top-K堆。

但细想之下,此方案问题诸多:
- 堆数据陈旧:一个视频可能在上个小时是Top K,但这个小时无任何观看。它会一直占据堆位置,除非有计数值更高的新视频将其挤出。
- 计算成本高昂:为计算一个视频一个月的观看量,需累加
30 * 24 * 60 = 43200 个分钟桶数据,开销巨大。
- 内存爆炸:为每个视频存储过去一个月的所有分钟桶,内存消耗急剧膨胀。
4.1.2 刷新堆数据
可尝试修正微桶方案的缺陷:
- 解决堆数据陈旧:在查询前刷新堆内数据。遍历堆中K个元素,重新计算其在当前窗口内的准确计数值,并根据新值调整堆。此方案笨重,读取性能差。
- 解决计算成本:维护多种时间粒度数据。除分钟桶外,额外存储小时桶、天桶。计算月榜时,用天桶聚合,大幅减少累加数据量。
- 解决内存膨胀:定期清理超期数据(如超过1个月的分钟桶)。

此优化方案变得异常复杂。尽管引入了多层聚合,读取时可能仍需重建大部分内容,且仍需维持整月每分钟的粒度,并非较优方案。
4.1.3 双指针法(推荐)
面试官:“你说的方法确实可一定程度解决问题,但过于复杂。有没有更简洁、更贴合我们需求的方案?”
既然数据源是如Kafka般可持久化、支持从任意偏移量消费的流,我们可以巧妙利用此特性。为每个时间窗口(1小时、1天、1个月)维护独立的计数值哈希表和Top-K堆。对于每个时间窗口,启动两组消费者(或两个指针):
- 上升沿指针(Leading Edge):消费实时数据流。每消费一条观看记录,将对应视频在所有时间窗口(1小时、1天、1个月、总榜)的计数值加一。
- 下降沿指针(Trailing Edge):对于“1小时”榜,此指针消费1小时前的数据流;对于“1天”榜,消费1天前的数据流,依此类推。每消费一条记录,将对应视频在相应时间窗口的计数值减一。

通过这一加一减,每个时间窗口的计数值哈希表里,永远精确存储着该窗口内的总观看次数,Top-K堆的数据也永不陈旧。假设有如下观看序列:
00:05: 视频A被观看
00:20: 视频B被观看
00:40: 视频B再次被观看
观察1小时榜的计数值变化:
00:05,{A:1}。(上升沿处理)
00:20,{A:1, B:1}。(上升沿处理)
00:40,{A:1, B:2}。(上升沿处理)
01:05,此时,下降沿指针正好消费到00:05那条A的观看记录,执行减一操作。计数值变为{A:0, B:2},即{B:2}。
01:20,下降沿指针消费到00:20那条B的记录,计数值变为{B:1}。
01:40,下降沿指针消费到00:40那条B的记录,计数值变为{B:0},即{}(空)。
此过程完美维护了滑动窗口内的精确计数。存在的问题主要有:
- 存储成本:要求Kafka中至少保留1个月数据,显著增加集群存储成本。
- 消费负载:总消费负载增加(1个上升沿 + 3个下降沿 = 4倍读取)。
- 堆容量:由于视频计数值有增有减,视频可能频繁进出Top-K。为防止频繁堆调整,需将堆实际容量设得比K更大(如2K),查询时只返回前K个。
尽管存在上述问题,综合考量下,双指针法仍是可行性较高的方案。
4.2 海量读请求处理
目前我们主要讨论了海量写入的处理,但读取呢?考虑到从行为发生到统计完成有1分钟延迟,最自然的方案是添加缓存。可为缓存设置1分钟TTL,确保结果不会比要求更陈旧。当请求到达时,可直接从缓存(如Redis)中提供服务;若缓存未命中,则查询对应堆的所有计数器,合并后存储至缓存并返回。

5. 小结
回顾整个设计方案,我们从最初直观的单机方案起步,通过多副本快照解决容灾,借助分片架构支撑高吞吐,再利用双指针法精确维护滑动时间窗口,最后引入缓存层优化读取性能。可见,一个看似简单的播放量排序问题,在亿级视频的场景下,要设计出可行的方案,需要数据结构、分布式系统、流处理、缓存机制和系统容错等多方面的综合考量。这不仅是对技术细节的考察,更是对架构思维和权衡能力的全面检验。
若你对这类结合了高并发、实时性与精确性的系统设计问题感兴趣,欢迎在云栈社区与我们深入探讨更多实战案例与架构模式。