
上周面试,我问候选人:“Nginx 的 limit_req 是哪种限流算法?”
他答得很标准——漏桶。教科书上就是这么写的。
我继续追问:“那 burst 参数为什么在?加了 nodelay 之后行为还是漏桶吗?”
他想了一会儿,没答上来。
我不觉得这是他的问题。三种限流算法——令牌桶、漏桶、滑动窗口——几乎每个后端工程师都能背出来。但一问到具体实现细节,大部分人就开始打结。不是因为不够努力,是因为这三种算法的"标准答案"和生产实现之间,有一条很少有人讲清楚的鸿沟。
我自己也是读了 Nginx 源码之后才意识到的。教科书里那个干净的理论模型,跟线上跑的那段代码,中间差了不止一个参数。然后我又去算了一下滑动窗口的内存开销,再看了分布式令牌桶的并发问题——三个误解串起来就是一条线。
今天把三个最常见的误解拆开讲。每个误解我都跑了代码或算了数据,不是嘴上说"有偏离"就完了——你会看到实测输出、内存表格、并发测试结果。看完之后,面对一个具体的业务场景,你至少知道从哪几个维度做取舍,而不是停在"看情况"三个字上。
三个误解分别是:Nginx 的 limit_req 不是你以为的漏桶、滑动窗口的"精确"有惊人的内存代价、令牌桶在分布式下一点都不简单。
本文聚焦算法选型。限流 Key 设计(粒度选择、CGNAT 下 IP 聚合等)是另一个大话题,这里不展开。下文的代码是核心逻辑简化表达。

一、Nginx 真的是漏桶吗?
我也曾这么以为,直到我去读了 ngx_http_limit_req_module.c 的源码。
教科书说漏桶的核心特征是匀速:不管上游来了多少请求,下游永远以固定速率处理。超出速率的请求全部排队等待。这个模型的价值在于——无论入口流量多疯狂,出口永远平稳。对下游友好。
问题是——当你在 Nginx 配了 limit_req zone=api burst=5 nodelay 之后,行为完全不是"匀速排队"。它会把 burst 容量内的请求全部立即放行,超出的直接拒绝。这跟漏桶的"平滑输出"完全是两回事。
我跑了一组实测
用 Go 写了两个版本对比。纯漏桶严格匀速,Nginx 风格的 burst+nodelay 模式。喂同一段突发流量——10 个请求在 50ms 内密集到达,rate 限制是 2 请求/秒。
纯漏桶的核心逻辑:每个请求必须等到上一个请求过了 500ms 间隔才能通过。没有"提前消费"的概念,来早了就排队。
// 核心逻辑简化(伪代码)
// 纯漏桶:到了就排队,固定间隔逐个放行
interval := time.Second / time.Duration(rate) // 500ms
next := lastTick.Add(interval)
if time.Now().Before(next) {
return false, next.Sub(time.Now()) // 限速等待
}
Nginx burst+nodelay 的核心逻辑:维护一个令牌桶(容量=burst),有令牌就立即放行,没令牌直接拒绝——不排队。
// 核心逻辑简化(伪代码)
// Nginx-like:桶里有令牌就放,没有就拒绝
tokens += elapsed * rate
if tokens > float64(burst) { tokens = float64(burst) }
if tokens >= 1 {
tokens--
return true // 立即通过
}
return false // 直接拒绝,不排队
实测结果对比(配置:rate=2r/s, burst=5, 10 个请求突发到达):
| 请求序号 | 纯漏桶(rate-check 模式) | Nginx burst+nodelay |
|---|---|---|
| 1 | 限速等待 500ms | 立即通过 |
| 2 | 限速等待 494ms | 立即通过 |
| 3 | 限速等待 489ms | 立即通过 |
| 4 | 限速等待 483ms | 立即通过 |
| 5 | 限速等待 477ms | 立即通过 |
| 6 | 限速等待 472ms | 拒绝 |
| 7-10 | 继续等待 | 全部拒绝 |
说明:此处简化为 rate-check 模式(拒绝+告知剩余等待时间),非 FIFO 排队。等待时间递减表示"距下一可用时刻的剩余时间"。
两列放一起看就很清楚了。纯漏桶就是"限速了,挨个来,500ms 一个不商量"。Nginx 模式是"桶里有余量就放,用完了直接拒绝"。
推演:为什么这不是漏桶?
从行为反推定义。当 burst=5 + nodelay 时:
- 桶里最多存 5 个令牌
- 有令牌就立即放行(不等待)
- 没令牌就拒绝(不排队)
- 令牌以 rate 速率持续补充
这在行为上已经是令牌桶——有令牌就放行、没有就拒绝。漏桶那套"匀速漏出、超额排队"的规矩,加了 burst+nodelay 之后已经不存在了。
Nginx 源码里的 excess 字段和 delay 分支证实了这一点(源码:ngx_http_limit_req_module.c#L195,L195 handler 入口,L296 if (!delay) 分支,L454 excess 计算)。nodelay 把"延迟处理"变成了"立即消费 burst 容量"。文档没骗你——在默认模式(无 burst、无 nodelay)下确实是漏桶。
但不少需要快速响应的生产配置会加 burst+nodelay,因为纯漏桶的匀速特性对突发流量太不友好了,用户点一下按钮要等 500ms 才能响应,体验是不可接受的。不加 nodelay 时 burst 请求会排队逐个放行,这仍然是漏桶行为(带有限队列深度)。
换句话说,Nginx 不是"要么漏桶要么令牌桶"——它的 limit_req 是一个可配置的连续光谱。不加 burst 和 nodelay 时是教科书漏桶,加了之后逐渐向令牌桶行为滑动。理解这个光谱,你才能针对具体业务选对配置。

我们也曾以为配上 limit_req 就稳了——结果发现 burst+nodelay 让一段突发流量直接穿透了"我以为存在的"排队机制。因为我们以为它在排队,其实它在消耗 burst 额度后直接放行。
选型启示
这里有一个简单的判断:严格匀速保护下游(数据库写入限速、消息队列消费限速)用纯漏桶模式,不加 nodelay;容忍短暂突发但限制总量(API 网关、用户请求入口)用 burst+nodelay,接受它本质是令牌桶。
| 教科书说 | 生产真相 |
|---|---|
| Nginx limit_req 是漏桶 | 默认模式是,加 burst+nodelay 后行为是令牌桶 |
| 漏桶特征是匀速排出 | 匀速只在无 burst 参数时成立 |
| 漏桶和令牌桶是两种算法 | Nginx 同一个模块两种行为,一个参数的事 |

二、滑动窗口的精确是免费的吗?
一笔内存账让我重新认识了滑动窗口。
滑动窗口日志多精确啊,每个请求的时间戳都记着,窗口内精确计数。面试答这个总没错吧?直到我算了一下——精确是用真金白银的内存换来的。
一笔让人清醒的内存账
滑动窗口日志(Sliding Window Log)的原理说白了就是:把每个请求的到达时间存进 Redis ZSET,查询时用 ZREMRANGEBYSCORE 删除窗口外的旧记录,再 ZCARD 看窗口内有多少条。
完美——但你算过这个 ZSET 要存多少东西吗?
我按 Redis ZSET 的 skiplist 内存模型做了测算。skiplist 编码下每个节点约 100 字节(含 zskiplistNode + dictEntry + SDS + jemalloc 对齐,基于 Redis 内存模型估算),60 秒窗口:
| QPS | 用户数 | 窗口内总条目 | 需要的内存 |
|---|---|---|---|
| 100 | 1,000 | 600 万 | 572 MB |
| 1,000 | 1,000 | 6,000 万 | 5.7 GB |
| 10,000 | 1,000 | 6 亿 | 56 GB |
| 10,000 | 10,000 | 60 亿 | 560 GB |
以上按均匀 QPS 分布估算(最不利情况)。真实流量通常是长尾分布(90% 用户低频),实际内存低于此值——但数量级差距不变。
1K QPS × 1K 用户 = 5.7 GB。一台标准 Redis 实例(8-16 GB)能塞下但已经很紧张了。万级 QPS?直接超过单台容量上限。十万级?根本不可行。
而 Sliding Window Counter(分 10 个子桶,每个子桶只存一个计数器)同样 10K QPS × 1K 用户只需要 80 KB(裸计数值;含 Redis Hash key 元数据约 200-400 KB)。
内存差距:数万倍(4-5 个数量级)。
这就是"精确"两个字背后的代价。
三变种实测:精度差多少?
内存省了数万倍,精度损失有多大?我用 Go 实现了三种变种对比。配置:窗口 1 秒,限额 5 个请求。测试场景是跨窗口边界——前一个窗口尾部(800-900ms)发 3 个请求,新窗口头部(1050-1150ms)再发 3 个。
| 请求 | 偏移 | SW Log(精确) | SW Counter(近似) | Fixed Window |
|---|---|---|---|---|
| 1-3 | 800-900ms | ✓ 通过 | ✓ 通过 | ✓ 通过 |
| 4-5 | 1050-1100ms | ✓ 通过 | ✓ 通过 | ✓ 通过 |
| 6 | 1150ms | ✗ 拒绝 | ✗ 拒绝 | ✓ 放行 |
第 6 个请求是分水岭。SW Log 和 SW Counter 都知道"最近 1 秒内够了",拒绝。但 Fixed Window 因为窗口重置把计数清了——它觉得新窗口才 3 个没超额,放行了。实际 1 秒内过了 6 个,超额 20%。
这就是 Fixed Window 的经典问题:窗口边界存在"双倍放行"漏洞。理论最坏情况是 2×limit(100% 超额)——窗口尾部打满 + 新窗口头部打满。在流量均匀的情况下很难触发,但突发流量恰好卡在窗口交界——这在限流场景里恰恰是常态。

至于 SW Counter 的精度上界:子桶数 = N 时,最大误差 ≤ 1/N。
推导不复杂:你把窗口切成 N 段,每段只知道"这一段有多少请求"而不知道精确时间。最差情况是某段请求全部集中在段首或段尾,窗口滑动时该桶的比例估算偏差最大为 1 个子桶的全量。
10 个子桶最差 10% 误差,100 个子桶 1% 误差,1000 个子桶 0.1% 误差。对绝大多数业务来说,10-100 个子桶就够了——比起数万倍的内存差距,1% 精度损失简直是白捡的。
还有一个不常被提到的性能差异:SW Log 每次请求要执行 ZADD + ZREMRANGEBYSCORE + ZCARD 三个 Redis 命令(即使 pipeline),而 SW Counter 只需要 HINCRBY 一个命令加一次过期桶的清理。高 QPS 下 Redis 的 CPU 开销差距也不小。
我们也踩过这个坑。早期在一个服务上线了 ZSET 滑动窗口限流,QPS 一上来 Redis 内存增长飞快,差点把实例撑爆。监控曲线直接是一条斜线往上走——每秒钟多存几千条时间戳,60 秒窗口意味着缓存里永远攒着几十万条数据。后来改成 Counter 变种(10 个子桶),内存直接从 GB 级回到 KB 级别,拒绝行为跟 Log 版本在非边界时刻完全一致。
选型启示
- 安全、金融场景(登录爆破检测、支付 API 精确配额、合规审计)→ SW Log,精确性强需求,内存代价可以用集群兜
- 常规高并发(API 网关、服务间限流)→ SW Counter(10-100 子桶),99% 精度 + 极低内存
- 低成本不敏感场景(内部管理接口、批处理触发)→ Fixed Window,但要知道边界风险
教科书告诉你"滑动窗口最精确",但没告诉你精确是用 O(QPS×窗口) 的内存换来的。当 99% 精度(Counter)够用时,多付数万倍内存就是纯浪费。至于 Fixed Window——能用,但窗口边界最坏情况可突发 2×limit(100% 超额),别在安全场景用它。

三、令牌桶在分布式下还简单吗?
10+ 实例共享限流额度的第一天就出事了。
令牌桶不就是"定时往桶里加令牌,请求来了扣一个"吗?20 行代码能写出来,面试手写都行。问题出在生产环境——多个实例同时去操作同一个令牌桶。
100 个并发打过来,你的桶兜不住
单机令牌桶确实简单。问题出在分布式:多个实例共享 Redis 中同一个令牌桶时,“读取当前令牌数 → 本地计算 → 写回新值"这三步之间有个致命间隙——GET 和 SET 不是原子的。
我用 100 个 goroutine 模拟了并发场景,桶里只有 10 个令牌,预期最多放行 10 个请求。朴素版的问题在于 GET 和 SET 之间有个时间窗口——在真实 Redis 场景下这个窗口是一次网络往返(约 1ms),在这 1ms 内所有到达的请求都能读到"还有令牌”。
核心逻辑简化表达(伪代码):
// 核心逻辑简化(伪代码)
// 朴素版核心问题:GET 和 SET 之间释放了锁
tokens := redis.GET("bucket") // 100 个人同时读到 "10"
// ← 这里有 1ms 的网络往返时间
if tokens >= 1 {
redis.SET("bucket", tokens-1) // 100 个人各自写 "9"
return true // 全部认为自己扣到了
}
5 轮实测结果:
| 版本 | 预期最多放行 | 实际放行 | 超发率 |
|---|---|---|---|
| 朴素版(GET/SET 两步) | 10 | 100 | 900% |
| Lua 原子版 | 10 | 10 | 0% |
注意:这是零延迟极端模拟(100 goroutine 同进程共享变量),测的是设计缺陷的理论上界。真实 Redis 网络场景下超发率随并发度和 RTT 变化,但非原子操作导致超发的本质不变——只要并发窗口内有多个请求同时读到旧值,就一定超发,并发越高超发越严重。
Lua 原子版把"读-算-写"封装在一个 Redis Lua 脚本里。Redis 单线程保证了脚本执行期间没有并发干扰——第一个请求的脚本没执行完,第二个请求的脚本排队等。所以精确限制在 10 个。
三个方案的取舍三角
我们实际踩过这个坑。去年 Q3 一个订单服务上了 10+ 实例共享限流,每台自己维护本地令牌桶,结果流量分布不均——80% 流量打到 3 台热点实例,本地桶早就用完了疯狂拒绝,其他实例的额度却空着。全局看通过量远低于设定额度,但热点实例的用户体验已经降级了。
这里要区分两个独立问题:流量分布不均是 LB 层问题(一致性哈希、加权轮询可解),中心化限流解决的是多实例共享额度的精确性——两者需分别处理。
切中心化方案时有三条路:
方案 A:各自本地桶(静态分片)
把总额度按实例数均分——10 个实例各分 10 QPS。零网络开销,响应快。但流量不均时问题严重:80% 的请求打到 3 台热点实例,它们的 10 QPS 额度瞬间耗尽,而其他 7 台实例的额度完全闲置。全局看总通过量远低于设定的 100 QPS 额度——用户觉得限流太严,但没有任何一台超额。扩缩容时还要重算分片,运维成本不低。
方案 B:Redis WATCH + MULTI(乐观锁)
用 Redis 的 WATCH 机制做乐观并发控制——WATCH key,读取当前值,本地计算,MULTI/EXEC 提交。如果 key 在这期间被别人改了,EXEC 返回 nil,重试。理论上正确,实际上在千级 QPS 单 key 竞争下性能退化严重。
为什么?3 个实例 × 1000 QPS = 每秒 3000 个请求竞争同一个 Redis key。WATCH 到 EXEC 之间大约 1-2ms(一次网络往返),这个窗口内平均有 3-6 个请求并发。第一个提交成功,其他全部重试。
重试又会冲突——形成重试风暴。
按 3000 QPS × 1ms 窗口估算,约 3-6 并发冲突,P99 延迟推算为 50-100ms——未实测,属工程推算。
方案 C:Lua 原子脚本
整个"补充令牌 → 检查余额 → 扣减"在一个 Lua 脚本里完成。Redis 单线程顺序执行,天然原子,无需重试。代价是 Redis 成了限流链路的单点——所有限流判定都要经过这一跳。Redis 宕机 = 限流消失。
| 维度 | 本地桶 | WATCH+MULTI | Lua 脚本 |
|---|---|---|---|
| 精确性 | ★★☆ | ★★★ | ★★★ |
| 延迟 | ★★★(零网络) | ★☆☆(重试) | ★★☆ |
| 高并发 | ★★★ | ★☆☆ | ★★☆ |
| 故障域 | 单实例 | Redis 单点 | Redis 单点 |

我们最终选了 Lua 脚本 + 本地桶降级:正常情况走 Redis Lua 保证精确,Redis 故障时自动降级到本地桶,牺牲短暂精度换取可用性不中断。降级期间的超发容忍度设为额度的 20%(即 100 QPS 额度允许短暂放到 120)——对我们的场景来说,短暂超额 20% 不会触发下游熔断阈值,业务可接受。恢复 Redis 连接后自动切回精确模式。安全场景(防刷)应 fail-closed(Redis 挂了就拒绝),可用性优先场景 fail-open(降级放行)。
GET/SET 两步走的朴素实现,只要并发窗口内有多个请求同时读到旧值,就一定超发。每次看到代码评审里有人写 tokens = redis.Get(); if tokens > 0 { redis.Set(tokens - 1) },我都会指出来:这段代码在单机可以跑,上了多实例就是定时炸弹。
选型启示
- 单机、进程内限流(本地中间件、单实例小服务)→ 内存令牌桶,确实 20 行代码
- 多实例共享限流(几乎所有生产场景)→ Lua 原子脚本,别省这一步
- 超高并发 + 可接受粗精度(万级 QPS 网关入口)→ 本地桶 + 周期性 Redis 同步
| 教科书说 | 生产真相 |
|---|---|
| 令牌桶实现简单 | 单机简单,分布式下原子性是硬门槛 |
| GET→计算→SET 就行 | 非原子操作在并发窗口内必然超发 |
| Redis 能解决一切 | Redis 本身是单点,故障降级策略不可少 |

四、从纠偏到选型
三个误解纠完了。把真相组合起来,选型就不再是"看情况"了——至少你知道"看的是什么情况"。
限流选型的核心不是"哪个算法最好"(没有最好,只有最合适),而是你在哪几个维度上愿意做取舍。下次拿到一个限流需求,问自己三个问题:
问题 1:流量模式是什么?
| 流量特征 | 选择 | 为什么 |
|---|---|---|
| 需要严格匀速 | 漏桶(不加 nodelay) | 保护下游不被突发打穿 |
| 允许突发但限总量 | 令牌桶(burst+nodelay) | 快速响应 + 总量可控 |
问题 2:精度和内存怎么取舍?
| 场景 | 选择 | 代价 |
|---|---|---|
| 安全、金融(零容忍) | SW Log | 内存 O(QPS×窗口),可能需要 Redis 集群 |
| 常规业务 | SW Counter(10-100 桶) | 精度损失 ≤ 1%,内存降 4-5 个数量级 |
| 非关键接口 | Fixed Window | 窗口边界最坏可突发 2×limit(100% 超额) |
问题 3:部署模式是什么?
这个问题决定了你需要在"正确性"和"可用性"之间做什么取舍。
| 部署 | 选择 | 注意 |
|---|---|---|
| 单机 | 内存实现 | 不需要考虑原子性,sync.Mutex 就够了 |
| 多实例(< 10 台) | Lua 原子脚本 | 别用两步 GET/SET,一定超发 |
| 超高并发多实例(10+ 台) | 本地桶 + 定期同步 | 接受粗精度换低延迟,同步周期按业务容忍度调 |
如果你用 Alibaba Sentinel(流控组件)、Envoy、Kong 等中间件,算法选择已被框架预设——此时理解底层行为帮助你正确配置参数,而非从头选算法。
三个问题答完,算法选型基本确定。剩下的是工程细节——连接池大小、降级策略、监控告警,以及客户端配合(429 响应 + Retry-After header + 指数退避)。这些重要,但属于实现层面,不再是"选哪个算法"的决策。

一个补充:工业级系统几乎都是混合架构。我们的网关入口用令牌桶应对突发,到数据库层切漏桶保护写入,安全层滑动窗口精确防刷。没有哪个业务只用一种算法打天下。理解单算法是地基,在此之上做组合才是日常。
云服务(Sentinel、Envoy、API Gateway)封装了这些细节,但封装不等于不需要理解。你调 Sentinel 的 flowRule.grade = RuleConstant.FLOW_GRADE_QPS 时,底下跑的是滑动窗口还是令牌桶?精度是多少?分布式下怎么同步?如果你不知道答案,那你也无法判断它的行为是否符合你的业务预期。正确配置的前提是知道背后在做什么——否则你会像我们一样,以为 limit_req 在排队,其实它在放行。
回到开头那个面试。如果候选人能说出"Nginx 加了 burst+nodelay 不是纯漏桶了",或者"分布式令牌桶的关键不是算法而是原子性",我会认为他不只是在背答案。因为这样的回答说明他自己验证过,或者至少——踩过坑。

建议回去看看你们的 Nginx 配置,burst+nodelay 是不是你以为的那样。
原文发布于 止语Lab