高并发系统如何保护自己:限流算法原理、实现与选型

为什么需要限流?

​ 在突发流量、热点事件或恶意请求面前,再稳健的系统也有容量上限。如果没有限流,短时间涌入的请求会迅速耗尽线程、连接和数据库资源,最终导致本可正常处理的请求一起失败。

​ 假设某电商平台在零点开启限量秒杀:平时接口只有几千 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 清理空闲状态。

相关推荐
鶴哥只手遮天1 小时前
企业级 .NET 领域驱动框架深度解析-第 1 章 全景图:一套框架的七条脊柱
后端·架构·领域驱动设计
小满zs1 小时前
Go语言第十四章(文件操作)
后端·go
打工仔折腾 AI1 小时前
Linux 网络编程:从零掌握 TCP Socket 编程与 C++ 实战落地
linux·网络·人工智能·后端·python·tcp/ip·性能优化
沙蒿同学1 小时前
Wails v3 实战:一个桌面提醒客户端,从 v2 升到 v3 的全过程
前端·后端·github
aibo8603081 小时前
基于 Spring Boot + Vue3 的爱心众筹管理系统设计与实现
java·spring boot·后端
SimonKing1 小时前
OpenCode免费模型直接给`Workbuddy`用,一键导入
java·后端·程序员
我命由我123452 小时前
Python 开发 - Python 项目部署(Windows 与 Linux)
java·linux·windows·后端·python·java-ee·python3.11
aibo8603082 小时前
基于 Spring Boot + Vue3 的餐饮食材采购与成本分析系统设计与实现
java·spring boot·后端
Rain的Java大神之路2 小时前
面了上百人,我最爱问的LeetCode 1「两数之和」:能背代码的很多,能讲清“先查后存”的没几个
java·后端·算法