裸金属 192 核上 Nginx 限流集体失效:shared zone 的 slab 分配账,1MB 到底装多少个 key
上周帮一个客户复盘一起刷单事故。反代是两台裸金属,2×96C / 512G / 25G 双口,limit_req 配的是 200r/s,一年没动过。攻击峰值打到单机 60 万 PPS,限流一点反应没有,业务侧 PHP 全挂。
翻 error.log,在凌晨 3 点那段翻到几百条:
2026/05/12 03:14:22 [emerg] 8123#8123: ngx_slab_alloc() failed: no memory in shared zone "perip"
就这一行。没有 503,没有告警,访问日志里 $limit_req_status 一路 PASSED。这不是配置写错了,是 zone 用光了之后 limit_req 的默认行为就是放行。要理解为什么,得从 slab 分配器怎么切页开始算。
一、slab 在 2048 字节处拐了个弯
Nginx 每个共享内存 zone 就是一块 mmap(MAP_ANON|MAP_SHARED),头部是 ngx_slab_pool_t,后面由 ngx_slab 自己管。初始化时算三个全局常量,编译期就定死了:
/* src/core/ngx_slab.c ngx_slab_init(),x86_64 + 4K page 环境下 */
pool->min_shift = 3; /* 最小 slot = 8B */
ngx_slab_max_size = ngx_pagesize / 2; /* 4096/2 = 2048B */
ngx_slab_exact_size = ngx_power_of_two(ngx_slab_max_size / (8 * sizeof(uintptr_t)));
/* 2048/64 = 32B */
ngx_slab_exact_shift = ngx_log2(ngx_slab_exact_size); /* = 5 */
分配路径因此分成三段,没有任何一段会跨越 2048 这条线去复用:
- size ≤ 32(shift 3/4/5):一页 4K 里的 slot 数 ≤ 512,bitmap 用一个
uintptr_t(8 字节)就能放下 64 个状态位,所以直接把 bitmap 指针塞进页描述结构里。 - 32 < size ≤ 2048:按 2 的幂向上取整到 slot(64 / 128 / 256 / 512 / 1024 / 2048),bitmap 只覆盖 8 个 slot,占 1 字节。
- size > 2048:不进 slot,
ngx_slab_alloc_pages()直接按整页从 page 链表上切:
if (size > ngx_slab_max_size) {
page = ngx_slab_alloc_pages(slab,
(size >> ngx_pagesize_shift) + ((size % ngx_pagesize) ? 1 : 0));
...
}
这是第一个关键点:2049 字节的分配占用一整页 4096,浪费 50%。如果你见过有人用 limit_req_zone $request_uri 做接口级限流,1MB 的 zone 只能装 250 条 URI,几十秒打满,然后开始静默放行。这种配置在测试环境完全正常,因为 key 少。
二、一条 key 到底吃掉多少字节
看 limit_req 真正申请的内存:
/* src/http/modules/ngx_http_limit_req_module.c */
typedef struct {
u_char color;
u_char dummy;
u_short len;
ngx_queue_t queue; /* 16B */
ngx_msec_t last; /* 8B */
ngx_uint_t excess; /* 8B */
ngx_uint_t count; /* 8B */
u_char data[1]; /* key 变长,跟在后面 */
} ngx_http_limit_req_node_t;
/* lookup 里创建节点 */
len = offsetof(ngx_http_limit_req_node_t, data) + key->len; /* offsetof = 48 */
node = ngx_slab_alloc_locked(ctx->shpool, len);
所以实际消耗 = 48 + key 长度,再向上取整到 2 的幂 slot。代入几种常见写法:
$binary_remote_addr(IPv4,4 字节):48+4 = 52 → 占 64B slot$binary_remote_addr(IPv6,16 字节):48+16 = 64 → 占 64B slot(刚好卡线)$arg_uid(16 位邀请码):48+16 = 64 → 占 64B slot$arg_uid换成 17 位、或 key 里拼了个前缀:48+17 = 65 → 跳到 128B slot
最后一行就是标题说的"腰斩"。key 从 16 字节变成 17 字节,zone 容量直接减半,配置上完全看不出来,nginx -t 也不会吭声。
1MB 到底装多少
翻一下 ngx_slab_init() 里给页描述数组的预留:1MB zone = 256 个 4K 页,ngx_slab_page_t 在 64 位下 24 字节,256×24 = 6144 字节,加上头部和页对齐,实际可分配约 250 页。于是:
1MB zone ≈ 250 页 × (4096 / slot_size)
slot 64B → 250 × 64 ≈ 16000 条
slot 128B → 250 × 32 ≈ 8000 条
slot 256B → 250 × 16 ≈ 4000 条
整页 4096B → 250 × 1 ≈ 250 条
官方文档里"1MB 大约处理 16000 个 key"这句话,就是 $binary_remote_addr 走 64B slot 那一行的结果,不是通用公式。换成变长 key 之后,这个数字不再成立。
现场还原一下:客户配的是 limit_req_zone $binary_remote_addr zone=perip:16m rate=200r/s;,16MB ≈ 25.6 万条。攻击方手里握着 /15 的 IPv4 段,纯伪造源 IP,8 万多个 key 就灌满了,从开始打到 zone 满大概 90 秒。
三、满了之后为什么是放行而不是拒绝
看 handler 尾部的分支:
if (rc == NGX_BUSY) {
/* 真正超限的路径 */
r->main->limit_req_status = NGX_HTTP_LIMIT_REQ_REJECTED;
return NGX_HTTP_SERVICE_UNAVAILABLE; /* 503 */
}
/* rc == NGX_ERROR || rc == NGX_OK */
if (rc == NGX_OK) {
r->main->limit_req_status = NGX_HTTP_LIMIT_REQ_PASSED;
}
return NGX_DECLINED; /* 放行 */
ngx_slab_alloc_locked() 返回 NULL 时,lookup 返回 NGX_ERROR,落到注释写着 /* rc == NGX_ERROR || rc == NGX_OK */ 那个分支,直接 NGX_DECLINED 走人。限流状态变量不会被置成 REJECTED,$limit_req_status 仍然是 PASSED。这就是为什么告警和日志双盲:你盯着 503 数量和 status 变量,两边都是零。
唯一的信号就是那行 emerg 日志。而很多环境下 error_log 被 logrotate 按天切、被 rsyslog 限速、或者干脆设成 error_log /dev/null crit;,这行就彻底消失了。
顺带一提,limit_conn 的路径不一样,节点分配失败时返回 NGX_ERROR,handler 会给出 500。所以同样的 zone 饥饿,limit_req 静默放行、limit_conn 报 500,两个模块的失败姿态完全不同,别指望它们行为一致。
四、裸金属多核把这个问题放大两层
4.1 一把 shmtx 锁,192 个 worker 抢
zone 是 master 进程 mmap 之后 fork 继承的,所有 worker 共享同一块内存,也共享同一个 ngx_shmtx_t。ngx_http_limit_req_lookup() 里每次查找都是 ngx_shmtx_lock() → 改树/改队列 → ngx_shmtx_unlock()。高 PPS 场景下这就是一个全局串行点:
perf record -F 999 -g -p $(pgrep -f 'nginx: worker' | paste -sd, -) -- sleep 30
perf report --stdio | grep -E 'ngx_shmtx_lock|sched_yield|ngx_http_limit_req' | head
如果 sched_yield 和 ngx_shmtx_lock 占了两位数的采样,说明锁已经在限制你。pgrep -f 'nginx: worker' 别忘了带 -f,worker 进程的 comm 是 "nginx",光靠 pidof nginx 抓不到全部。
缓解手段是拆 zone:每个 limit_req_zone 声明都对应独立的 ngx_slab_pool_t 和独立 mutex。把"按 IP"和"按 uid"拆成两个 zone,锁碰撞立刻降一个量级,代价只是各占一份内存。
4.2 reload 改不大 zone
这个坑每次扩容都会踩。zone 在 ngx_shared_memory_add() 里按 name + tag + size 三元组匹配,size 不一致直接 emerg 退出:
$ nginx -t
nginx: [emerg] the size 33554432 of shared memory zone "perip" conflicts with
already declared size 16777216 in /etc/nginx/nginx.conf:41
也就是说 把 16m 改成 32m,nginx -s reload 一定失败,必须 stop/start 或者走二进制热升级。在裸金属上重启一次 nginx 意味着 25G 网卡上所有长连接断开、TLS 握手风暴、upstream 连接池全废。所以 zone 容量必须在第一次上线时就按峰值 key 数算足,别指望后面热调。
另外,同一个 zone 名字被多个 server 块引用且 size 写得不一致,也是同一个报错,但行号指向第二个 server 块,很容易看漏。
五、正确的容量算法和现场验证
5.1 按 key 数倒推,不按请求数
# 峰值独立源 IP 20 万,key 用 $binary_remote_addr
# 单节点 = offsetof(data)=48 + 4 = 52 → slot 64B
# 1MB ≈ 250 页 × 64 = 16000 条
# 200000 / 16000 ≈ 12.5MB,留 2 倍冗余应付突发扫描段
limit_req_zone $binary_remote_addr zone=perip:32m rate=200r/s;
limit_req_zone $binary_remote_addr zone=perconn:16m rate=50r/s; # 独立锁
limit_req zone=perip burst=400 nodelay;
limit_req zone=perconn burst=50 nodelay;
如果是变长 key,先把"48 + 平均 key 长度"算出来,确认它有没有跨过 64/128/256 这条线。宁可把 key 补齐成定长,也别让它随机落在两条 slot 中间。
5.2 打满之前先抓现行
开源版没有 zone 使用率指标,只能靠压测复现 + 日志。worK 一个 Lua 脚本就能造出海量 key:
-- keyflood.lua,wrk -t16 -c2000 -d120s -s keyflood.lua http://127.0.0.1/
request = function()
return wrk.format("GET", "/?uid=" .. math.random(1, 300000))
end
配合一个只看 status 的 log_format:
log_format limit '$remote_addr [$time_local] "$request" $status '
'req=$limit_req_status conn=$limit_conn_status';
# 压测期间观察
tail -F /var/log/nginx/limit.log | awk '{print $5, $6}' | sort | uniq -c
# 直接抓 error.log 里的 zone 饥饿
grep -c 'ngx_slab_alloc() failed' /var/log/nginx/error.log
$limit_conn_status 和 $limit_req_status 都是 1.17.6 之后才有,老版本请先升。上一份压测用的就是轻云互联的双路裸金属(2×96C / 512G / 25G 双口),物理核和逻辑核的差异、NUMA 跨节点访问延迟顺手一起验了——slab 这块和 NUMA 无关,但同一套机型上跑出来的 PPS 数据比较有参考价值。
5.3 集群场景还要再乘一次
上面所有账都是单机的。反代集群 8 台、每台 rate=200r/s,攻击方轮询打过来,实际放行 1600r/s。zone 满了之后更夸张,直接无上限。开源版没有商业版的 zone sync(1.15.3+ 才有,且要 nginx plus),唯一的办法是把单机 rate 按 1/N 配,或者在最前面放一层集中式限流。
六、Apache 那边是同一笔账
用 Apache 的别以为能躲开。mod_qos 的 QS_ShmSize(默认 50M)走 apr_shm,同样是定长块哈希表,满了之后 qos_shm 拿不到槽位,限流规则直接跳过,日志要在 QS_LogLevel 4 以上才看得见。mod_evasive 的 DOSPageCount/DOSHashTableSize 同理,表项数超过 DOSHashTableSize 就按链表覆盖,老的记录被挤掉,攻击者反而"洗白"。
至于 mod_ratelimit,它压根没有共享状态,纯按连接算带宽,做不了按源 IP 的频次控制。要用 Apache 做这件事,mod_qos 基本是唯一选择,容量规划方式和上面完全一样:先算 key 数,再乘单条表项大小,最后留冗余。
收尾:三条能直接照抄的结论
- zone 大小按 key 数算,不按 QPS 算。公式:
ceil(48 + key_len)向上取整到 2 的幂 → 得 slot 大小 →key 数 × slot / 4096 + 20%页数 → 转 MB。 - key 一律用定长的
$binary_remote_addr。用$request_uri、$arg_*、带变量的拼接 key,要么单条蹦进整页分配,要么正好卡在 slot 边界上随机腰斩,两种都没法预估。 - 把 zone 饥饿做成监控项。开源版能用的就一条:定时
grep 'ngx_slab_alloc() failed' error.log,命中就告警。没有它,你的限流在被打穿的那一刻,是彻底静默的。