四种限流算法实测对比:固定窗口、滑动窗口、漏桶、令牌桶(附 Redis+Lua 实现)

限流阈值设成 100 QPS,可下游还是被打到 200。这不是阈值写错了,是算法选错了。

这篇对比四种限流算法的实际表现,给出分布式场景下的 Lua 实现,以及一张按业务场景选型的对照表。

一、阈值设了 100,实测峰值 200

一个典型事故:接口按 100 QPS 限流,监控上平均 QPS 确实是 100,看起来一切正常。但下游服务仍然报超时。

翻秒级监控才发现,特定时刻的瞬时 QPS 能到 180 以上------就在每个整秒的边界上。

原因是用了最简单的固定窗口计数:每秒一个计数器,超过 100 就拒绝。问题在于,第 59.9 秒打了 100 个请求,第 60.1 秒又打了 100 个,跨窗口的这 0.2 秒里下游承受了 200 个请求。

限流算法的价值不在于「平均值对不对」,而在于限制瞬时峰值。这四种算法在这件事上差别很大。

二、四种算法的原理差异

固定窗口(计数器)

每个时间窗一个计数器,窗口切换时清零。实现最简单,一个 INCR 加过期时间就够。

缺陷就是上面那个临界问题:窗口边界处允许两倍突发。

滑动窗口

把时间轴切成若干小格,统计「最近一个窗口」内的总量。有两种实现:

  • 日志式:记录每个请求的时间戳,统计最近 1 秒内的条数。最精确,但每个被限流的 key 要存 O(limit) 个时间戳,高并发下内存吃不住。
  • 格子式:把 1 秒切成 10 格,每格 100ms,统计最近 10 格的总和。内存只有 O(10),精度取决于格数。

格子式是工程上最常用的折中,精度损失通常在 10% 以内。

漏桶

请求先进桶排队,桶以固定速率漏出(处理)。桶满了就拒绝。

核心特征是输出绝对匀速。突发流量会被削平,代价是排队延迟------突发时请求要等。

令牌桶

桶里以固定速率放令牌,请求来了取一个令牌,取不到就拒绝。

和漏桶的关键区别:令牌桶允许突发。桶容量是 200、速率是 100/秒时,一个安静的桶里攒了 200 个令牌,突然来 200 个请求能全部放行(只要令牌够)。而漏桶会把这 200 个请求排成 2 秒慢慢放。

所以:要削峰用漏桶,要允许突发用令牌桶。

三、实测对比

测试条件:限流阈值 100 QPS,注入 200 QPS 持续 10 秒,统计实际放行量。

算法 配置 平均放行 QPS 瞬时峰值(1 秒内) 突发容忍 内存开销
固定窗口 100/秒 约 100 180-200(窗口边界) 无 O(1)
滑动窗口·日志 100/秒 约 100 约 105 无 O(limit)
滑动窗口·格子 100/秒 / 10 格 约 100 约 120 无 O(10)
漏桶 100/秒,桶容 50 约 100 约 100(严格匀速) 排队 O(1)
令牌桶 100/秒,桶容 200 约 100 约 200(容量内) 是 O(1)

数据为压测量级示意,你的实际数值取决于实现细节与时钟精度。

两个结论:

  1. 平均 QPS 全部达标。所以只看平均值,你根本发现不了固定窗口的问题。
  2. 瞬时峰值差别巨大。固定窗口和令牌桶都会允许接近两倍峰值,但原因完全不同------固定窗口是缺陷,令牌桶是设计意图。

四、令牌桶的实现

单机版足够应对大多数场景:

python 复制代码
import time

class TokenBucket:
    """令牌桶:速率控制 + 容量内允许突发。
    非线程安全,多线程场景需在 consume 外层加锁。"""

    def __init__(self, rate: float, capacity: int):
        self.rate = rate              # 每秒补充的令牌数
        self.capacity = capacity      # 桶容量,决定最大突发
        self.tokens = float(capacity)
        self.last = time.monotonic()

    def consume(self, n: int = 1) -> bool:
        now = time.monotonic()
        # 按经过时间补充,注意上限是容量
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

两个易错点:

第一,补充令牌必须 min 到容量。 漏了这个 min,长时间空闲后桶里会攒出巨量令牌,一次全放行,等于没有限流。

第二,时间源要用单调时钟。 time.time() 会被系统时间调整影响,NTP 校时回拨时可能出现负增量,导致令牌数变成负数。

五、分布式限流:为什么必须用 Lua

单机令牌桶在集群里会失效------10 个实例各自限 100 QPS,实际总放行就是 1000 QPS。

分布式限流要把状态放到 Redis,但不能用「读出来算完再写回去」,因为读和写之间不是原子的,高并发下会超发。正确做法是 Redis + Lua,整个逻辑在服务端原子执行。

ini 复制代码
-- KEYS[1]: 限流 key
-- ARGV[1]: rate      每秒补充令牌数
-- ARGV[2]: capacity  桶容量
-- ARGV[3]: now       当前毫秒时间戳
-- ARGV[4]: requested 本次请求的令牌数
local key      = KEYS[1]
local rate     = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local data    = redis.call('HMGET', key, 'tokens', 'ts')
local tokens  = tonumber(data[1])
local last    = tonumber(data[2])

if tokens == nil then
  tokens = capacity
  last   = now
end

-- 按毫秒差补充令牌,上限为容量
local delta = math.max(0, now - last) / 1000.0
tokens = math.min(capacity, tokens + delta * rate)

local allowed = 0
if tokens >= requested then
  tokens = tokens - requested
  allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
-- 过期时间设成「装满一桶所需时间」的两倍,避免冷 key 常驻
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return allowed

三个细节值得单独说:

现在时间由调用方传进来。 不在 Lua 里取 TIME,是为了让多台应用服务器的时钟判断保持一致------虽然 Redis 的 TIME 也行,但传参能让测试更好写。

delta 用 math.max(0, ...)。 防止时钟回拨导致令牌减少。

过期时间是算出来的。 固定写 60 秒会让冷 key 堆积;写成「装满一桶时间 × 2」既够用又能自动清理。

六、按场景选型

场景 推荐 理由
保护下游数据库 漏桶 下游需要匀速,排队可接受
对外开放 API 计费配额 令牌桶 客户会攒着用,要允许短时突发
秒杀/抢购入口 滑动窗口·格子 不允许窗口边界双倍,精度要求高
内部服务间调用 令牌桶 实现简单,允许合理突发
单机简单防护 固定窗口 够用,但要知道临界问题存在

一个务实建议:如果你的限流目的是保护下游,选漏桶或令牌桶(容量小);如果目的是公平分配配额,选令牌桶(容量大);固定窗口只适合「大概限一下」的场景,不要用它保护关键链路。

七、检查清单

  • 限流算法与业务目标匹配(削峰 / 允许突发 / 公平配额)
  • 令牌补充有 min(capacity) 上限,不会攒出巨量令牌
  • 时间源用单调时钟,能容忍时钟回拨
  • 分布式场景用 Redis + Lua,不是「读-算-写」三步
  • 限流 key 有合理过期时间,冷 key 会清理
  • 限流命中率有监控,能看出是正常限流还是配置过严
  • 被限流的请求有明确响应(429 + Retry-After),不是静默丢弃
  • 集群总配额 = 单实例配额 × 实例数 这件事已确认过
  • 压测时看过秒级监控,不是分钟级平均值
  • 限流阈值有变更流程,不是改代码重新发布

八、一句总结

限流的坑不在于算法难写,而在于你以为限住了,其实只是平均值被限住了。

四个算法的代码量差不多,差别全在边界行为上。选之前先问自己一句:我要保护的下游,怕的是持续的 100 QPS,还是瞬间的 200 QPS?答案会直接指向算法。

相关推荐
王中阳Go1 小时前
面到 harness 架构就卡住的人,多半是做过但没这么叫过
架构·go·agent
智驭未来掌门人1 小时前
雷达架构梳理:那些容易混淆的边界与分层
后端
用户9888321871741 小时前
别把一次性任务写成常驻 Worker:从 .NET 托管服务的生命周期说起
后端
韩振方1 小时前
Kubernetes StatefulSet 让每个副本找回自己的存储
后端
小卿噢1 小时前
把连接池从 4 调到 128,吞吐掉了 60%
后端
yunwei371 小时前
eBPF 示例:使用 CUPTI 构建 GPU 火焰图分析器
linux·后端·性能优化
网腾无限1 小时前
多智能体编排的长事务翻车实录:重试风暴、重复扣款与补偿失效怎么治
后端
吱吱鼠1 小时前
四、搞懂文件存储管理:多级索引寻道、位示图秒级分配与访问控制体系
架构
Lyy1 小时前
DevOps平台 — 第十六篇:动态的设计与实现
后端·devops