四种限流算法实测对比:固定窗口、滑动窗口、漏桶、令牌桶(附 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?答案会直接指向算法。

相关推荐
橙序员小站11 分钟前
Windows 安装包反复报错?一次排查补上了 5 个 CI 检查点
后端
陈随易15 分钟前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
SimonKing27 分钟前
震惊:怎么回事,opencode一轮对话耗尽免费额度
java·后端·程序员
吴建旭 智宅焕1 小时前
智能家居全国交付中报价工具与交付基础设施的架构分离设计:接口化路径与交付确定性系统实现
架构·智能家居
张彦峰ZYF1 小时前
功能齐了不等于能上线:智能工单系统的AI回归测试、安全防护、成本可观测与CI/CD生产部署
后端·企业智能工单系统·评测线·安全线·成本线·部署线
Rain的Java大神之路1 小时前
LeetCode 37 解数独面试攻略:回溯 + 位掩码 + MRV,从暴力到毫秒级求解
java·后端·leetcode
你看我名字1 小时前
产品参数昨天还对,今天已过期:数眼智能与大模型应用如何做资料版本校验
开发语言·架构
量化分析码农1 小时前
【Python量化波动率实战 05】你的组合真的分散了吗:滚动相关与分散化收益
后端
xbzb1 小时前
Docker 架构与部署:一条 docker run 到底走了三层
docker·容器·架构·镜像·加速·daemon
IT_陈寒2 小时前
Vue这个奇葩的响应式问题让我加班到凌晨
前端·人工智能·后端