一、凌晨两点的惨案:抢购系统挂了
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) | 错误率 |
|---|---|---|---|---|
| 无防护 | 8200 | 26 | 180 | 0%(但后端已经完全崩溃) |
| 仅limit_req(10r/s) | 10 | 15 | 40 | 99.5%(大量请求被拒绝) |
| 三层限流(req+conn+rate) | 1050 | 3.2 | 8 | 0.1% |
| 三层限流+burst优化 | 1680 | 2.8 | 6 | 0%(通过调大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_req | 0(不需要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) | 提升幅度 |
|---|---|---|---|
| 峰值QPS | 8200(打崩) | 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限流 + 业务性能优化 + 监控告警,三者缺一不可。