为什么音视频API必须做限流
音视频类业务有一个非常明显的特征:流量突发性强。一场热门直播开售、一个爆款视频被转发、一次重大赛事开播,推拉流请求可能在几秒内涌进来,流量峰值达到平时的几十倍甚至上百倍。如果API网关没有限流保护,后端的鉴权服务、房间调度服务、边缘节点分配服务都会被打垮,进而引发连锁反应,最终导致全站不可用,这就是典型的雪崩效应。

限流的本质是控制系统在任意时间窗口内的请求速率不超过服务容量上限,超出的请求要么被拒绝,要么被排队延迟处理。做限流之前,团队需要先做一次容量评估:单个鉴权接口的QPS上限是多少,一次推流申请需要消耗多少下游资源,边缘节点的带宽池有多大。有了这些数据,才能定出合理的限流阈值,阈值定低了会误伤正常用户,定高了又起不到保护作用。
除了保护自身服务,限流还有商业层面的意义。音视频平台的带宽和转码资源是按量计费的,合理的限流可以防止恶意刷流导致的成本失控。同时,针对不同等级的客户(比如企业客户和免费用户),也可以通过限流实现差异化的服务质量控制,这本质上就是资源配额管理。
三种经典限流算法的原理剖析
最直观的限流方案是固定窗口计数器:把时间切成一秒一个的窗口,每个窗口内维护一个计数器,超过阈值就拒绝。这个实现简单,但有明显的临界问题——假设限流阈值是每秒100个请求,攻击者在第0.9秒到第1.1秒之间打了200个请求,那么两个窗口各自只有100个,都没超限,但实际瞬间流量已经翻倍。为了解决这个问题,滑动窗口算法应运而生。
滑动窗口把固定窗口进一步细分,比如把1秒切成10个100毫秒的小格子,统计时取最近10个格子的总和。格子切得越细,统计越精确,临界突刺问题越不明显。实际工程中通常用Redis的有序集合(ZSET)来实现:每次请求以当前时间戳作为score写入,先用ZREMRANGEBYSCORE清理窗口外的过期数据,再用ZCARD统计窗口内的请求数,超过阈值则拒绝。下面是一个Lua脚本示例:
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- 清理滑动窗口之外的过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
-- 未超限,记录本次请求
redis.call('ZADD', key, now, now .. math.random())
redis.call('PEXPIRE', key, window)
return 1
end
return 0漏桶算法的思路像一只底部开孔的水桶:请求先进入桶中,桶以固定速率向外“漏出”请求进行处理,桶满了就直接溢出拒绝。它的核心价值是整流——不管上游流量多么汹涌,下游永远以恒定速率消费,这对音视频转码这类不适合被打爆的后端服务非常友好。漏桶通常用队列实现,配合一个固定速率的消费线程或定时器,实现简单且行为可预测。
令牌桶则换了一个角度:系统以固定速率往桶里放令牌,桶有容量上限,请求到达时必须拿到一个令牌才能通过,拿不到就被拒绝或等待。关键区别在于,桶里可以积累令牌。如果系统空闲了一段时间,桶里会攒下最多容量上限的令牌,突发流量到来时可以瞬间消耗这些存粮,允许一定程度的突发请求通过。下面用Go演示一个简单的单机令牌桶:
type TokenBucket struct {
capacity float64 // 桶容量,即最大突发量
tokens float64 // 当前令牌数
rate float64 // 每秒生成令牌数
lastTime time.Time
mu sync.Mutex
}
func (b *TokenBucket) Allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
// 按时间差补充令牌
elapsed := now.Sub(b.lastTime).Seconds()
b.tokens = min(b.capacity, b.tokens+elapsed*b.rate)
b.lastTime = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}三种算法在音视频场景下的对比与选型
三种算法没有绝对的优劣,关键看业务诉求。滑动窗口解决的是统计精度问题,它限的是“数量”,允许请求以任意速率涌入,只要窗口内总量不超标即可。漏桶强调“速率恒定”,天然适合保护下游处理能力固定的服务。令牌桶兼顾“平均速率”和“突发容忍”,是大多数互联网API网关的首选。下面这张表做个直观对比:
| 维度 | 滑动窗口 | 漏桶 | 令牌桶 |
|---|---|---|---|
| 限流目标 | 窗口内总量 | 恒定流出速率 | 平均速率加突发容忍 |
| 突发流量 | 窗口内可任意突发 | 完全平滑,无突发 | 允许桶容量内的突发 |
| 实现复杂度 | 中,依赖有序结构 | 低 | 低到中 |
| 内存开销 | 较高,需记录每次请求 | 低 | 低,只需存令牌数 |
| 典型场景 | 用户级请求配额 | 保护转码、存储等下游 | API网关全局限流 |
结合音视频业务的实际链路,可以按层次组合使用。在入口的API网关层,建议用令牌桶,因为直播开播、连麦接入这类操作本身就有合理的突发需求,用户点击进入直播间往往集中在几秒内,完全平滑的漏桶会造成明显延迟。在用户维度,可以用滑动窗口做配额控制,比如限制单个用户每小时最多发起多少次推流申请,防止账号被盗用后恶意刷流。在调用下游转码、截图、内容审核等服务时,漏桶更合适,因为这些服务的处理能力基本固定,恒定速率的请求队列能让它们始终运行在最佳负载区间。
分布式部署时还要注意一致性问题。单机限流只能保护单个实例,网关集群的总放行量等于各实例阈值之和,扩缩容后阈值会漂移。更可靠的做法是把限流状态集中到Redis,通过Lua脚本保证检查和扣减的原子性。但Redis本身也可能成为性能瓶颈,通常再做一层优化:每个网关实例从Redis批量预取一段令牌配额到本地,本地耗尽后再去中心续期,这样既保证了全局限流的准确性,又把Redis的访问压力降到很低。令牌的预取量可以按实例权重分配,与容量规划挂钩。
工程实践中的常见坑与进阶建议
第一个常见的坑是时钟依赖。如果令牌桶或滑动窗口的实现依赖各节点本地时间戳,机器之间的时钟偏差会导致限流判定不一致,严重时窗口统计数据完全错乱。分布式场景下尽量用Redis服务器返回的时间,或者用NTP保证集群时钟偏差在毫秒级以内。第二个坑是Redis热key。所有请求都打到同一个限流key上时,单分片Redis可能先于业务服务被打垮,解决办法是对key做分片打散,比如把一个限流额度拆成10个子key轮询使用,代价是限流精度略有下降。
被限流之后的处理策略同样重要,不能简单地一拒了之。对音视频客户端来说,直接返回错误会带来糟糕的体验,建议返回标准的429状态码并附带Retry-After头部,客户端拿到后做指数退避重试。对于开播这种关键操作,还可以设计请求排队机制,把超额请求放进队列延迟处理,用户只会感觉稍微多等了一两秒,而不是直接报错。同时要把限流事件上报到监控系统,记录限流维度、被限次数和放行率,这些数据既能用于阈值调优,也能作为攻击识别的特征。
最后建议建立多级限流体系,而不是只靠一层防线。最外层用CDN和WAF拦截明显的恶意流量,中间层在网关做基于用户和API的全局限流,最内层在每个微服务内部再做进程级兜底限流。层层设防后,即使某一层被绕过或配置失误,后面的层级仍能兜住。限流参数也不要一次性定死,先采用灰度放量策略,观察服务的实际负载曲线再逐步收紧阈值,把限流当成一个持续运营的过程,而非一次性的配置任务。