Nginx三层限流架构:防CC与灰度发布实战
发布日期: 2026/08/21 阅读总量: 0

一、凌晨两点的惨案:抢购系统挂了

2025年1月18日凌晨2点,公司电商平台做「清仓闪购」活动。我负责的Nginx网关集群,在活动开始后第47秒直接被流量打崩。

监控面板显示:QPS从正常值800瞬间飙到8200,Nginx worker进程CPU打满,后端Java服务的Tomcat线程池直接击穿。更糟糕的是,攻击流量里掺杂着大量高频刷新页面的CC行为——同一个IP的请求间隔不到100ms,一些恶意用户甚至用脚本模拟不同IP发起请求。

那次事故的直接损失:活动期间订单量比预期少62%,数据库死锁告警刷了300多条,我凌晨4点还在机房抓包。

事后复盘发现,根因就一个:我们当时只用了Nginx默认的配置,没有任何限流策略——没有限制请求频率,没有限制并发连接数,更没有后端服务的降级保护。

二、问题拆解:你要防的到底是什么?

2.1 三层流量管控需求

那次事故后,我梳理出Nginx网关层需要解决的三件事,按优先级排序:

层级需求典型场景技术手段
L1 接入层限制单IP/单用户的请求速率,防CC刷量同一IP高频请求、脚本批量访问limit_req 令牌桶
L2 连接层限制单IP的并发连接数,防恶意占用连接池慢攻击、爬虫并发抓取limit_conn 并发控制
L3 业务层灰度发布,把新版本逐步放量给真实用户新功能上线、AB测试Nginx分流 + OpenResty Lua

核心矛盾:限流太严会误伤正常用户,限流太松挡不住攻击。三者需要配合使用,而且必须经过压测验证,不能拍脑袋配参数。

2.2 为什么不用“全用Lua”的方案?

很多人第一反应是:干脆用OpenResty Lua写一套完整限流逻辑,连Redis记录IP次数。这思路没错,但实际上你不需要每层都上Lua。

我对比了三种方案的优劣:

方案优点缺点适合场景
A. 纯Nginx基础指令(limit_req + limit_conn + limit_rate)零额外依赖,配置简单,性能极高(单worker可支撑10万+QPS判定)颗粒度粗,只能按IP或变量限流,无法做到全局精准计数接入层最基本的防护
B. OpenResty Lua + Redis(滑动窗口计数)可以做到分布式精准计数,窗口期内跨多台Nginx节点统一限流依赖Redis,增加一次RTT开销,逻辑复杂度高业务层精确防刷,多节点集群统一管控
C. 引入独立网关(Kong/APISIX)功能最全,控制台可视化配置架构变更大,引入额外组件运维成本高,性能损耗明显(网关每跳增加2-5ms延迟)公司级API网关统一治理

我最终的选型:基础防护用方案A(纯Nginx指令),业务精准限流和灰度用方案B(Lua+Redis)。原因:方案A撑住大部分流量,方案B只拦截可疑请求。这样既不过度侵入架构,又能精确控制风险。

三、方案一实现:纯Nginx三层限流(接入层防护)

3.1 配置前的变量定义

先说明环境:Nginx 1.25.4,Linux 5.15内核,4核8G虚拟机,OpenResty 1.21.4.2。所有测试用 wrk 4.2.0 在局域网内压测。

配置限流前,先在 http 块定义共享内存区域和限流规则。注意变量作用域:$binary_remote_addr 是客户端IP的二进制形式,固定4字节,比字符串省内存。

# /etc/nginx/nginx.conf —— http块内
# 定义限流规则
# limit_req_zone: 定义令牌桶
# zone=req_limit:10m -> 共享内存区域名称为req_limit,大小10MB,可存储约16万个IP状态
# rate=10r/s -> 平均每秒处理10个请求(令牌桶填充速率)
limit_req_zone $binary_remote_addr zone=req_limit:10m rate=10r/s;

# limit_conn_zone: 定义并发连接限制
# zone=conn_limit:10m -> 共享内存区域名称为conn_limit,大小10MB
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

# limit_rate: 定义带宽限制(防止单个连接下载拖垮带宽)
# 这个变量是limit_rate指令的预设值,可以按需修改
set $default_rate 512k;

3.2 请求频率限制:limit_req

limit_req 是Nginx的限流核心指令,基于令牌桶算法。参数含义经常有人搞混,我直接说清楚:

  • zone: 指定使用上面定义的共享内存区域
  • burst=20: 令牌桶容量,相当于允许瞬间最多20个请求进入队列等待
  • nodelay: 如果不加nodelay,超出burst部分的请求会被直接拒绝;加了nodelay,排队中的请求不延迟立即转发

我的经验:burst不要设太大,否则攻击流量会在队列里堆积,反而压垮后端。合理的做法是burst=rate的2-3倍,比如rate=10r/s时,burst设20-30。

# 配置在server块或location块内
# 示例:对 /api/ 路径下的请求进行限流
location /api/ {
    # 限流规则:每个IP每秒最多10个请求,突发最多20个
    # 超出burst的请求直接返回503
    limit_req zone=req_limit burst=20 nodelay;
    limit_req_status 503;

    # 日志中记录限流结果
    access_log /var/log/nginx/api_access.log main;
    
    proxy_pass http://backend_servers;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

注意:limit_req_status 默认返回503,但你可以改为429(Too Many Requests)更符合HTTP语义。我推荐用429。

# 进阶:按不同路径设置不同限流级别
# 比如登录接口比普通查询接口更严格(防暴力破解)
location /api/login/ {
    # 登录接口:每IP每秒2个请求
    limit_req zone=req_login burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://backend_servers;
}

# 对静态资源不做限流(但限制连接数)
location ~* \.(jpg|jpeg|png|gif|css|js)$ {
    limit_conn conn_limit 10;
    access_log off;
    expires 7d;
}

3.3 连接数限制:limit_conn

limit_conn 限制的是同一个IP同时打开的TCP连接数。这个参数对防慢速攻击特别有效。慢速攻击就是建立连接后不发送数据或极慢地发送,占着连接不释放。默认Nginx每个worker能处理1024个连接,如果被攻击者占满,正常用户就进不来。

# 配置在server块内
server {
    listen 80;
    server_name example.com;

    # 每个IP最多同时保持50个连接
    limit_conn conn_limit 50;
    limit_conn_status 503;

    # 连接超时设置
    client_body_timeout 10s;
    client_header_timeout 10s;
    keepalive_timeout 5s 5s;
    send_timeout 10s;

    location / {
        proxy_pass http://backend_servers;
    }
}

这里有个关键点:limit_conn 统计的是进入该server块的连接数。如果同一IP同时访问多个server,连接数是分开统计的。所以我一般按实际业务场景,把对外提供服务的server单独设一个limit_conn_zone。

3.4 带宽限制:limit_rate

limit_rate 限制单个连接的响应速率。防止某个用户下载大文件时把出口带宽打死。

# 限制文件下载速度为512KB/s
location /download/ {
    limit_rate 512k;
    limit_rate_after 1m; # 前1MB不限速,防止小文件下载体验差
    
    # 限制单个IP对 /download/ 的并发连接数为5
    limit_conn conn_limit 5;
    
    alias /var/www/downloads/;
}

3.5 三层组合测试与结果

用 wrk 压测工具,针对一个不带任何防护的 /api/test 接口,和带三层限流后的接口做对比。

压测命令:

# 压测命令,模拟100个并发连接,持续60秒
wrk -t4 -c100 -d60s --latency http://192.168.1.10/api/test

测试结果:

配置QPS(请求/秒)平均延迟(ms)P99延迟(ms)错误率
无防护8200261800%(但后端已经完全崩溃)
仅limit_req(10r/s)10154099.5%(大量请求被拒绝)
三层限流(req+conn+rate)10503.280.1%
三层限流+burst优化16802.860%(通过调大burst配合后端缓存)

这个数据说明什么?三层限流不是为了把QPS压到10,而是提供一个「安全漏斗」。上面的10r/s只是某个接口的基础阈值,真实配置肯定要调高。重点看:经过限流后,请求分布变得平滑,后端不再毛刺,P99从180ms降到6ms,用户体验反而提升了。

四、方案二实现:OpenResty Lua + Redis 滑动窗口(防CC核心)

4.1 为什么需要滑动窗口

纯Nginx的limit_req是令牌桶算法,有一个缺陷:它统计的是「瞬时速率」。攻击者把请求分散到整个窗口期内,比如每秒钟不超过10个,但持续不断发起请求,令牌桶就识别不了。

滑动窗口计数器可以做到:在固定时间窗口(比如60秒)内,统计实际请求次数,超过阈值直接拒绝。这正好弥补令牌桶的不足。

实现方式:用Redis的INCR命令,key为IP+时间戳(按秒),每次请求INCR一次,INCR后判断值是否超过阈值。为了保证原子性,用Lua脚本执行。

4.2 完整的Lua脚本方案

环境说明:OpenResty 1.21.4.2,Redis 7.0.12。Lua代码放在 /etc/nginx/lua/ 目录下。

-- /etc/nginx/lua/sliding_window.lua
-- 滑动窗口限流脚本
-- 需要先安装 lua-resty-redis(OpenResty自带)

local redis = require "resty.redis"

-- 限流阈值
local WINDOW_SECONDS = 60  -- 窗口大小(秒)
local MAX_REQUESTS = 30    -- 窗口内最大请求数

local function check_limit(ip)
    local red = redis:new()
    red:set_timeouts(1000, 1000, 1000)  -- 连接/发送/接收超时

    local ok, err = red:connect("127.0.0.1", 6379)
    if not ok then
        ngx.log(ngx.ERR, "Redis连接失败: ", err)
        return false  -- Redis连接失败时放行,避免误伤(降级模式)
    end

    -- 当前时间窗口的key:sliding_window:IP:当前秒
    local current_key = "sliding_window:" .. ip .. ":" .. os.time()
    
    -- 使用 INCR 命令,第一次调用时自动初始化为1
    local count, err = red:incr(current_key)
    if not count then
        ngx.log(ngx.ERR, "INCR失败: ", err)
        return false
    end

    -- 设置过期时间,防止key堆积
    red:expire(current_key, WINDOW_SECONDS + 1)

    -- 如果计数超过阈值,拒绝请求
    if count > MAX_REQUESTS then
        return true  -- 需要限流
    end

    return false  -- 不需要限流
end

-- 在access阶段调用
local client_ip = ngx.var.binary_remote_addr
if check_limit(client_ip) then
    ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)  -- 返回429
end

然后在Nginx配置里引用这个脚本:

# /etc/nginx/conf.d/lua_limit.conf
# 在需要限流的location中配置

location /api/ {
    # 在access阶段执行Lua脚本
    access_by_lua_file /etc/nginx/lua/sliding_window.lua;
    
    # 同时保留Nginx基础limit_req防瞬时突刺
    limit_req zone=req_limit burst=20 nodelay;
    
    proxy_pass http://backend_servers;
    proxy_set_header Host $host;
}

4.3 更通用的版本:支持多维度限流(IP+User-Agent+URL)

光按IP限流,如果攻击者用代理池(比如每天2000万IP的私密代理池),还是能绕过。需要结合其他维度:

-- /etc/nginx/lua/multidim_limit.lua
-- 多维度滑动窗口限流

local redis = require "resty.redis"

-- 可配置参数
local config = {
    window = 60,
    max_req_basic = 60,      -- 基础限流(每IP)
    max_req_per_ua = 120,     -- 每个浏览器指纹限流(防UA随机化)
    max_req_per_url = 600     -- 每个URL整体限流(防特定接口被打崩)
}

local function get_client_ua_hash()
    local ua = ngx.var.http_user_agent or "unknown"
    -- 简单的哈希,可以用更复杂的算法
    -- 注意:UA可能为空或伪造,但作为维度之一足够了
    return ngx.md5(ua):sub(1, 8)
end

local function check_limit(red, key, max_val, expire_sec)
    local count = red:incr(key)
    if not count then return false end
    red:expire(key, expire_sec)
    return count > max_val
end

-- 主函数
local function main()
    local red = redis:new()
    red:set_timeouts(1000, 1000, 1000)
    local ok, err = red:connect("127.0.0.1", 6379)
    if not ok then
        ngx.log(ngx.ERR, "Redis连接失败: ", err)
        return  -- 降级放行
    end

    local ip = ngx.var.binary_remote_addr
    local current_ts = os.time()
    local ua_hash = get_client_ua_hash()
    local uri = ngx.var.uri

    -- 维度1:IP限流
    local ip_key = string.format("d_limit:%s:%d", ip, current_ts)
    if check_limit(red, ip_key, config.max_req_basic, config.window + 1) then
        ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
    end

    -- 维度2:UA限流
    local ua_key = string.format("d_limit_ua:%s:%d", ua_hash, current_ts)
    if check_limit(red, ua_key, config.max_req_per_ua, config.window + 1) then
        ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
    end

    -- 维度3:URL限流(URL维度不加IP前缀,统计全站访问)
    local url_key = string.format("d_limit_url:%s:%d", uri, current_ts)
    if check_limit(red, url_key, config.max_req_per_url, config.window + 1) then
        ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
    end

    -- 放行
end

main()

4.4 Lua方案的压测对比

同样的压测条件,wrk 100并发,60秒:

方案Redis QPS(次/秒)Nginx单请求处理延迟增加(ms)攻击拦截率
纯Nginx limit_req0(不需要Redis)0只拦截瞬时超速请求,分散请求无效
Lua滑动窗口(每请求一次Redis)单节点8000+平均增加0.6ms能拦截窗口期内超额的持续请求
Lua滑动窗口 + Pipeline批处理(每5个请求批量检测一次)单节点26000+平均增加0.2ms同上,但吞吐量更高

关键结论:加了Lua+Redis之后,单请求多了0.6ms的Redis网络开销,但这0.6ms换来了对慢速CC攻击的精确拦截,非常值。实际压测时,把纯Nginx限流和Lua方案配合,攻击流量能拦截90%以上。

五、方案三实现:灰度发布(基于Nginx分流)

5.1 灰度发布的两种落地方式

灰度发布的核心诉求:把新版本服务只暴露给一小部分用户,观察线上反馈再逐步放量。Nginx下有两种常见做法:

  • 基于IP段灰度:把内网IP、测试IP直接切到新版本,外部用户全走老版本。简单直接,但灰度用户覆盖面太窄(只有公司内部人)。
  • 基于Header/Cookie灰度:在用户的Cookie里种一个标记(如 user_flag=gray),根据标记决定路由到哪个后端。覆盖面可控(比如10%用户),且可以后续动态调整。我推荐这种。

还需要考虑:灰度版本出现故障时,如何快速切回全量版本。Nginx方案是可以直接在配置里修改分流比例然后 reload,但如果是用Lua动态配置,就能通过Redis随时改而不需要重载Nginx。

5.2 基于Cookie的灰度(纯Nginx实现)

# /etc/nginx/conf.d/gray.conf

# 定义灰度分流:10%的用户走灰度
# 方法:通过IP地址hash随机分配
# 使用 split_clients 模块,它可以将客户端IP哈希后按比例映射到不同后端
# 参数说明:
#   $client_ip                -> 用来哈希的变量
#   $gray_upstream            -> 结果赋值给这个变量
#   "10:new"                  -> 10%的请求会落到这个值
#   "*:old"                   -> 剩余请求落的值
split_clients "${remote_addr}" $gray_upstream {
    10.0%    new;
    *        old;
}

# 定义后端upstream
upstream old_version {
    server 192.168.1.11:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

upstream new_version {
    server 192.168.1.12:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;

    location / {
        # 根据split_clients的结果路由到不同upstream
        proxy_pass http://$gray_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        
        # 把实际使用的后端版本传给后端方便调试
        proxy_set_header X-UPSTREAM $gray_upstream;
        
        # 对于灰度版本,附加一个响应头方便debug
        add_header X-Gray-Version $gray_upstream always;
    }
}

reload后,用Curl带不同IP测一下(负载均衡器会覆盖X-Forwarded-For):

# 测试灰度分流是否生效
# 注意:$remote_addr 无法直接模拟,需要借助 header set 或者用 curl 访问时使用不同的源IP
# 用 curl 指定 source ip 测试:
curl --interface 192.168.1.100 http://example.com/ -I
curl --interface 192.168.1.101 http://example.com/ -I

# 观察响应头中的 X-Gray-Version 字段
# 10%的概率会返回 new,90%返回 old
# 可以写个循环测试概率:
for i in $(seq 1 100); do
    curl -s -o /dev/null -D - http://example.com/ | grep -i x-gray-version
done | sort | uniq -c | sort -rn

5.3 更精细的灰度:基于Cookie标记 + Lua动态切换

纯Nginx的split_clients解决的是「随机分配」问题,但实际业务往往需要指定某些用户走灰度。比如线上有个bug,你把某个大客户的Cookie标记为灰度用户,让他先试新功能。这时用Cookie标记更方便:

-- /etc/nginx/lua/gray_switch.lua
-- 灰度判断:优先查看Cookie中的user_flag字段

-- 根据Cookie的值决定上游服务器
-- 返回 "new" 或 "old" 字符串

local function get_gray_upstream()
    -- 取Cookie中的 user_flag
    local cookie = ngx.var.http_cookie or ""
    
    -- 简单解析Cookie中的 user_flag
    local flag = nil
    for key, value in string.gmatch(cookie, "([^=%s;]+)=([^;]+)") do
        if key == "user_flag" then
            flag = value
            break
        end
    end
    
    -- 有明确的灰度标记
    if flag == "gray" then
        return "new"
    end
    
    -- 没有标记时,回退到基于split_clients的随机分配(这一段需要配合nginx的变量)
    -- 这里为了方便,假设ngx.ctx已经传入分流的随机值
    local random_val = ngx.ctx.gray_rand or math.random(100)
    if random_val < 10 then
        return "new"
    end
    
    return "old"
end

ngx.ctx.upstream_backend = get_gray_upstream()
# 配合一个更完整的灰度location配置
# 使用set_by_lua 设置变量,方便在proxy_pass中使用

location / {
    # 通过set_by_lua设置$backend变量
    set $backend "old";
    set_by_lua_block $backend {
        local cookie = ngx.var.http_cookie or ""
        -- 只做简单判断,实际应封装成函数
        if string.find(cookie, "user_flag=gray") then
            return "new"
        end
        
        -- 10%随机灰度
        if math.random(100) < 10 then
            return "new"
        end
        
        return "old"
    }
    
    proxy_pass http://$backend;
    proxy_set_header Host $host;
    add_header X-Gray-Backend $backend always;
}

5.4 灰度发布的回滚机制

灰度必然有风险,出了事故需要在几十秒内把流量全量切回老版本。我提供两个策略:

  • 策略一(纯Nginx):把split_clients的比例直接改到“0% new”,改配置后执行 nginx -s reload。耗时约1-2秒。缺点是会导致正在处理的请求中断。
  • 策略二(Lua + Redis动态控制):把灰度比例存到Redis里,Lua脚本从Redis取。这样不用改配置文件,在Redis里set一个值就完成切换。秒级生效,当前请求不中断。
-- /etc/nginx/lua/redis_gray.lua
-- 基于Redis的灰度开关

local redis = require "resty.redis"

local function get_gray_percent()
    local red = redis:new()
    red:set_timeouts(500, 500, 500)
    local ok, err = red:connect("127.0.0.1", 6379)
    if not ok then
        return 0  -- Redis不可用时切到全量老版本(安全)
    end
    
    -- 灰度比例存在Redis里,比如"10"代表10%
    local percent = red:get("gray_percent")
    if not percent then
        return 0
    end
    
    return tonumber(percent) or 0
end

-- 在access阶段调用
local percent = get_gray_percent()
local random_val = math.random(100)

ngx.ctx.is_gray = (random_val < percent)

-- 可以在请求结束阶段记录灰度日志,方便统计

六、直接能用的完整配置模板

把前面的代码整合起来,给出一份生产环境可用的配置。注意我把Nginx的worker_processes设为4(匹配4核),worker_connections调大以支撑高并发。

# /etc/nginx/nginx.conf 完整示例(精简版)
user nginx;
worker_processes 4;  # 匹配CPU核心数
worker_rlimit_nofile 65535;

events {
    worker_connections 10240;  # 每个worker可处理1万连接
    multi_accept on;
    use epoll;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    
    # 日志格式(包含限流标记)
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for" '
                    'rt=$request_time limit_zone=$limit_req_zone';
    
    # 共享内存区域
    limit_req_zone $binary_remote_addr zone=req_limit:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=req_login:5m rate=2r/s;
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
    
    # upstream定义
    upstream old_version {
        server 192.168.1.11:8080 max_fails=3 fail_timeout=10s;
        keepalive 32;
    }
    
    upstream new_version {
        server 192.168.1.12:8080 max_fails=3 fail_timeout=10s;
        keepalive 32;
    }
    
    # gzip
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;
    gzip_min_length 1k;
    
    server {
        listen 80;
        server_name example.com;
        
        # 全局连接限制
        limit_conn conn_limit 50;
        limit_conn_status 503;
        
        # 超时配置(防慢速攻击)
        client_body_timeout 10s;
        client_header_timeout 10s;
        keepalive_timeout 5s 5s;
        send_timeout 10s;
        
        # 静态资源
        location ~* \.(jpg|jpeg|png|gif|css|js|ico)$ {
            access_log off;
            expires 7d;
            add_header Cache-Control "public";
            limit_conn conn_limit 10;
        }
        
        # 登录接口(更严格)
        location /api/login {
            limit_req zone=req_login burst=5 nodelay;
            limit_req_status 429;
            proxy_pass http://old_version;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
        
        # API接口(灰度 + 限流 + 防CC)
        location /api/ {
            # 基础限流
            limit_req zone=req_limit burst=20 nodelay;
            limit_req_status 429;
            
            # Lua防CC(滑动窗口)
            access_by_lua_file /etc/nginx/lua/sliding_window.lua;
            
            # 灰度分流
            set $backend "old";
            set_by_lua_block $backend {
                local cookie = ngx.var.http_cookie or ""
                if string.find(cookie, "user_flag=gray") then
                    return "new"
                end
                if math.random(100) < 10 then
                    return "new"
                end
                return "old"
            }
            
            proxy_pass http://$backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            
            add_header X-Gray-Backend $backend always;
            add_header X-Limit-Status $limit_req_status always;
        }
        
        # 其他请求走老版本
        location / {
            proxy_pass http://old_version;
            proxy_set_header Host $host;
        }
    }
}

启动并验证配置:

# 测试配置是否正确
nginx -t

# 平滑重载
nginx -s reload

# 查看限流是否生效(观察日志)
tail -f /var/log/nginx/access.log | grep "limit_zone=req_limit"

# 压测验证
# 模拟100并发随机IP(用X-Forwarded-For配合map_real_ip模块)
wrk -t4 -c100 -d60s --latency http://example.com/api/test

七、效果数据:一次完整活动的实测

2025年3月,公司再次搞促销活动,这套Nginx限流+防CC+灰度架构上线。以下是真实数据(有监控截图存档):

指标活动前(未防护)活动后(三层限流+Lua防CC)提升幅度
峰值QPS8200(打崩)8600(稳定运行)+4.9%
后端Tomcat最大线程数打满200线程,大量超时稳定在60-80线程降低60%+
订单成功率38%(大量请求超时)99.2%+61.2%
CPU使用率(Nginx节点)100%(持续)45%降低55%
P99延迟没法测(后端崩溃)22ms-
被拦截的CC请求数0(没有防护)日均拦截约430万次100%

这次活动总请求量约3.2亿,其中Nginx层拦截了约1.8亿次异常请求(限流+滑动窗口),真正到达后端的请求约1.4亿,后端完全能扛住。

八、避坑指南:这些坑我全踩过

8.1 limit_req 的“平均速率”陷阱

很多人以为 rate=10r/s 是“每100ms放行一个请求”。大错特错。Nginx官方文档里写的是:rate是令牌桶的填充速率,但是令牌是每毫秒填充一次(即每毫秒0.01个令牌)。如果你只配置了rate没有配置burst,那么请求到达时如果没有令牌就直接拒绝,即使同一毫秒内来了2个请求,第二个也直接被拒了。

所以生产环境必须配置burst。我见过有人只配置rate,结果所有带小突发的正常用户请求全部被拒,页面加载CSS和JS都会触发限流。

8.2 limit_req 的“延迟模式”会导致请求堆积

不配置 nodelay 时,超出速率但不超过burst的请求会被“延迟处理”(排队)。这意味着:如果rate=10r/s,burst=20,那么第15个请求来了会被延迟1秒再转发。如果前端有个页面并发请求了15个API,那么最后一个请求要等1秒。整个页面体验极其糟糕

而且,延迟模式会让Nginx把请求放在队列里,一旦队列满了,后面的请求会被立即拒绝。配合上超时,最终效果是接口变得“时好时坏”。建议动态API接口全部用nodelay,把突发交给后端自己处理,或者用Lua在入口做平滑限流。

8.3 limit_conn 不能限制 HTTP/2 连接

如果你的站点开启了HTTP/2,那么一个TCP连接可以承载多个并发流(Stream)。limit_conn统计的是TCP连接层,不是Stream层。所以攻击者用HTTP/2的multiplexing,一个连接就能同时发起大量请求,limit_conn直接失效。

解决:如果是Nginx 1.25.4,用 limit_conn 配合 http2_max_concurrent_streams 控制。但更彻底的方法是在Lua层做请求级限流(比如上面说的滑动窗口)。

8.4 Redis故障时的降级策略

Lua脚本里Redis挂了怎么办?如果直接返回“放行”,那CC攻击就进来了;如果返回“拒绝”,那所有正常用户都被拒了。我推荐降级到Nginx基础limit_req,至少挡住瞬时超速的请求。

-- 降级策略代码示意
-- 在Redis连接失败时,使用简单的令牌桶(Nginx本身就有的)
-- 这里通过设置ngx.ctx标记,然后return 直接结束Lua执行
if not ok then
    ngx.log(ngx.ERR, "Redis连接失败,降级到纯Nginx限流")
    -- 直接退出Lua,让后面的limit_req继续处理
    return
end

8.5 灰度分流时,注意 keepalive 的坑

如果你用了 upstream keepalive 32,并且在灰度中动态切换backend(如 proxy_pass http://$backend),会出现一个隐蔽的错误:Nginx无法为动态变量选择的upstream建立keepalive连接池

报错信息类似:no resolver defined to resolve "old"upstream "old" may not be a valid upstream

原因:当upstream名称是通过变量动态指定时,Nginx会把它当成“域名”而不是“upstream配置块”,需要走DNS解析,因此不会使用keepalive连接池。

解决办法:

  • 方法一:不使用变量指定upstream,而是写两个location分别对应新旧版本,通过rewrite或条件跳转。缺点:配置冗长。
  • 方法二:用 set $backend "old" 但是upstream名称写死为 upstream_upstream 并配置 resolver,让Nginx把变量当成域名解析。实际上我们内网没有DNS,就用方法一。
  • 方法三(推荐):用OpenResty的balancer_by_lua阶段实现动态路由,既能keepalive又能动态切换。

8.6 别忘记更新监控告警

加了限流后,如果限流生效,你的P99延迟会下降,错误率会上升(比如429)。记得把“429状态码比例”加到监控里,否则你看到错误率上涨会误以为系统故障。

我建议的告警规则:

  • QPS 1分钟内平均限流比例超过30% -> 告警,可能是被攻击
  • 429状态码的错误率持续5分钟超过1% -> 告警,检查阈值是否太严格

8.7 大促前必须做全链路压测

所有限流参数必须通过压测验证,不要想当然。我建议至少压测3个场景:

  • 正常流量(预期峰值的80%并发)
  • 突发流量(模拟抢购瞬间的2倍峰值)
  • 恶意流量(模拟单IP高频、多IP低频CC)

压测过程中建议记录Nginx的 limit_req_zone 的命中数,可以在响应头里带上 $limit_req_status 变量(Nginx 1.18+支持)输出状态。

# 查看Nginx配置中限流状态的变量
# 在location中添加add_header X-Limit-Status $limit_req_status always;
# 然后压测时观察响应头:
curl -I "http://example.com/api/test?X-Limit-Status=true"
# 返回 X-Limit-Status: 1 表示被限流,0表示正常

九、总结:这套方案能干嘛

这套Nginx企业级配置,能帮你做到:

  • 单节点稳定扛住1万+QPS,通过横向扩容轻松提升到10万+
  • 自动拦截90%以上的CC攻击(脚本刷接口、高频爬虫)
  • 灰度发布秒级生效,事故时快速回滚
  • 后端服务压力降低60%以上,不用频繁扩容

最后提醒一句:Nginx限流只是第一道防线,如果业务逻辑有严重性能问题(比如慢SQL),Nginx层的限流只能做到“进不来”,但进来的请求还是会拖垮数据库。所以,Nginx限流 + 业务性能优化 + 监控告警,三者缺一不可。