后端接口限流算法全解析:计数器到令牌桶的工程实践

发布时间:2026/9/19 4:03:08
后端接口限流算法全解析:计数器到令牌桶的工程实践 只要你做过后端接口几乎都遇到过这种场景线上某个接口的 QPS 突然从几百冲到几千数据库连接池瞬间被打满前端重试再叠加一层流量服务直接雪崩。这时候最先能救命的往往是限流。我自己在搜“限流策略”相关资料的时候发现搜索联想里跳出不少 verilog 计数器、PLC 计数器这类内容因为“计数器”这个词在硬件领域太重了。不过这里要讨论的是纯软件层面的限流算法——计数器、滑动窗口、漏桶、令牌桶它们看起来都是“控制流量速率/总量”的套路但各自的适用场景、边界条件、工程落地差异非常大。这篇文章会把这四种限流策略逐个拆开给出能直接运行的实现代码再讲清楚实际业务里怎么选型、会踩到哪些坑适合正在做接口防护、网关限流或者想把限流组件做得更扎实的后端开发同学参考。1. 限流策略的全局观先想清楚限什么再决定怎么限1.1 限流不是在“麻烦用户”而是在保护系统可用性很多新手一开始会把限流理解成“拒绝请求”的粗暴手段其实限流的本质是做容量规划。任何一个系统都有处理上限数据库连池有上限、下游第三方接口有 QPS 上限、消息队列有消费速率上限。当流量超过系统能承载的阈值与其让整个服务被拖垮不如在入口处主动丢弃或延迟一部分请求。这个“主动丢”就是限流策略的价值所在。实际项目里限流维度通常有几种按接口维度某个 URL 每秒最多 1000 次、按用户维度单用户每秒最多 10 次、按 IP 维度单 IP 每分钟最多 60 次、按全局限额整个集群每秒最多 5000 次。选算法之前先问自己一个问题你是在保护数据库连接池还是在保护第三方接口不被你打爆又或者只是防止爬虫刷接口不同的保护目标直接决定算法选型。1.2 四种算法的核心差异就三个点精度、内存、突发容忍你在网上能看到很多关于限流算法的文章篇幅都花在原理和代码上但很少有人把对比维度总结清楚。我习惯这样记忆这四种算法计数器固定窗口实现最简单但窗口边界有突刺可能瞬间打到两倍阈值。滑动窗口窗口边界平滑精度高代价是更复杂或更高内存。漏桶出口速率恒定像水龙头滴水严格平滑流量但无法应对突发。令牌桶按速率“攒令牌”允许一定程度的突发是最多二进制系统中默认选型。用一句话概括如果系统需要严格控制流出速率选漏桶如果需要容忍突发流量但又不想让系统超载选令牌桶如果只是临时限制总量、不在乎边界抖动固定窗口够用如果既要平滑边界又不想引入复杂的令牌补充机制滑动窗口是最直观的方案。2. 计数器与滑动窗口从“简单粗暴”到“边界平滑”2.1 固定窗口计数器最直白的限流实现也是最多人最早写的版本固定窗口计数器的思想很简单把时间切成长度固定的窗口例如 1 秒每个窗口内维护一个计数器。请求进来计数器加一超过阈值就拒绝窗口结束计数器清零。用 Python 写一个带锁的单机版本大概是这样的import threading import time class FixedWindowCounter: def __init__(self, limit: int, window_seconds: int): self.limit limit self.window_seconds window_seconds self.window_start time.time() self.count 0 self.lock threading.Lock() def allow(self) - bool: now time.time() with self.lock: if now - self.window_start self.window_seconds: self.window_start now self.count 0 self.count 1 return self.count self.limit这个实现看着没问题但它在高并发下有一个经典缺陷边界突刺。假设限流阈值是 5000 次/秒在 00:00:00.999 到 00:00:01.001 这 2 毫秒内上一个窗口可能已经用了 5000 次下一个窗口重新计数又放进来 5000 次。短时间实际进入的流量能达到 10000 次虽然没有超过单个窗口的阈值但已经翻了一倍。对后端资源来说这 10000 次请求可能在 2 毫秒内打过来数据库照样顶不住。如果只是限制总调用量、对瞬时突发不敏感固定窗口还是可以用的尤其是在 Redis 里用INCR EXPIRE实现很简单。但它很难作为精确限流方案放在生产环境的流量入口。2.2 滑动窗口日志法把每个请求时间记下来精确但费内存滑动窗口要解决固定窗口的边界问题思路是窗口不是“固定格子”而是“跟着当前时间滑动的区间”。最直接的实现是滑动窗口日志法——记录窗口内每一个请求的时间戳新请求到达时把窗口范围之外的旧时间戳删掉再统计数量。import time from collections import deque class SlidingWindowLog: def __init__(self, limit: int, window_seconds: int): self.limit limit self.window_seconds window_seconds self.timestamps deque() def allow(self) - bool: now time.time() while self.timestamps and now - self.timestamps[0] self.window_seconds: self.timestamps.popleft() if len(self.timestamps) self.limit: self.timestamps.append(now) return True return False这个算法胜在精度高窗口内任何时刻的流量都不会超过阈值边界突刺被完全削平。代价是内存和删除开销如果接口 QPS 很高比如每秒 10 万次那就得在内存里维护 10 万个时间戳还要频繁popleft堆内存和 GC 压力都不小。工程实践里滑动窗口日志法通常用 Redis 的 ZSET 来做分布式版本。把时间戳作为 scoremember 用请求唯一 ID每次请求前删除窗口外的成员再统计数量。这个方案我会在第 4 部分给出 Lua 脚本先不展开。2.3 滑动窗口计数器分桶聚合内存和精度的折中方案滑动窗口日志法的痛点在于请求级记录太细。那能不能把窗口内再切分成若干小格子每个格子只维护一个计数滑动时只淘汰最老的格子这就是滑动窗口计数器也是很多成熟限流组件比如 Sentinel默认使用的方式。import threading import time class SlidingWindowCounter: def __init__(self, limit: int, window_seconds: int, bucket_num: int 10): self.limit limit self.window_seconds window_seconds self.bucket_num bucket_num self.bucket_seconds window_seconds / bucket_num self.buckets {} self.lock threading.Lock() def allow(self) - bool: now time.time() bucket_key int(now // self.bucket_seconds) valid_start now - self.window_seconds with self.lock: for key in list(self.buckets): # 如果这个桶完全落在窗口外就删掉 if key * self.bucket_seconds self.bucket_seconds valid_start: del self.buckets[key] total sum(self.buckets.values()) if total self.limit: return False self.buckets[bucket_key] self.buckets.get(bucket_key, 0) 1 return True这个实现里一个 60 秒的窗口分成 10 个 6 秒的桶统计时只累计最近 60 秒内所有桶的数量。相比日志法内存开销从“按请求数量”降为“按桶数量”但精度变差如果 6 秒的桶里第一个毫秒就打满了剩余时间该窗口还能继续放别的桶的请求所以滑动窗口计数器的精度受桶大小影响。实现方式精度内存开销复杂程度固定窗口计数器低边界突刺极低最低滑动窗口日志法极高高随请求量增长中滑动窗口计数器分桶高取决于桶数低固定桶数中工程上的经验是如果对边界精度要求没那么苛刻分桶粒度小一点比如 1 秒一个桶通常足够用了没必要真的去记录每个请求。3. 漏桶与令牌桶从“强制匀速”到“允许突发”3.1 漏桶一个自带“匀速排水阀”的缓冲池漏桶这个名字我在很多地方解释过想象一个桶底部有个小洞水以恒定速率从洞里漏出。无论你往桶里倒水多快漏出的速率始终不变桶满了再来水就会溢出。对应到限流就是请求进入队列缓冲处理线程按固定速率取请求执行队列满了直接丢弃新请求。一个简单的“水位式”漏桶实现如下import threading import time class LeakyBucket: def __init__(self, capacity: int, leak_rate: float): self.capacity capacity self.leak_rate leak_rate # 每秒漏出的请求数 self.water 0.0 self.last_time time.time() self.lock threading.Lock() def allow(self) - bool: now time.time() with self.lock: elapsed now - self.last_time self.water max(0.0, self.water - elapsed * self.leak_rate) self.last_time now if self.water self.capacity: self.water 1 return True return False注意这个实现是“丢弃式”漏桶请求进来桶有余量就立刻放行否则丢弃。它没有让请求排队等待出队排队的延迟被略过了。严格意义上的漏桶应该有一个 FIFO 队列请求先进队然后由调度器按固定速率取出执行。但工程上直接维护队列的话响应时间会变得不可控——队列一旦堆积请求等了几秒才发现超时体验很差。所以很多系统更愿意用上面这种“桶满即丢”的水位模型至少行为可预期能进就进不能进直接告诉调用方。漏桶真正有用的场景是保护下游的“脆弱资源”。比如你要把业务数据异步写入数据库数据库的写入能力是每秒 200 次写多了会锁竞争严重。这时候用漏桶把写入速率压到 200/秒比任何令牌桶都合适。3.2 令牌桶攒令牌的售票窗口允许短时冲一波令牌桶和漏桶看起来都是“桶 速率”但逻辑刚好反着漏桶控制的是“出去的水”令牌桶控制的是“进来的令牌”。系统按固定速率往桶里放令牌桶的容量限制令牌最大堆积量。请求进来先拿一个令牌拿到就放行拿不到就拒绝。因为令牌可以攒着之前低流量时期攒下的令牌在突发流量到来时可以一次性用完这就能容忍一定程度的突发。import threading import time class TokenBucket: def __init__(self, rate: float, capacity: int): self.rate rate # 每秒生成令牌数 self.capacity capacity # 桶容量 self.tokens float(capacity) # 初始桶满 self.last_time time.time() self.lock threading.Lock() def allow(self) - bool: now time.time() with self.lock: self.tokens min(self.capacity, self.tokens (now - self.last_time) * self.rate) self.last_time now if self.tokens 1: self.tokens - 1 return True return False这个代码块的核心逻辑就三行先用当前时间差补发令牌再判断令牌是否足够足够就消费一个。capacity参数很关键它代表“零流量一段时间后最多能攒下多少令牌”。假设rate1000, capacity500长期空闲后桶里有 500 个令牌突发流量到来时第一瞬间可以放行 500 个请求然后每秒再生 1000 个令牌理论上能支撑的最强脉冲是“500 1000×时间”。对比漏桶最大区别在于漏桶强制恒定出队令牌桶允许大于平均速率的突发只要持续时间不长。这也解释了为什么网关和服务接口默认偏好令牌桶——业务请求天然有毛刺不能每次请求都慢吞吞等匀速排队。3.3 漏桶和令牌桶的选型对比维度漏桶令牌桶输出速率恒定允许突发实现语义队列/水位 固定漏出令牌补充 桶容量适合场景保护数据库/下游脆弱依赖API 网关、业务接口响应延迟高流量时有排队延迟低令牌不足直接拒绝参数含义capacity队列容量leak_rate流出速率rate生成速率capacity最大突发不少人会混淆这两个算法我用一个生活化类比帮自己记忆漏桶像是一个出水口很小的漏斗哪怕你整盆水泼上去下面也是一滴一滴流令牌桶像是游乐园的售票窗口每分钟放 100 张票进场但票可以攒着人少的时候攒了几百张来了一辆大巴也能让整车人一起进去最多只能进“攒票上限”那么多人。4. 分布式限流落地从单机代码到 Redis Lua 的工程细节4.1 单机限流与分布式限流的本质区别上面几段给的都是单机实现锁用的是进程内锁。单机限流的问题很明显如果你部署了 3 个实例每个实例限制 1000 QPS整个集群的真实上限是 3000 QPS而不是 1000。反过来如果下游数据库在集群这一侧你应该限的是集群总量而不是单机量。解决分布式限流的标准做法是引入 Redis 这样的中心化存储。所有实例共享同一个计数或者令牌状态用 Redis 的原子操作保证并发安全。这样一来限流从“进程内”升级为“全局限流”代价是多一次网络 RTT以及在极端情况下 Redis 变成了新的瓶颈。所以高并发场景里通常做两级限流网关层走 Redis 全局限流服务本地再放一个单机限流兜底避免 Redis 抖动时本地完全失控。4.2 用 Redis Lua 实现分布式令牌桶Redis 实现令牌桶最稳妥的方式是 Lua 脚本。因为 Redis 从 2.6 开始保证 Lua 脚本内所有命令原子执行中间不会插入其他命令。以前有人用GET SET EXPIRE三段式写限流高并发下必然出现竞态因为两个请求可能同时读到同一个旧值然后各自计算、覆盖限流就被穿透了。用 Lua 把这些操作包在一起才能保证“读令牌、补令牌、扣令牌”这一连串操作是对外不可分割的。-- KEYS[1]令牌桶当前令牌数 -- KEYS[2]令牌桶上次更新时间 -- ARGV[1]每秒生成令牌数 rate -- ARGV[2]桶容量 capacity -- ARGV[3]当前时间戳 now -- ARGV[4]本次请求需要令牌数 requested local tokens tonumber(redis.call(get, KEYS[1]) or ARGV[2]) local last tonumber(redis.call(get, KEYS[2]) or ARGV[3]) local delta now - last if delta 0 then delta 0 end tokens math.min(tonumber(ARGV[2]), tokens delta * tonumber(ARGV[1])) redis.call(set, KEYS[1], tokens) redis.call(set, KEYS[2], ARGV[3]) if tokens tonumber(ARGV[4]) then redis.call(set, KEYS[1], tokens - tonumber(ARGV[4])) return 1 else return 0 end使用这个脚本的 Python 端代码import redis import time client redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) lua_script -- 上面的 Lua 代码 token_bucket_lua client.register_script(lua_script) def token_bucket_allow(bucket_key: str, rate: int, capacity: int, requested: int 1) - bool: result token_bucket_lua( keys[ftb:{bucket_key}, ftb:{bucket_key}:ts], args[rate, capacity, time.time(), requested] ) return bool(result)这里有几个工程细节需要注意。第一同一类限流 key 最好设计成能落同一个 Redis 哈希槽尤其是使用 Redis Cluster 时可以用{bucket_key}这种 hash tag 语法让两个 key 强制落到同一个槽否则集群模式下 Lua 脚本访问跨槽 key 会报错。第二第一次执行时last会初始化为当前时间tokens初始化为capacity也就是桶一开始是满的。如果你不想让系统刚启动就被突发流量打满可以把初始化值改成0或者capacity * 0.5。第三Lua 脚本内部不要用math.random()这类不可重复的随机函数分布式环境可能导致脚本在不同节点上执行结果不一致影响主从复制。4.3 固定窗口与滑动窗口的 Redis 版本如果你的业务对精度要求不高用 Redis 做固定窗口限流是最省事的import time def fixed_window_allow(bucket_key: str, limit: int, window_seconds: int) - bool: window_id int(time.time()) // window_seconds key ffw:{bucket_key}:{window_id} count redis.incr(key) if count 1: redis.expire(key, window_seconds 5) # 多保留几秒避免边界 key 瞬间失效 return count limit这段代码的思路是把时间戳归一化成窗口编号用INCR自增计数首次创建时顺带设置过期时间。加 5 秒过期时间是为了避免窗口刚切换上一个窗口的 key 立刻被删除导致统计问题。虽然固定窗口有边界突刺但如果是限制“每分钟最多 60 次”这种低频、非突发场景这个写法完全够用而且性能极好。滑动窗口日志法在 Redis 里的标准实现是用 ZSET-- KEYS[1]滑动窗口 ZSET key -- ARGV[1]当前时间戳 now -- ARGV[2]窗口大小 window_ms -- ARGV[3]窗口内最大请求数 limit -- ARGV[4]请求唯一 ID redis.call(zremrangebyscore, KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2])) local count redis.call(zcard, KEYS[1]) if count tonumber(ARGV[3]) then redis.call(zadd, KEYS[1], ARGV[1], ARGV[4]) redis.call(expire, KEYS[1], math.ceil(tonumber(ARGV[2]) / 1000) 1) return 1 end return 0ZSET 的 score 存时间戳member 存请求唯一 ID。每次请求先清理窗口范围外的元素再统计数量。这里 member 一定不能只用时间戳因为同一毫秒可能有很多请求相同 member 会互相覆盖。我一般用 UUID 或者业务侧生成的唯一 requestId 做 member。这种实现的精度非常高但代价是内存随 QPS 增长。我见过一个订单接口用 ZSET 做滑动窗口平时 QPS 不高还好大促期间每秒几万请求Redis 内存肉眼可见地上涨。所以高流量场景我通常不推荐 ZSET 日志法更建议用分桶的滑动窗口计数器把时间戳都聚合到秒级 bucket内存占用可控得多。4.4 容量规划rate、capacity 和实例数怎么定很多同学拿到令牌桶代码却不知道怎么填参数。这里我给一个实际例子。假设你的数据库只能承受 800 QPS但业务高峰入口会有 2000 QPS 的流量而你的服务部署了 2 个实例每个实例能独立扛 400 QPS但全局不能超过 800。用 Redis 全局令牌桶时可以设置rate800表示长期速率上限 800 次/秒capacity需要根据业务容忍的突发量来定。如果允许瞬间 1200 的突发持续 1 秒后就回到 800那capacity400就够了因为桶初始可能积满 400 个令牌第一秒最多可以消耗 800新生成的 400积攒的 1200。如果业务连 1200 的初始突发都不想给就把初始令牌值设成 0或者把 capacity 调小。容器化部署的容量规划还要注意扩缩容对限流的影响。如果按“每实例 rate300”配置单机限流实例从 2 个扩到 5 个总量就从 600 涨到 1500下游不一定扛得住。扩容后最好同步调整限流参数或者始终使用全局限流作为最终防线。5. 限流落地中的高频踩坑与排查经验5.1 窗口边界突刺引发的“双倍流量”事故有个项目曾经用固定窗口限流保护一个报表查询接口阈值是每分钟 600 次。平时没事但运营人员集中在整点查看数据监控发现某个瞬间 DB 慢查询暴涨查了半天才发现是固定窗口的边界问题59 秒的时候窗口内已经进去 600 次下一秒新窗口又放进来 600 次两拨请求几乎同时在整点的前 1 秒和后 1 秒打到数据库。排查思路很简单看监控里流量走势如果峰值总是出现在分钟或秒的整数边界而且宽度很尖基本就是固定窗口的边界突刺。解决方式要么换成滑动窗口要么换成令牌桶。半吊子方案是把固定窗口的窗口切小比如从 60 秒切成 30 秒但这只是把问题变得更频繁并没有根治。5.2 Redis 限流为什么偶尔“穿透”很多人在 Redis 里实现限流时用的是“先GET再SET”的逻辑读到当前计数判断没超限然后自增。这种写法在并发稍高一点就会出现穿透因为两个请求同时读到 99都以为自己是第 100 个然后都放行。排查这种问题先看你的实现里读和写是不是原子操作。单命令INCR本身是原子的但“读旧值 判断 自增”不是。正确做法是把这三个动作塞进 Lua 脚本或者用INCR的结果做判断——INCR返回的是自增后的值直接拿它和阈值比较就能避免“读到旧值”的竞态。固定窗口那段代码之所以安全正是因为它只用了一个INCR命令。另一个容易忽略的点是 Redis 主从切换。如果 Lua 脚本在主节点执行后还没来得及同步主节点宕机从节点变成主节点它的计数器可能还是旧值限流同样会短暂失效。对这种极端情况通常靠业务容忍或依赖更可靠的外部限流组件单靠 Redis 很难彻底解决知道有这个边界就行。5.3 令牌桶的“冷启动打满”和漏桶的“排队延迟”用令牌桶时最容易踩的坑就是接口刚启动桶是满的第一波流量直接全部放进来。我在上一节的代码里初始化self.tokens float(capacity)这在多数业务场景下其实是理想情况——系统刚上线希望尽可能承接流量。但如果限流目的是保护资源这个初始满桶就需要改掉。Guava 的RateLimiter甚至专门提供了warmup模式让令牌桶从低速率逐步爬升到目标速率就是为了应对冷启动时数据库连接池还没完全建立、JIT 还没预热的情况。漏桶的问题则恰恰相反它常被诟病“请求延迟过高”。严格漏桶把请求排到队列里再匀速取出一旦流量超过漏出速率队列就会积压请求等待时间迅速拉长。有一次我们用一个漏桶保护报表导出接口限制每秒最多生成 5 个导出任务结果用户点击导出后要等几十秒才看到文件生成产品侧直接投诉。后来改成“桶满即丢”的水位模型用户遇到繁忙时前端提示稍后重试反馈反而更好。在大多数用户交互场景里快速的失败反馈比长时间排队更让人容易接受。5.4 限流后的重试风暴限流本身不会造成雪崩但限流后的客户端重试会。如果接口返回 429Too Many Requests调用方没有做退避而是立刻重试那么这个重试流量会被限流器识别成新的正常流量重试几次就把限流器的配额占满了真正该进入的业务请求反而进不来。更严重的是多级调用链上每一层都重试流量会被指数放大。处理办法有两个层面。客户端层面遇到 429 应该做指数退避并加入随机抖动而不是固定间隔重试。服务端层面可以在响应头里带上Retry-After字段告诉调用方多久之后再试。还有一个比较隐蔽的点限流器本身不要对重试请求网开一面否则等于给重试开了一扇后门。我见过有同学在网关层判断“带了重试标记就放行”结果被调用方当成绕过限流的口子直接把下游打挂。5.5 问题速查表现象可能原因建议限流生效但接口还是被打挂只做了单机限流没限制集群总量引入 Redis 全局限流监控峰值总在窗口边界固定窗口边界突刺换滑动窗口或令牌桶Redis 限流并发下偶尔超阈值GET/SET 非原子操作改为 INCR 单命令或 Lua 脚本令牌桶刚上线就遭流量打穿初始令牌满桶初始化值为 0 或用 warmup 模式漏桶限流后延迟升高请求排队积压改丢弃式漏桶或调大容量限流后下游压力反而更大客户端重试风暴增加指数退避和随机抖动我个人在实际项目里的选型心得很明确只要不是保护数据库这种要求严格匀速的场景默认先上令牌桶因为参数直观、支持突发而且 Guava、Redis 都有成熟实现不用自己造。滑动窗口计数器适合做精细化控制内存可控、边界也平滑Sentinel 默认就是这种策略。固定窗口我只用在低频、简单限制的场景里。算法之间没有绝对高下关键是先认清自己到底要保护什么再去选匹配的方案别一上来就套一个“最流行”的限流组件。