为什么需要限流?
在突发流量、热点事件或恶意请求面前,再稳健的系统也有容量上限。如果没有限流,短时间涌入的请求会迅速耗尽线程、连接和数据库资源,最终导致本可正常处理的请求一起失败。
假设某电商平台在零点开启限量秒杀:平时接口只有几千 QPS,活动开始瞬间却涌入几万次点击,而数据库和下游服务依然只能承受有限的并发。如果没有限流,请求会迅速占满线程池、连接池和消息队列;排队、超时与自动重试又会继续放大流量,最终可能拖垮整个交易链路。
热点新闻、直播抢购、突发事故查询等场景也会出现类似问题。限流的目的不是简单地把用户挡在系统外,而是在容量有限时主动控制进入系统的流量,以少量请求受限为代价,换取系统整体的可用性。本文将从固定窗口开始,依次分析滑动窗口、漏桶和令牌桶的原理、边界行为与适用场景。
限流算法
这里我们先从最简单的限流方案开始,逐步推导进行优化。
一个最简单的限流算法应该怎么做?
如果从最基本的需求出发,首先需要回答一个问题:系统当前还允许多少请求进入?其实可以先推出一个最原始的方案。系统维护一个"剩余额度"计数器,例如,将每秒的限流阈值设为 100,那么计数器的初始值就是 100。每当一个请求到达时,程序先检查计数器是否大于 0:如果大于 0,说明仍有额度,于是将计数器减一并放行请求;如果已经等于 0,则拒绝请求。
但只有计数器还不够,系统累计放行 100 个请求后就会永远耗尽额度,显然不符合业务需求。因此,我们需要这个计数器必须能够反映当前一段时间内收到了多少请求,并在时间过去后重新开始统计。
对应判断逻辑:
ini
初始化:
remaining = limit
请求到达:
if remaining > 0:
remaining -= 1
放行请求
else:
拒绝请求
固定窗口结束:
remaining = limit
计数器的状态一般存储在redis中便于多实例共享,当然也可以存储在本地,做单机的限流。下面我们都以redis中的实现作为示例,通常情况下限流的状态应该由两种情况来确定:限流对象 + 时间 ,最终落在redis上类似这样:rate_limit:{限流对象}:{时间窗口}。注意,这里的"时间"不能直接使用每次请求的当前时间,否则每个请求都会生成不同的 Key,计数器就无法共享,它应当是当前时间窗口的起点。
因为此时涉及到了redis中kv的get和set,所以需要使用lua脚本防止并发问题:
lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local remaining = tonumber(redis.call("GET", key))
-- 当前窗口第一次请求,创建 Key 并设置过期时间
if remaining == nil then
redis.call("SET", key, limit, "PX", ttl)
remaining = limit
end
-- 没有额度,拒绝
if remaining <= 0 then
return -1
end
-- 有额度,放行并消耗一次
return redis.call("DECR", key)
这个 Lua 脚本接收限流 Key、窗口总额度和 TTL,先查询当前剩余额度。若 Key 不存在,就用 SET ... PX ttl 初始化额度并设置过期时间;若额度已耗尽则返回 -1 表示拒绝,否则执行 DECR 扣减一次并返回剩余额度。由于整个脚本在 Redis 中原子执行,多个并发请求不会同时读到相同余额并导致超限。
有了这个Lua脚本,应用代码就比较简单了,主要就是计算 Key 和 TTL,将它传入 Redis 执行脚本。
到这里已经推导出了固定窗口限流。其核心特征是用**"限流对象 + 固定时间窗口"**确定 Key,Key 中保存剩余额度,请求通过时原子递减,窗口结束后由新 Key 替代旧 Key。Redis + Lua 只是这个算法在分布式环境下的工程实现,算法本身仍然是固定窗口。
滑动窗口限流
固定窗口的问题在于:它只在窗口边界进行机械的计数重置,并不关心前一个窗口刚刚通过了多少请求。例如,将限流设置为每 2 分钟最多 100 次。如果前一个窗口的最后 10 秒通过了 100 次请求,而新窗口开始后的前 10 秒又通过了 100 次请求,那么系统实际上在 20 秒内通过了 200 次请求。单看任意一个固定窗口,都没有超过 100 次,但短时间内的真实并发量已经远超限制。
所以更合理的规则应该是,每一个请求到达,都应该结合之前的时间窗口内的请求数,来判断是否达到限流limit的瓶颈,这样统计范围会随着时间持续向前移动,也就是滑动窗口算法。
shell
#### 滑动日志
固定窗口会有上述问题,本质上是固定窗口只保存当前窗口的剩余额度,却不知道这些请求具体发生在窗口中的哪一刻,就比如当它计算出"当前窗口还剩 0 次"时,它也不知道前一次请求是两分钟前发生的,还是刚刚发生的。要判断任意连续两分钟内的请求量,就不能只保存一个计数,而必须知道每次请求发生的时间。
假设窗口为 2 分钟、阈值为 100。当请求在时刻 now 到达时,真正需要统计的是下面这个时间区间:
csharp
[now - 2分钟, now]
因此滑动日志的思路是每次放行请求时,把请求时间记录下来,新请求到达后,先删除已经滑出窗口的旧记录,再统计当前窗口内的请求数量:
markdown
清理范围外的记录
计算窗口内请求数量
如果数量 < 阈值:
放行并记录当前时间
否则:
拒绝
例如,当前时间为 10:02:30,那么 10:00:30 之前的记录都已经无效,只保留 10:00:30 到 10:02:30 之间的请求记录。由于统计范围不断向前移动,而不再按固定边界清零,这就形成了滑动窗口。
下面我们看如何在redis中保存滑动日志的限流状态并且实现限流算法:滑动日志需要保存"请求发生时间",并且要不断删除旧记录、统计剩余数量。Redis 的 ZSET 很适合这个需求:用请求时间作为 score,用唯一的请求标识作为 member。一次请求的判断过程如下:
markdown
1. 删除 now - window 之前的记录
2. 统计当前窗口内的记录数
3. 如果数量小于阈值,则放行当前请求
4. 将当前请求的时间写入 ZSET
下面给出具体的Lua脚本:
lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requestId = ARGV[4]
-- 删除窗口外的请求记录
local cutoff = now - windowMillis
redis.call("ZREMRANGEBYSCORE", key, "-inf", cutoff)
-- 统计当前窗口内的请求数
local count = tonumber(redis.call("ZCARD", key))
if count >= limit then
return -1
end
-- 记录当前请求
redis.call("ZADD", key, now, requestId)
redis.call("PEXPIRE", key, windowMillis)
return limit - count - 1
滑动日志会为每个通过限流的请求记录时间戳,并按时间排序。新请求到达时,先删除窗口外的旧记录,再统计窗口内请求数;未达到阈值就放行并记录当前请求,否则拒绝。整个过程通常通过 Lua 保证原子性。
优点是统计准确,能保证任意连续窗口内不超限,不受固定窗口边界问题影响。缺点是每个请求都要保存一条记录,高并发时内存和 Redis 压力较大。
滑动窗口
滑动日志需要准确知道窗口内有多少请求,所以保存了每次请求的时间,空间复杂度会随请求量增长。但实际上,如果允许一定误差,我们只需要知道各个时间段内分别有多少请求。于是把时间轴按固定长度 Δ 切分。例如窗口 W = 2 分钟,令 Δ = 10 秒,一个完整窗口就包含 12 个时间桶。每个请求只增加当前桶的计数,不再保存请求 ID。
请求到达时,只增加当前桶的计数;随着时间推进,最早滑出窗口的桶被整体删除,不再参与统计。这样不需要拆分单个桶,窗口每经过 Δ 时间向前移动一次,统计结果精确到桶的粒度。基本流程如下:
1.根据当前时间计算当前桶编号及窗口内最旧的桶编号。
2.删除最旧桶编号之前的桶记录。
3.汇总当前窗口内最近 N 个桶的请求数量。
4.如果总数小于阈值,则当前桶计数加一并放行;否则拒绝请求。
下面给出具体的lua脚本:
lua
-- KEYS[1]:滑动窗口 Hash Key
-- ARGV[1]:限流阈值
-- ARGV[2]:窗口长度,毫秒
-- ARGV[3]:桶长度,毫秒
-- ARGV[4]:当前时间戳,毫秒
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local bucketMillis = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local currentBucket = math.floor(now / bucketMillis)
local bucketCount = math.ceil(windowMillis / bucketMillis)
local oldestBucket = currentBucket - bucketCount + 1
local currentSlot = tostring(currentBucket % bucketCount)
local total = 0
local currentCount = 0
local buckets = redis.call("HGETALL", key)
for i = 1, #buckets, 2 do
local bucket, count = string.match(buckets[i + 1], "^(%d+):(%d+)$")
bucket = tonumber(bucket)
count = tonumber(count)
if bucket >= oldestBucket and bucket <= currentBucket then
total = total + count
if bucket == currentBucket then
currentCount = count
end
end
end
if total >= limit then
return -1
end
currentCount = currentCount + 1
redis.call(
"HSET",
key,
currentSlot,
currentBucket .. ":" .. currentCount
)
redis.call("PEXPIRE", key, windowMillis + bucketMillis)
return limit - total - 1
Hash 的 field 是环形槽位,最多只有 bucketCount 个;value 保存 bucketId:count,用来识别这个槽位的数据是否已经过期。每次请求扫描固定数量的槽位,只统计当前窗口内的 bucket。确认未超限后,将当前桶计数加一覆盖对应槽位,并设置整体 TTL。由于整个脚本原子执行,并发请求也不会重复使用同一份额度。
模拟滑动窗口
分段滑动窗口虽然比滑动日志节省内存,但每个限流对象仍要保存 N 个桶的计数,每次判断还要读取并汇总这些桶。当限流对象数量很多、窗口划分很细或 QPS 很高时,这部分存储和计算成本仍然不可忽略。
为了不维持那么多的状态,我们可以采用数学模拟的思路,把状态进一步压缩到只保存两个计数:当前固定窗口和上一个固定窗口。它根据当前窗口已经过去的时间比例,逐步降低上一窗口请求数的影响,用两个计数近似表示一个持续移动的窗口。这样每个限流对象的状态从 N 份减少到 2 份,判断复杂度也从 O(N) 降到 O(1)。
代价是结果不再精确,需要假设请求在一个窗口内大致均匀分布。因此,它适合流量大、状态多、但可以接受少量误差的限流场景。
具体思想如下示例:
erlang
1. 先把时间划分为固定的窗口。例如窗口长度是 2 分钟:
上一窗口:[10:00, 10:02)
当前窗口:[10:02, 10:04)
2.假设请求当前发生在 10:02:30。真正的滑动窗口应当统计最近 2 分钟:
[10:00:30, 10:02:30]
这个区间包含当前窗口内的全部请求,但只包含上一窗口最后 90 秒的部分。
3.由于我们只保存了上一窗口的总请求数,不知道这些请求具体发生在什么时间,因此假设它们均匀分布。上一窗口与滑动窗口的重叠比例是: 90 / 120 = 75%
4.于是用上一窗口总数的 75%,加上当前窗口的全部请求,来模拟真实的滑动窗口:
估算请求数 = 当前窗口计数 + 上一窗口计数 × 75%
5. 随着当前时间继续前进,上一窗口的重叠比例会从 75% 逐渐降到 50%、25%,最后变成 0。这就是"模拟"的过程:用一个随时间线性衰减的旧窗口计数,近似替代无法精确获知的历史请求分布。
下面给出lua脚本的实现:
lua
-- KEYS[1]:滑动窗口 Hash Key
-- ARGV[1]:限流阈值
-- ARGV[2]:窗口长度,毫秒
-- ARGV[3]:当前时间戳,毫秒
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowStart = math.floor(now / windowMillis) * windowMillis
local state = redis.call(
"HMGET", key,
"windowStart", "currentCount", "previousCount"
)
local storedStart = tonumber(state[1])
local currentCount = tonumber(state[2]) or 0
local previousCount = tonumber(state[3]) or 0
-- 处理窗口切换
if storedStart == nil then
currentCount = 0
previousCount = 0
elseif windowStart == storedStart + windowMillis then
previousCount = currentCount
currentCount = 0
elseif windowStart ~= storedStart then
currentCount = 0
previousCount = 0
end
-- 按当前窗口进度折算上一窗口
local elapsedRatio = (now - windowStart) / windowMillis
local estimated = currentCount + 1
+ previousCount * (1 - elapsedRatio)
if estimated > limit then
return -1
end
currentCount = currentCount + 1
redis.call(
"HSET", key,
"windowStart", windowStart,
"currentCount", currentCount,
"previousCount", previousCount
)
redis.call("PEXPIRE", key, windowMillis * 2)
return math.floor(limit - estimated)
脚本先根据 now 计算当前固定窗口的起点,并读取 Hash 中保存的窗口起点、当前窗口计数和上一窗口计数。当窗口没有变化时沿用量;进入下一窗口时,把当前计数移到上一计数;跨过多个窗口时两项清零。之后计算当前窗口已经过去的比例,用 1 - elapsedRatio 作为上一窗口的衰减权重,估算包含当前请求在内的最近一个窗口请求量。未超过阈值就增加当前窗口计数、更新 Hash 并设置 TTL,返回估算剩余额度;否则返回 -1。
漏桶限流
前面介绍的固定窗口与滑动窗口,本质都是在解决一件事情:**某个时间区间内还剩多少准入额度?**回答的是"现在还能放行多少请求",而不是"请求应该以什么速度持续进入下游"。只要总请求数没有超过阈值,这些算法就不关心请求是在整个周期内均匀到达,还是在周期内某一瞬间集中到达。
例如,一分钟限流 100 次,即使采用了精确的滑动窗口,这 100 个请求仍然可以集中在几毫秒内通过。通过缩短窗口并降低阈值,可以不断缩小这种突发的时间粒度,但这本质上是用参数逼近平滑效果,同时会增加状态数量和计算成本。更重要的是,这种模型没有描述下游的处理速度、允许积压多少请求,以及积压后是继续排队还是立即拒绝。
因此,当业务只要求某个时间区间内不超过固定次数时,窗口计数已经足够。但如果系统需要明确控制请求的输出节奏,或者需要区分"暂时排队"和"直接拒绝",仅靠时间窗口计数就无法完整表达这类语义。
如果换一个角度,不再只问"当前窗口还剩多少额度",而是明确限制请求流出系统的速度,并为来不及处理的请求提供有限缓冲,就需要一种新的模型。漏桶算法正是从这个思路出发:请求先进入一个有容量上限的桶,再以固定速率流出;桶满后拒绝新请求。
漏桶可以看作一个容量有限的桶,请求从上方进入,再从桶底以固定速率流出。请求到达时,如果桶里还有空间,就进入桶中等待处理;如果桶已经满了,就拒绝该请求。下游始终按照设定的流速取走请求,因此即使上游流量存在突发,输出也会保持相对稳定。基本流程如下:
lua
水位 = max(0, 水位 - 经过时间 × 流出速率)
if 水位 < 容量:
水位 += 1
放行
else:
拒绝
下面给出具体的lua脚本:
lua
-- KEYS[1]:漏桶 Hash Key
-- ARGV[1]:桶容量
-- ARGV[2]:漏出一个请求所需时间,毫秒
-- ARGV[3]:当前时间戳,毫秒
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leakInterval = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local state = redis.call("HMGET", key, "level", "lastLeakTime")
local level = tonumber(state[1]) or 0
local lastLeakTime = tonumber(state[2]) or now
-- 根据经过时间降低水位
local elapsed = math.max(0, now - lastLeakTime)
level = math.max(0, level - elapsed / leakInterval)
lastLeakTime = now
-- 桶满,拒绝请求
if level + 1 > capacity then
return -1
end
-- 请求进入桶中
level = level + 1
redis.call(
"HSET", key,
"level", level,
"lastLeakTime", lastLeakTime
)
redis.call("PEXPIRE", key, capacity * leakInterval)
return math.floor(capacity - level)
脚本用 Hash 保存漏桶当前水位 level 和上次漏水时间 lastLeakTime。请求到达后,先根据时间差计算期间漏出了多少个请求,并相应降低水位,同时把 lastLeakTime 向后推进到最近一次完整漏水的时间。随后判断水位是否达到容量:达到容量就返回 -1;否则水位加一,表示请求进入桶中,并更新 Hash。TTL 设置为清空整个桶所需的时间,停止请求后 Redis 会自动回收状态。返回值是放行后的剩余容量。
令牌桶限流
漏桶通过固定流出速率和平滑流量保护下游,但即使系统刚刚空闲、还有处理能力,它也不会主动允许更大的突发流量,因为它关注的是持续输出节奏。但严格恒定的输出速率也可能过于保守。下游通常具备一定缓冲能力,可以承受短时间的流量突发;系统在空闲期间没有消耗处理能力,如果流量稍后集中到来,也不一定需要全部排队或拒绝。
因此,更符合实际需求的目标是:继续限制长期平均速率,同时允许短时间的流量突发,并确保突发规模有明确上限。为了表达"持续补充可用容量,并允许一定量累积"的机制,就可以引入令牌桶算法。
令牌桶维护一个令牌数量和一个最大容量,系统按照固定速率持续生成令牌;当桶满后,新生成的令牌会被丢弃。每个请求到达时需要消耗一个令牌,有令牌就放行,没有令牌就拒绝或等待。例如生成速率是每秒 10 个,桶容量是 20。系统空闲 3 秒后最多积累 20 个令牌,此时可以立即通过最多 20 个请求,之后则主要按照每秒 10 个的平均速率继续放行。基本流程如下:
lua
令牌数 = min(容量, 原令牌数 + 经过时间 × 生成速率)
剩余令牌 = 令牌数 - 1
if 剩余令牌 >= 0:
令牌数 = 剩余令牌
放行
else:
拒绝
因此令牌桶既能限制长期平均流量,也能允许规模不超过桶容量的短时突发。实际实现通常不需要由定时任务持续生成令牌,而是在请求到达时根据经过的时间计算新增了多少令牌。
下面给出令牌桶算法的lua脚本:
lua
-- KEYS[1]:令牌桶 Hash Key
-- ARGV[1]:桶容量
-- ARGV[2]:每秒生成令牌数
-- ARGV[3]:当前时间戳,毫秒
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local state = redis.call("HMGET", key, "tokens", "lastRefillTime")
local tokens = tonumber(state[1]) or capacity
local lastTime = tonumber(state[2]) or now
-- 根据经过时间补充令牌
local elapsed = math.max(0, now - lastTime)
tokens = math.min(capacity, tokens + elapsed * rate / 1000)
-- 当前请求消耗一个令牌
tokens = tokens - 1
if tokens < 0 then
return -1
end
redis.call("HSET", key, "tokens", tokens, "lastRefillTime", now)
redis.call("PEXPIRE", key, math.ceil(capacity * 1000 / rate))
return math.floor(tokens)
脚本用 Hash 保存当前令牌数 tokens 和上次补充时间 lastRefillTime。请求到达后,根据经过的时间乘以生成速率计算新增令牌,并通过 math.min 保证令牌数不超过桶容量。随后将令牌数减一,表示本次请求消耗一个令牌;如果结果为负数就拒绝请求,否则将新的令牌数和当前时间写回 Redis,并设置 TTL 便于空闲后自动清理。返回值表示放行后的剩余令牌数量。
小结
固定窗口实现简单,但存在窗口边界突发;滑动日志能够精确控制任意连续窗口,却需要保存每次请求;分段滑动窗口用时间桶降低状态数量,在精度和内存之间取得平衡;滑动窗口模拟进一步把状态压缩到两个计数,但只能基于均匀分布假设进行近似。漏桶通过固定流出速率平滑流量,适合保护处理能力稳定的下游;令牌桶则通过预先生成并积累令牌,在限制长期平均速率的同时允许有上限的短时突发。
因此,限流算法没有绝对优劣。只需要简单限制且能接受边界突发时,可以选择固定窗口;要求精确控制时,使用滑动日志;希望减少状态时,可以选择分段窗口或双窗口模拟;更关注平滑输出时选择漏桶;需要兼顾平均速率和突发能力时,令牌桶通常是更常见的选择。在 Redis 实现中,还需要利用 Lua 保证状态判断和更新的原子性,并通过 TTL 清理空闲状态。