运维知识
悠悠
2026年10月25日

Nginx 限流实战:4 种算法、生产配置、灰度验证与回滚一次讲透

晚上 20:03,登录接口请求量突然上涨。Nginx 还能接收连接,但后端认证服务的线程池已经排队,数据库连接数一路逼近上限,接口 P99 从 180ms 涨到 4.6s。

值班同事紧急加了一条 limit_req,结果流量没压住,正常用户反而开始大量收到 503。继续检查才发现两个问题:$remote_addr 取到的是负载均衡器地址,所有用户被算成了同一个客户端;配置中的 burst=100 nodelay 又瞬间向后端放过了一批突发请求。

限流看起来只有几行配置,真正落地却涉及算法、限流维度、客户端 IP、突发流量、状态码、多实例一致性和失败策略。任何一项理解偏差,都可能把保护措施变成新的故障源。

我想先给出一句最重要的结论:

限流不是简单地“超过数字就拒绝”,而是在吞吐、突发能力、排队延迟和后端承载上限之间做约束。

限流算法到底有几种

工程中常见的请求速率算法主要有四种:固定窗口、滑动窗口、令牌桶和漏桶。除此之外,Nginx 还可以限制并发连接数,但并发限制不属于请求速率算法。

算法核心行为优点主要问题典型场景
固定窗口每个固定时间段允许 N 次简单、开销低窗口交界处可能出现双倍突发低成本配额、非核心接口
滑动窗口统计最近一段时间内的请求比固定窗口平滑状态和计算开销更高登录、短信、敏感 API
令牌桶按速率生成令牌,请求消耗令牌允许可控突发参数和分布式状态较复杂开放 API、业务网关
漏桶请求按固定速率流出,多余请求排队或拒绝输出平滑、保护后端效果好可能增加排队延迟Nginx 入口、脆弱上游保护

Nginx 开源版的 limit_req 使用漏桶思想控制请求速率。它不是“每秒钟清零一次”的固定窗口,也不是严格意义上的令牌桶。

这一点很关键。配置 rate=10r/s 并不表示每个自然秒最多处理 10 个请求,而是按平均每 100ms 一个请求的节奏计算超额请求。

Nginx 原生漏桶应该怎么配

动生产配置前,先确认版本、编译模块和当前生效配置:

nginx -v
nginx -V 2>&1
nginx -T 2>&1

nginx -v 查看版本,nginx -V 查看编译参数,重点确认是否包含所需 HTTP 模块。官方常见发行包通常已经编译 limit_req 和 limit_conn,但精简镜像、自行编译版本不能想当然。

nginx -T 只读取并输出完整配置,适合确认配置从哪些文件加载、同一指令是否被其他层级覆盖。它可能打印证书路径、上游地址或配置中的敏感信息,不要直接把完整输出粘贴到工单或群聊。

下面是一份基础配置:

http {
    limit_req_zone  $binary_remote_addr
                    zone=login_per_ip:20m
                    rate=5r/s;

    log_format main_ext
        '$remote_addr [$time_iso8601] "$request" status=$status '
        'request_time=$request_time upstream_time=$upstream_response_time '
        'upstream_status=$upstream_status '
        'limit_req=$limit_req_status';

    server {
        listen 80;
        access_log /var/log/nginx/access.log main_ext;

        location = /api/login {
            limit_req zone=login_per_ip burst=10;
            limit_req_status 429;
            limit_req_log_level notice;

            proxy_pass http://auth_backend;
        }
    }
}

这段配置可以拆成四部分理解。

limit_req_zone 在 http 层定义共享内存区。多个 Nginx worker 通过该共享区维护计数,而不是每个 worker 各算一份。

$binary_remote_addr 是限流键,表示按客户端 IP 统计。与文本形式的 $remote_addr 相比,二进制地址通常更节省共享内存。

rate=5r/s 表示平均放行速率为每秒 5 个请求。

burst=10 表示允许暂时积累最多 10 个超额请求。没有 nodelay 时,这些请求会被延迟处理,以便让流量按配置速率流向上游;超出队列容量的请求才会被拒绝。

假设速率为 5r/s,每个请求的理论流出间隔约为 200ms。如果超额请求排到队尾,仅由限流引入的最坏等待时间可能接近:

burst / rate = 10 / 5 = 2 秒

因此,burst 不能只看“能承受多少突发”,还要检查接口超时预算。如果客户端超时只有 1 秒,却允许请求在 Nginx 排队 2 秒,配置本身就不合理。

如果接口不允许在入口等待,可以加 nodelay:

limit_req zone=login_per_ip burst=10 nodelay;

此时突发额度内的请求会立即进入上游,不再排队,但漏桶仍会记录这次突发占用。后续请求必须等待额度逐渐恢复,否则仍会被拒绝。

nodelay 降低了入口等待,却会把瞬时压力传给后端。它适合后端能承受短时突发、接口又对延迟敏感的场景,不适合用来保护已经接近容量上限的数据库或单线程服务。

部分较新版本还支持 delay=N,可以让前 N 个超额请求立即通过,再对后续超额请求进行延迟:

limit_req zone=login_per_ip burst=20 delay=5;

这是介于排队与 nodelay 之间的折中方案。使用前应通过 nginx -v 和测试环境的 nginx -t 确认当前版本支持该参数,不能直接把新版本配置复制到旧环境。

拒绝状态码建议显式设置为 429:

limit_req_status 429;

默认拒绝码通常是 503。503 更像服务不可用,容易触发客户端、SDK 或上游代理的重试;429 能更准确地表达“请求过多”。但改成 429 并不会自动解决重试风暴,客户端仍需要遵循退避和抖动策略。

客户端 IP 取错,限流一定失真

Nginx 位于负载均衡器、CDN或 Kubernetes Ingress 后面时,$remote_addr 很可能是上一跳代理地址。

如果直接按它限流,可能出现两种极端:

  • 所有用户都显示成负载均衡器 IP,一个用户突发导致整站被限流。
  • 为绕开问题直接使用 $http_x_forwarded_for,攻击者伪造请求头,不但可以逃避限流,还可能制造大量高基数键耗尽共享内存。

正确做法是只信任明确的代理地址,并由 Real IP 模块还原客户端地址:

set_real_ip_from 10.20.0.0/16;
set_real_ip_from 192.0.2.10;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

set_real_ip_from 必须填写实际负载均衡器或受信代理的地址范围,不能为了省事写成 0.0.0.0/0。否则任何直接访问 Nginx 的客户端都可以伪造源地址。

上线前先用扩展日志采证:

log_format ip_check
    'remote=$remote_addr realip_remote=$realip_remote_addr '
    'xff="$http_x_forwarded_for" host=$host request="$request"';

重点比较以下字段:

  • $remote_addr 应是经过可信代理链解析后的客户端地址。
  • $realip_remote_addr 保留被替换前的直连对端地址,可用于确认请求是否来自受信代理。
  • X-Forwarded-For 只能作为排查证据,不能在未建立信任边界时直接作为限流键。

如果直连对端不在受信代理范围内,却仍能改变 $remote_addr,说明信任范围配置过宽,应立即回滚 Real IP 配置。

固定窗口、滑动窗口和令牌桶怎样实现

Nginx 原生 limit_req 不会自动变成其他三种算法。如果业务明确需要固定窗口、滑动窗口或令牌桶,可以在 OpenResty 的访问阶段实现,也可以把限流下沉到 API 网关或业务服务。

下面的示例使用 OpenResty 的 lua_shared_dict。共享字典在同一 Nginx 实例的 worker 之间共享,但不同容器、Pod、虚拟机之间不共享。

固定窗口:实现简单,但要警惕边界突发

http {
    lua_shared_dict fixed_limit 20m;

    server {
        location = /api/send-code {
            access_by_lua_block {
                local dict = ngx.shared.fixed_limit
                local window = 60
                local limit = 10
                local now = ngx.now()
                local slot = math.floor(now / window)

                local identity = ngx.var.remote_addr
                local key = identity .. ":" .. slot

                local count, err =
                    dict:incr(key, 1, 0, window * 2)

                if not count then
                    ngx.log(ngx.ERR, "fixed limiter error: ", err)
                    return ngx.exit(503)
                end

                if count > limit then
                    ngx.header["Retry-After"] =
                        tostring(window - math.floor(now % window))
                    return ngx.exit(429)
                end
            }

            proxy_pass http://sms_backend;
        }
    }
}

同一个窗口内超过 10 次即返回 429。计数递增由共享字典原子完成,状态在本机 worker 间共享。

它的缺陷发生在窗口边界:某个客户端在 12:00:59 发出 10 次请求,又在 12:01:00 发出 10 次请求,短时间内便通过了 20 次。短信、支付尝试等敏感操作不能只依赖这种算法。

滑动窗口计数:更平滑,但属于近似统计

可以用“上一窗口加权值+当前窗口计数”估算最近一个窗口的请求量:

http {
    lua_shared_dict sliding_limit 20m;

    server {
        location = /api/login {
            access_by_lua_block {
                local dict = ngx.shared.sliding_limit
                local window = 60
                local limit = 30
                local now = ngx.now()
                local slot = math.floor(now / window)
                local elapsed = now - slot * window
                local identity = ngx.var.remote_addr

                local current_key = identity .. ":" .. slot
                local previous_key = identity .. ":" .. (slot - 1)

                local current, err =
                    dict:incr(current_key, 1, 0, window * 2)

                if not current then
                    ngx.log(ngx.ERR, "sliding limiter error: ", err)
                    return ngx.exit(503)
                end

                local previous = dict:get(previous_key) or 0
                local estimated =
                    previous * (1 - elapsed / window) + current

                if estimated > limit then
                    return ngx.exit(429)
                end
            }

            proxy_pass http://auth_backend;
        }
    }
}

这种方式只保存两个窗口,内存消耗低于“为每个请求保存时间戳”的精确滑动日志,但统计是近似值。

示例中,被拒绝的请求也会进入当前计数,因此策略会偏严格。如果必须精确区分已通过和已拒绝请求,或者要求多节点全局一致,应使用 Redis 原子脚本、成熟网关插件或业务侧限流组件,不要继续扩展一段未经并发测试的 Lua。

令牌桶:允许突发,适合开放 API

令牌桶包含三个核心参数:

rate:每秒补充多少令牌
capacity:桶最多保存多少令牌
cost:一次请求消耗多少令牌

例如 rate=10、capacity=30,表示长期速率为每秒 10 次,但空闲一段时间后最多可以连续处理 30 次请求。

单机 OpenResty 可以使用共享字典保存令牌数和最后补充时间,但“读取状态—计算—写回状态”必须加锁,否则多个 worker 并发执行时会超发:

http {
    lua_shared_dict token_state 20m;
    lua_shared_dict token_locks 1m;

    server {
        location /open-api/ {
            access_by_lua_block {
                local resty_lock = require "resty.lock"
                local state = ngx.shared.token_state

                local rate = 10
                local capacity = 30
                local cost = 1
                local now = ngx.now()
                local key = "tb:" .. ngx.var.remote_addr

                local lock, err = resty_lock:new(
                    "token_locks",
                    { timeout = 0.05, exptime = 0.2 }
                )

                if not lock then
                    ngx.log(ngx.ERR, "create lock failed: ", err)
                    return ngx.exit(503)
                end

                local elapsed, lock_err = lock:lock(key)
                if not elapsed then
                    ngx.log(ngx.ERR, "acquire lock failed: ", lock_err)
                    return ngx.exit(503)
                end

                local raw = state:get(key)
                local tokens = capacity
                local last = now

                if raw then
                    local sep = string.find(raw, ":", 1, true)
                    tokens = tonumber(string.sub(raw, 1, sep - 1))
                    last = tonumber(string.sub(raw, sep + 1))
                end

                tokens = math.min(
                    capacity,
                    tokens + math.max(0, now - last) * rate
                )

                local allowed = tokens >= cost
                if allowed then
                    tokens = tokens - cost
                end

                local ok, set_err = state:set(
                    key,
                    tostring(tokens) .. ":" .. tostring(now),
                    math.ceil(capacity / rate) * 2
                )

                local unlock_ok, unlock_err = lock:unlock()

                if not ok then
                    ngx.log(ngx.ERR, "save token state failed: ", set_err)
                    return ngx.exit(503)
                end

                if not unlock_ok then
                    ngx.log(ngx.ERR, "unlock failed: ", unlock_err)
                end

                if not allowed then
                    ngx.header["Retry-After"] = "1"
                    return ngx.exit(429)
                end
            }

            proxy_pass http://api_backend;
        }
    }
}

这段配置依赖 OpenResty 及 lua-resty-lock,应先在测试环境完成并发和锁超时测试。

生产中还要明确失败策略:限流状态读取失败时,是放行还是拒绝?登录保护、短信发送通常倾向失败关闭;普通查询接口可能倾向失败开放,避免 Redis 或锁故障拖垮整个入口。无论选择哪种,都要为限流器错误单独告警。

多实例场景不要直接把这段单机代码复制到每个 Pod 后就宣称“全局每秒 10 次”。三个实例各有 10 次额度,客户端流量均匀分布时,总额度可能接近 30 次。需要全局额度时,应使用 Redis 等共享状态,并通过服务端时间和原子脚本完成“补充、判断、扣减”,避免节点时钟偏差和并发超发。

请求速率之外,还要限制并发

慢请求即使 QPS 不高,也可能耗尽上游连接池。此时只配置 limit_req 不够,还要考虑 limit_conn:

http {
    limit_conn_zone $binary_remote_addr
                    zone=conn_per_ip:20m;

    server {
        location /download/ {
            limit_conn conn_per_ip 5;
            limit_conn_status 429;

            proxy_pass http://download_backend;
        }
    }
}

它表示同一客户端最多保留 5 个被计数的并发请求。

需要注意:

  • limit_conn 只有在请求头读取完成后才开始计数。
  • 在 HTTP/2、HTTP/3 中,一个连接可以承载多个并发请求,这些并发请求可能分别被计数,不能把指令名称机械理解成 TCP 连接数。
  • WebSocket 和长轮询连接持续时间很长,额度要与普通短请求分开设计。
  • 大量用户共享一个 NAT 出口时,按 IP 限制并发可能误伤整个办公区或校园网。

请求速率限制负责控制“单位时间进来多少”,并发限制负责控制“同一时间占着多少资源”。对于数据库查询、报表下载和大文件传输,这两个维度经常需要组合使用。

一个脱敏后的典型场景

下面用一个脱敏后的典型场景说明,时间、地址和指标均为示例数据。

20:03,登录接口从平时约 180 RPS 上涨到 2400 RPS。应用实例 CPU 只有 55%,但认证数据库活跃连接从 80 增长到 360,连接等待时间明显上升,接口 P99 达到 4.6s。

团队最初怀疑应用发布引入性能退化。对比健康时段和异常时段后发现,应用版本、SQL 执行计划和实例资源没有变化;访问日志却显示少量来源持续请求不存在或错误的账号,请求分布高度集中。

第一次止损按 $remote_addr 设置 5r/s,灰度实例立刻出现大量 429。检查日志发现,几乎所有请求的 $remote_addr 都是 10.20.8.15,这其实是内部负载均衡器地址。这个证据推翻了“正常用户也在高频请求”的判断。

团队随后限定受信负载均衡器网段,启用 Real IP 解析,再观察 $remote_addr、$realip_remote_addr 和转发链是否符合预期。确认客户端地址恢复正常后,先在一台实例启用只记录、不实际拒绝的 dry-run 模式:

limit_req_dry_run on;
limit_req zone=login_per_ip burst=10 nodelay;

是否支持 limit_req_dry_run 必须以当前 Nginx 版本和 nginx -t 结果为准。开启后重点观察 $limit_req_status:正常请求通常显示为 PASSED,命中但未真正限制的请求会出现带 DRY_RUN 的状态。

观察一个完整业务高峰后,团队发现正常用户绝大多数低于每秒 2 次,异常来源长期超过每秒 30 次,于是将灰度策略设为 5r/s、burst=10,拒绝码为 429。

灰度期间设定三个停止条件:

  • 登录成功率下降超过业务基线允许范围;
  • 429 命中大量已登录或已验证的正常用户;
  • Nginx P99、CPU或共享内存相关错误明显上升。

灰度结果显示,异常请求被限制后,认证数据库活跃连接降回示例基线附近,接口 P99 恢复;正常登录成功率没有超过停止阈值。团队才逐步扩大范围。

最终治理没有只停留在 IP 限流:登录接口增加账号维度的风险控制,客户端重试加入指数退避,异常账号枚举进入安全告警,入口保留全局保护阈值。因为 IP 只代表网络来源,不能可靠代表一个用户;共享 NAT、移动网络和代理出口都会造成误判。

怎样安全上线和回滚

配置变更前先保存当前生效配置及待修改文件。备份中可能包含敏感信息,应限制文件权限和保存位置。

完成修改后必须校验语法:

nginx -t

只有出现类似下面的结果才继续:

syntax is ok
test is successful

随后优先在单实例、单域名或小比例流量上灰度。现代 Nginx reload 通常是平滑加载,但它仍属于有状态变更:新 worker 使用新配置,旧 worker 处理完存量请求后退出。

nginx -s reload

如果环境由 systemd 管理,也可以使用平台约定的 reload 命令,但不要同时混用多套进程管理方式。

验证至少覆盖:

curl -i https://example.com/api/login

单次请求应正常通过。压测必须在测试环境或经过授权的隔离流量中执行,不能直接对生产入口循环轰击。

观测项至少包括:

  • 总请求量、2xx、429、5xx及其比例;
  • $limit_req_status 的 PASSED、DELAYED、REJECTED 等状态;
  • Nginx 请求耗时和上游响应耗时;
  • 后端线程池、连接池、队列和数据库连接;
  • error log 中带有限流区名称的拒绝或共享内存分配异常;
  • 按接口、客户端、租户和实例拆分后的命中分布。

回滚不要临时手改几个参数。应恢复上一份经过校验的完整配置,再执行:

nginx -t
nginx -s reload

如果 nginx -t 不通过,禁止 reload。回滚后继续观察 429、5xx、上游延迟和 worker 状态,确认旧配置已经由新 worker 接管。

这些坑上线前务必逐项确认

  • 不要直接用 $http_x_forwarded_for 做限流键。 请求头可能被伪造,完整代理链还会造成高基数和额外内存消耗。
  • 不要把 rate=10r/s 理解成固定窗口每秒 10 次。 limit_req 按漏桶节奏计算,行为与自然秒清零不同。
  • 不要把 burst 当并发数。 它表示可容纳的超额请求数量;未加 nodelay 时还会带来排队延迟。
  • 不要看到延迟就盲目加 nodelay。 它会让突发请求立即压向后端,可能把 Nginx 的排队问题变成数据库雪崩。
  • 不要使用默认拒绝码却不评估重试。 503 可能触发自动重试,建议根据协议语义使用 429,并同步治理客户端退避。
  • 不要忽略共享内存容量。 限流键数量过多会占满 zone;要监控 error log,并避免使用用户可随意构造的高基数字段。
  • 不要认为多台 Nginx 自动共享额度。 共享内存只覆盖同一实例内的 worker,多实例全局限流需要外部共享状态或稳定的流量分片。
  • 不要只按 IP 识别用户。 NAT、企业出口和移动网络可能让大量正常用户共享地址;账号、租户和 API Key 应在可信认证层参与限流。
  • 不要忽略配置继承关系。 在更内层重新声明相关指令后,父层配置是否仍按预期生效必须用 nginx -T 和实际请求验证。
  • 不要把限流器自身故障留到现场决定。 Redis 不可用、Lua 锁超时、共享内存不足时究竟放行还是拒绝,应按接口风险提前定义。
  • 务必先 dry-run、再单实例灰度。 没有命中分布和正常流量基线,任何阈值都只是猜测。
  • 务必同时观察后端。 429 减少不代表策略正确,真正目标是让上游延迟、连接池和错误率回到安全范围。
  • 务必准备完整回滚配置。 回滚不是删除一行指令,而是恢复经过验证的上一版本并重新执行语法检查。

Nginx 原生漏桶适合在入口快速削峰,固定窗口适合低成本配额,滑动窗口适合更平滑的敏感接口控制,令牌桶适合允许可控突发的开放 API。并发限制则用来约束长请求和稀缺连接资源。

真正可靠的限流方案必须同时回答五个问题:按谁限制、长期速率是多少、允许多大突发、超过后怎样处理、限流器故障时怎么办。

现在就可以做一个动作:给核心接口的访问日志补上 $limit_req_status、客户端真实地址和上游耗时,先采集一个业务高峰周期的数据,再决定阈值。没有基线的限流配置,通常只是在生产环境里下注。

如果这篇文章帮你理清了 Nginx 限流的算法和生产落地细节,欢迎关注、点赞、在看,并转发给正在做网关治理或稳定性建设的同事。

公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码