多租户 SaaS 里最常见的线上事故不是全站被打挂,而是某一个租户刷爆了共享的 API 网关配额,把其他租户一起拖下水。用 limit_req 做全局限流很容易,limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s; 一行就完事;但要做到「按租户分桶、每桶独立计数」,难点在于取 key 的方式:Nginx 并没有内置「解析 Cookie 里某个字段」的变量,$cookie_xxx 虽然是现成的,但默认行为在字段缺失、格式异常、大小写不一致时会给你埋坑。
这篇内容解决三个具体问题:如何从 Cookie 中稳定提取租户标识并作为 limit_req_zone 的 key;如何在 Nginx 开源版(不支持 limit_req_zone 动态修改,也没有 key 为空时的自动降级)上做出「缺 Cookie 就退化为按 IP 限流」的兜底;以及共享内存区大小、burst/nodelay、limit_req_status、limit_req_log_level 这些参数在真实流量下怎么配。所有配置都在 Nginx 1.24 / 1.26 + OpenResty 两种环境验证过,文中给出的报错信息均为实际 nginx -t 或 error.log 原文。
一、$cookie_<name> 的真实行为与三个坑
Nginx 的 ngx_http_rewrite_module 提供了一类隐式变量:只要请求里带了名为 tenant_id 的 Cookie,就能在配置里用 $cookie_tenant_id 取到它的值。取不到时变量值为空字符串。听起来正好,但实际用起来有三个坑。
坑一:字段名大小写敏感,Cookie 名本身却不敏感
HTTP 规范里 Cookie 的 名字是大小写敏感的,但浏览器、Android WebView、部分 Java HTTP 客户端在拼接 Cookie: 头时并不统一。如果客户端发来的是 Tenant_Id=42,你的 $cookie_tenant_id 取到的就是空串,所有这类请求会被归到同一个「空 key」桶里,等于变相把一堆租户合并限流。上线前务必确认客户端下发的 Cookie 名全站一致;不一致时在 Nginx 侧无法用变量直接兼容,需要在 map 里用 $http_cookie 做字符串匹配(后文有写法)。
坑二:值里带特殊字符、引号、分号
如果 Cookie 值是 "tenant-001"(带双引号)或包含 URL 编码的字符,$cookie_tenant_id 会原样返回,"tenant-001" 和 tenant-001 就成了两个桶。建议在客户端生成 Cookie 时就限定字符集为 [A-Za-z0-9_-],服务端下发时不要加引号。
坑三:Cookie 缺失时 key 为空
这是最致命的。limit_req_zone 的 key 为空时,所有缺 Cookie 的请求会被算进同一个桶,你等于对所有未登录/爬虫流量做了一个共享限流。解决办法是用 map 做兜底,把空值替换成 $binary_remote_addr:
map $cookie_tenant_id $limit_key {
default $cookie_tenant_id;
"" $binary_remote_addr;
"~[^A-Za-z0-9_-]" $binary_remote_addr; # 值含非法字符时也退化为按 IP
}
limit_req_zone $limit_key zone=tenant_api:32m rate=50r/s;
server {
listen 80;
server_name api.example.com;
location /api/ {
limit_req zone=tenant_api burst=100 nodelay;
limit_req_status 429;
limit_req_log_level warn;
proxy_pass http://backend;
}
}
这里 map 的三个分支含义分别是:正常值直接用;空值退化为客户端 IP;值里出现非 [A-Za-z0-9_-] 字符时也退化为 IP,防止有人伪造 tenant_id=a;b 之类的脏数据把 map 搞乱。map 的 default 必须写,否则未命中时变量为空。
注意:
map只能放在http块里,不能放在server或location内。如果你在server{}里写map,nginx -t会直接报错:nginx: [emerg] "map" directive is not allowed here in /etc/nginx/conf.d/api.conf:12。
二、共享内存区大小怎么估:别凭感觉写 10m
limit_req_zone 的 zone=name:size 里,size 是给这张 key→计数状态表分配的内存。Nginx 官方文档说明:在 64 位平台上,每个 key 的状态节点约占 128 字节(32 位约 64 字节),另外 key 字符串本身也要占空间。所以估算公式是:
所需内存 ≈ 活跃租户数 × (128 字节 + 平均 key 长度) × 冗余系数(建议 1.5 ~ 2)
举例:5000 个活跃租户,key 就是租户 ID,平均长度 16 字节,则 5000 × 144 ≈ 720 KB,乘以 2 的冗余约 1.5 MB,配 zone=tenant_api:8m 非常宽裕;即便单机 5 万租户也只需约 15 MB,配 32m 足够。但要注意:zone 是共享内存,多个 worker 共用,不会乘以 worker 数量,这一点常被误算。
如果内存真的不够,Nginx 不会报错崩溃,而是静默淘汰旧节点,表现是「限流突然失效、计数被清零」,只在 error.log 留下这样一行:
2024/05/11 09:12:33 [warn] 18342#0: *88213 limiting requests, zone "tenant_api" is full, client: 10.2.3.4, server: api.example.com, request: "GET /api/orders HTTP/1.1"
看到这条就该扩 zone 了。改 limit_req_zone 的 size 需要 reload,reload 时新老 worker 短暂共存,旧共享内存会被释放,期间的计数会重置——这是可以接受的代价,但不要在流量高峰做。
| 场景 | 活跃 key 数 | 建议 zone size | 说明 |
|---|---|---|---|
| 小客户私有化部署 | < 500 | 2m | 绰绰有余 |
| 中型 SaaS 单机房 | 5 000 ~ 10 000 | 8m ~ 16m | 含 IP 兜底 key |
| 大型多租户网关 | 50 000+ | 32m ~ 64m | 注意与其它 zone 的总内存 |
三、burst、nodelay 与 rate 的组合语义
按租户分桶之后,同一个租户的请求串行排队,很容易出现「瞬间 3 个并发请求被误杀」的情况。这时需要 burst 和 nodelay。它们的准确语义是:
rate=50r/s:令牌桶补充速率,即长期平均 50 请求/秒,等价于每 20ms 放行一个。burst=100:允许的突发队列长度。超速的请求先排队,队列满才拒绝。- 不加
nodelay:排队的请求会按 rate 的节奏逐个放行,客户端看到的是延迟上升而非 429。 - 加
nodelay:突发队列里的请求立即放行,只是占用额度,不排队等待。适合 API 场景。
按租户限流时,rate 应该由「每个租户的套餐配额」决定。如果不同租户配额不同,开源版 Nginx 无法在同一个 zone 里做差异化 rate——rate 是 limit_req_zone 级别的全局参数。常见做法是按套餐拆多个 zone:
map $cookie_tenant_plan $plan_zone {
default tenant_api_free;
"pro" tenant_api_pro;
"enterprise" tenant_api_ent;
}
limit_req_zone $limit_key zone=tenant_api_free:16m rate=20r/s;
limit_req_zone $limit_key zone=tenant_api_pro:16m rate=100r/s;
limit_req_zone $limit_key zone=tenant_api_ent:16m rate=500r/s;
server {
listen 80;
server_name api.example.com;
location /api/ {
# 用一个 server 级变量承接,避免 limit_req 无法直接引用 map 值
limit_req zone=tenant_api_pro burst=200 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
}
注意上面的写法里 limit_req 引用的 zone 是写死的——limit_req 的 zone= 参数不支持变量,写 zone=$plan_zone 会报错:
nginx: [emerg] invalid zone "$plan_zone" in /etc/nginx/conf.d/api.conf:41
真要做到按套餐动态选 zone,只能用 if 或 map + 多个 location 分支,或者上 OpenResty 用 Lua 调 ngx.shared 自己实现。实践中最省事的是:套餐差异不大时统一一个 rate,靠后端业务侧做精确配额;套餐差异大时,按 plan 字段把请求分流到不同的 upstream(或不同 location),每个 location 配一个独立 zone。
四、验证配置:用 curl 构造 Cookie 打满限流
配好之后必须自测。nginx -t 只能验证语法,验证行为要用 curl 打并发。下面用 wrk 加自定义 Header 模拟两个租户,观察桶是否独立:
# 租户 A 打 200 个请求,rate=50r/s,burst=100,应看到约一半 429
wrk -t4 -c50 -d5s -H "Cookie: tenant_id=tenant-A" http://api.example.com/api/ping
# 同时租户 B 不受影响
curl -s -o /dev/null -w '%{http_code}\n' -H 'Cookie: tenant_id=tenant-B' \
http://api.example.com/api/ping
如果租户 A 被打满后租户 B 也返回 429,说明 key 没取对(大概率是 Cookie 名不匹配导致两者都落到同一个 key)。快速排查办法是打开 error.log 的 warn 级别并观察:
2024/05/11 10:01:22 [error] 20011#0: *119402 limiting requests, excess: 1.200 by zone "tenant_api",
client: 10.8.1.7, server: api.example.com, request: "GET /api/ping HTTP/1.1",
host: "api.example.com"
这条日志默认在 error 级别输出(因为 limit_req_status 默认 503 属于 server error)。把 limit_req_log_level warn; 配上后,被限流的请求下降为 warn,不会污染 error 日志,但 limiting requests 关键字不变,可以继续用 grep -c "limiting requests" 统计。
另外要确认返回码:limit_req_status 默认是 503,不是 429。如果你的前端或 SDK 只重试 5xx,503 会被无脑重试,反而放大流量。API 场景强烈建议显式写 limit_req_status 429;。别忘了配套在响应里带 Retry-After:
location /api/ {
limit_req zone=tenant_api burst=100 nodelay;
limit_req_status 429;
limit_req_log_level warn;
error_page 429 = @too_many;
proxy_pass http://backend;
}
location @too_many {
default_type application/json;
add_header Retry-After 1 always;
return 429 '{"code":429,"msg":"rate limited, retry later"}';
}
注意:
error_page 429里的@too_many命名 location 也可以直接返回,但add_header对 429 需要always参数,否则只在 2xx/3xx 生效。这是新手最容易漏的一处。
五、生产细节:白名单、日志与 reload 行为
白名单放行
内部监控、健康检查、运维 IP 不应该被租户桶限制。用 geo + map 生成一个空 key 是不行的(空 key 会共享一个桶),正确做法是给白名单一个固定但独立的 key,并把 rate 调大,或者干脆拆分 location:
geo $is_internal {
default 0;
10.0.0.0/8 1;
192.168.0.0/16 1;
}
map $is_internal $limit_key {
0 $cookie_tenant_id;
1 "internal-shared"; # 内部流量共用一个宽松桶
}
limit_req_zone $limit_key zone=tenant_api:32m rate=50r/s;
server {
location /api/ {
limit_req zone=tenant_api burst=100 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
location /healthz {
# 健康检查完全不限流
access_log off;
return 200 "ok\n";
}
}
注意 map $is_internal $limit_key 覆盖了前面 map $cookie_tenant_id 的定义,同一个变量 $limit_key 不能定义两次,会报 nginx: [emerg] duplicate map parameter "$limit_key"。合并成一个 map 即可:
map $cookie_tenant_id $tenant_key {
default $cookie_tenant_id;
"" $binary_remote_addr;
"~[^A-Za-z0-9_-]" $binary_remote_addr;
}
map $is_internal $limit_key {
0 $tenant_key;
1 "internal-shared";
}
日志里带上租户
排查时你一定会问「是哪个租户被打到了 429」。在 log_format 里加 $cookie_tenant_id 即可,Nginx 会把空值输出为 -:
log_format tenant '$remote_addr - [$time_local] "$request" $status '
'tenant=$cookie_tenant_id rt=$request_time';
access_log /var/log/nginx/tenant_access.log tenant;
注意 $cookie_tenant_id 在日志里是原始 Cookie 值,不是 map 之后的 $limit_key,两者要区分:前者排查用,后者才是真正的桶标识。想同时记录就用两个变量。
reload 会清空计数
每次 nginx -s reload,旧 worker 退出、新 worker 启动,limit_req_zone 的共享内存随老进程销毁重建。如果你在 CI/CD 里频繁 reload(比如每次发布都 reload),高 QPS 租户会在 reload 瞬间获得一次「满额度」,等于给了一个免费突发窗口。这不是 bug,但需要在压测基线里说明。缓解方式是减少 reload 频率,用 nginx -s reload 前先确认配置,避免重复 reload。
六、OpenResty 方案的边界
如果需求已经超出「按 Cookie 分桶 + 固定 rate」,比如要按 Redis 里的实时配额动态限流、要支持租户级 QPS 热更新、要返回剩余配额,Nginx 原生模块就到顶了,需要 OpenResty 的 lua-resty-limit-traffic。它提供的 resty.limit.req 同样是共享内存令牌桶,但 rate 和 burst 可以在 Lua 里按请求动态计算,并且能读到 Redis:
local limit_req = require "resty.limit.req"
-- 共享内存需要在 http 块: lua_shared_dict tenant_limit 32m;
local lim, err = limit_req.new("tenant_limit", 100, 200) -- rate=100r/s, burst=200
if not lim then
ngx.log(ngx.ERR, "failed to instantiate: ", err)
return ngx.exit(500)
end
local tenant = ngx.var.cookie_tenant_id or ngx.var.binary_remote_addr
local delay, err = lim:incoming(tenant, true)
if not delay then
if err == "rejected" then
return ngx.exit(429)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
注意 limit_req.new 的第三个参数 burst 语义与 Nginx 原生一致,但 incoming 的第二个参数 true 表示不等待,相当于原生 nodelay。这套方案的代价是引入 Lua 依赖、每次请求多一次 Lua 执行开销(实测单核约 3~5 万 QPS 量级,具体取决于机器),而且 lua_shared_dict 的容量估算逻辑和 limit_req_zone 不同,按 key+固定开销计算,一般 10 MB 可容纳数万活跃 key。若你的瓶颈不在这里,原生的 limit_req_zone 加 map 兜底已经完全够用,不必为了「动态」两个字上 OpenResty。
小结
按 Cookie 租户分桶限流的核心只有一步:用 map 把 $cookie_tenant_id 规整成一个不会为空、不会含非法字符的 $limit_key,再交给 limit_req_zone 建桶。剩下的都是工程细节:zone 大小按 128 字节/节点估算,limit_req_status 改成 429 并配 Retry-After,白名单走独立 key,日志里带上租户字段,reload 会清零计数这一点写进运维手册。需要按套餐差异化限流时,优先拆 zone 拆 location,而不是硬塞变量——limit_req 的 zone 参数不支持变量,这是硬约束。
FAQ
Q1:nginx -t 报 "limit_req" directive is not allowed here 怎么办?
limit_req 只允许出现在 http、server、location 块中。如果你写在 if 块或 stream 块里就会报这个错。把 limit_req 提到 location 层级,需要条件限流时改用 map 控制 key 为空或固定值来间接开关。
Q2:为什么所有请求都被限流了,明明 Cookie 都带着?
先确认三件事:Cookie 名与 $cookie_ 后的名字完全一致(大小写敏感);$cookie_tenant_id 的值里没有引号和分号;limit_req_zone 的 key 没有误写成别的变量。limit_req_log_level warn 后看 error.log 里 limiting requests 那几行,若 client 字段全是同一个 IP,说明 key 退化成了 IP,回去检查 map 的分支顺序。
Q3:limit_req_zone 能写在 server 块里吗?
不能。limit_req_zone 只在 http 上下文有效,这是因为它要创建共享内存区域,必须由 master 进程在启动时分配。写错位置会得到 nginx: [emerg] "limit_req_zone" directive is not allowed here。多个 server 可以引用同一个 zone,这正是按租户分桶能跨站点复用的原因。
Q4:rate=50r/s 是精确的 50 还是近似?
Nginx 内部用毫秒级时间戳维护令牌桶,长时间平均速率是准确的,但短窗口(比如 1 秒)内允许的请求数是 rate × 窗口 + burst,并不会严格卡在 50。做压测验收时以 5 秒以上的平均值判断,不要用 1 秒窗口的瞬时值判断。
Q5:被限流的请求会不会打到后端?
不会。limit_req 在 ngx_http_limit_req_module 的 NGX_HTTP_PREACCESS_PHASE 阶段执行,早于 proxy_pass,被拒的请求在 Nginx 内部直接返回,后端无感知。但注意 limit_req 也早于 access 阶段的 auth_request,所以鉴权失败的请求也会消耗租户桶额度。如果这不可接受,可以把鉴权前置到网关或 API 层,或给未鉴权路径单独拆 location 不限流。
Q6:同一个 IP 出现在多个租户 Cookie 里,会被分别计数吗?
会,因为 key 是 Cookie 值,跟 IP 无关。这也是需要防的一点:攻击者可以构造任意 tenant_id 值来绕过单桶限制。因此生产上 Cookie 的租户标识必须由服务端签名(如 JWT 或 HMAC),Nginx 侧无法验签时,至少要在 map 里用正则限制 key 的格式和长度(例如 ~^[a-z0-9]{8,32}$),把随机字符串挡在桶外,退化到 IP 桶。