⭐ 推荐:社区规则条款 V1.0

流量消耗过快如何排查与处理?

chinaz
chinaz 初级会员萌新站长
发布于 2026-10-25 07:11 ·4 浏览 ·11 回复
内容摘要

流量消耗过快需先用 Nginx 日志按 IP 与 URL 聚合定位来源,区分恶意刷量与业务增长,再封禁 C 段并在 Nginx 限流,最后压缩图片附件、启用 gzip、CDN 与防盗链,顺序颠倒会反复复现。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2525.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
GJ论坛站长

全部回复 11

周末
周末 萌新站长 1楼 2026-10-25 07:22

【结论】四步顺序没问题,我只补三个最容易翻车的细节:日志字段要先核对、封禁用 ipset 别硬堆 iptables、限流要配 limit_conn 和状态码。

先说要核对字段的地方。$10 只在标准 combined 格式下成立,一旦你在 log_format 里前面插了 $host、$request_time,字段就整体错位,聚合出来的数字毫无意义。跑之前 head -1 access.log 数一遍位置最省事。另外 $body_bytes_sent 不含响应头,想验证压缩效果得拿 $bytes_sent 对比着看。日志按天轮转时别直接 cat 一堆大文件进去,用 zcat access.log.*.gz | awk ... 分批更稳。

封禁这块,IP 分散时逐条 iptables -I 会让规则链膨胀、内核匹配变慢,改用 ipset create ban hash:net 建集合,一条 iptables 规则引用它,之后增删都是常数开销。Nginx 侧建议 limit_req 和 limit_conn 一起上:前者限速率、后者限并发,再配 limit_req_status 429、limit_conn_status 503,日志里一眼能分清是被限速还是被限连接。用 map 拦 UA 时记得给默认值 0,否则限流变量为空直接报错。

静态资源部分,防盗链用 valid_referers none blocked server_names *.gj0.com; 配 if ($invalid_referer) { return 403; },附件尽量走独立域名或 CDN,回源带宽单独统计,能马上看出是不是有人循环拉下载。顺带一提,论坛的搜索限流支持按登录账号和游客分别计数,填 0 是不限次数,别图省事留 0。

最后一个坑:只看 access.log 会漏掉被 CDN 挡掉的那部分,得和 CDN 流量报表对着看;大文件下载入口加个 limit_rate 512k,比事后封 IP 省心得多。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2710 2楼 2026-10-25 07:36
周末:【结论】四步顺序没问题,我只补三个最容易翻车的细节:日志字段要先核对、封禁用 ipset 别硬堆 iptables、限流要配 limit_conn 和状态码。 …

ipset 和 limit_conn 这两条都是踩过坑才知道的,尤其集合持久化那步不补,重启一次就全白干。

先说 ipset:ipset create 建的集合重启后会消失,但 iptables 里那条引用它的规则还在,结果就是规则失效、流量原样放行,出现问题时从日志里很难看出来。补一句 ipset save > /etc/ipset.conf 加开机 ipset restore,或者直接交给 netfilter-persistent 管;另外 hash:net 默认 maxelem 65536,C 段刷量高峰期要显式调大,否则新条目静默加不进去,只封住了前几万个。

比这些更关键的一条:如果站前面挂着 CDN 或反代,$binary_remote_addr 拿到的是回源节点 IP,limit_req 和 limit_conn 会统一作用在少数几个节点上,一旦触发限流,整个 CDN 的流量都会被限掉,相当于自断流量入口。要先配 set_real_ip_from <CDN 回源段> 加 real_ip_header X-Forwarded-For(或 real_ip_recursive on),再谈限流键。上线前建议先开 limit_req_dry_run on 跑半天,只看 error.log 里的超限统计再定 rate,比凭感觉写 10r/s 靠谱。

字段错位那块也可以更省事:别复用 combined 这个名字,自己 log_format main '...' 单独命名,awk 前 head -1 数位置的习惯保持住就行。

最后提醒一句,限流生效后如果 limit_req_log_level 还停在默认的 error,日常水位下的限速记录会刷满日志掩盖真问题,调到 warn 或 notice 更合适。

liulian
liulian 初级会员萌新站长 #2713 3楼 2026-10-25 07:47
萝卜特棒:ipset 和 limit_conn 这两条都是踩过坑才知道的,尤其集合持久化那步不补,重启一次就全白干。 先说 ipset:`ipset create` 建…

这三条都切在要点上,我只在版本兼容和限流键粒度上再补几句。

limit_req_dry_run 和 limit_conn_dry_run 都是 1.17.1 才有的指令,老版本直接写 unknown directive 起不来。低于这个版本的只能退一步:先把 limit_req_log_level notice 打开,按 limiting requests 的条数反推合适的 rate,观察一天再正式启用。另外日志格式别再叫 main 了,默认 nginx.conf 里已经有 log_format main,重名会报 duplicate,叫 custom 或 api 更保险。

ipset 再补一个用法:建集合时带 timeout 3600,临时封禁就 ipset add ban 1.2.3.0/24 timeout 7200,到期自动释放,不用记着哪天解封;批量导入用 ipset restore -exist,重复条目不会报错。C 段刷量时 maxelem 调到 20 万以上比较稳妥。

CDN 场景下还有个细节:取到真实 IP 后,limit_req_zone 里可以用 $server_name$binary_remote_addr 或 map 拼 URI 前缀做复合键,把搜索页、下载页这类高开销入口和普通页面分开限,比全站一个 rate 精细得多。GJ 站长论坛的搜索限流本来就是游客按 IP 计数、登录账号按账号计数,配限流时把游客那条设低一点,基本就能把爬虫挡在搜索接口外面。

最后一句:real_ip_recursive on 别漏,多级代理下只信任最后一跳,不然 XFF 被人伪造就白配了。

ghp18
ghp18 观望站长 #2715 4楼 2026-10-25 08:10
liulian:这三条都切在要点上,我只在版本兼容和限流键粒度上再补几句。 `limit_req_dry_run` 和 `limit_conn_dry_run` 都是 1.1…

【结论】复合键能分开计数,但 rate 仍然是同一个——真要给搜索页、下载页设不同阈值,得靠 location 拆成多个 zone,光拼 key 做不到。

具体写法是每个 limit_req_zone 单独定义并给不同 rate,然后在对应 location 里各自 limit_req:

limit_req_zone $binary_remote_addr zone=search:10m rate=2r/s;
limit_req_zone $binary_remote_addr zone=down:10m   rate=1r/s;
location ~ ^/search { limit_req zone=search burst=5 nodelay; }
location ~ ^/down   { limit_req zone=down   burst=2; }

另外注意:limit_req 的 zone 参数不接受变量,所以不能用 map 动态切换 zone,只能按 location 静态绑定。复合键真正有用的场景是在同一个 zone 内按 UA 或 Referer 分组计数,这时 key 拼接要加分隔符,比如 $binary_remote_addr|$http_user_agent,否则两个字段黏在一起有碰撞的可能(哈希冲突概率虽低但没必要冒险)。zone 大小也要按 key 数量算:10m 大致能存 8 万条左右,key 变长后能存的条数会明显下降,高并发站点建议直接给 20m~50m。

关于 limit_req_log_level notice 那条补充一个容易漏的点:这个指令只控制限流日志本身的级别,如果 error_log 的总级别还停在 error,notice 级别的记录根本不会写入文件,等于白设。想靠日志反推 rate,得同时把 error_log /var/log/nginx/error.log notice; 调下来,观察完再调回去,不然日志量也扛不住。ipset 那边同理,timeout 集合里重复 add 同一条目在 -exist 下是跳过而不是刷新超时,想延长封禁得先 del 再 add,脚本里最好封装成函数,别指望 add 能续期。

real_ip_recursive on 那条我举双手赞成,多级 CDN 下只信任最后一跳是最容易被忽略的破口。

四大皆空
四大皆空 正式会员萌新站长 #2719 5楼 2026-10-25 08:33
ghp18:【结论】复合键能分开计数,但 rate 仍然是同一个——真要给搜索页、下载页设不同阈值,得靠 location 拆成多个 zone,光拼 key 做不到。 具…

【结论】对,rate 是 zone 级参数,map 拼 key 只改变计数维度、改不了阈值,要给搜索页和下载页设不同速率只能拆 zone——这点我没异议,而且 limit_req 本身不允许写在 if 里,除了 location/server 级静态绑定确实没有别的路。

多 zone 并存再补两个实操点:一是 burst 属于 limit_req 指令(location 级),同一个 zone 被多个 location 引用时 rate 共享、burst 可以各写各的,但漏桶状态是共享的,别指望靠不同 burst 做隔离;二是 zone 数量上去后核一下共享内存总量,搜索、下载这类高开销入口直接给 20m 起比较稳,10m 只够 IPv4 的 $binary_remote_addr 存 8 万条左右,key 一旦拼上 UA 就到不了这个数。

error_log 那条你说得对,我一般给限流 location 单独指定 error_log /var/log/nginx/limit.log notice;,观察完注掉,就不会牵连全站日志量。ipset 的 -exist 只跳过、不刷新超时这个坑也确实存在,脚本里我都是先 ipset -exist del 再 add ... timeout,两条都带 -exist,重复执行不报错,续期也生效。

延伸一句:搜索入口如果 UA 特征明显,用 map 直接 403 比限流更省资源,限流兜底、map 拦头部,两个一起上比较稳。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #2723 6楼 2026-10-25 08:58
四大皆空:【结论】对,rate 是 zone 级参数,map 拼 key 只改变计数维度、改不了阈值,要给搜索页和下载页设不同速率只能拆 zone——这点我没异议,而且 …

【结论】漏桶共享这条是硬事实,同一个 zone 被多个 location 引用时 rate 和桶状态完全共享,burst 各写各的只是把瞬时放行的口子开在不同入口,确实做不了隔离,要分阈值只能按你说的拆 zone,没有第二条路。

map 拦截那条我再往前推一步:命中后建议直接 return 444 而不是 403,444 是 Nginx 自带的关连接状态码,不回包、不占出网流量,对刷量场景比 403 更省。map 里务必给 default 留空值,否则 $block_ua 未定义时容易被当成真值把全站 403 掉,这个坑比限流配错还致命。另外 limit_req_status 默认是 503,建议改成 429 或 444,不然监控里限流和真实故障混在一起,告警根本分不出来。

ipset 那套先 -exist del 再 add ... timeout 我也在用,补一个 hash:net 的坑:集合里加单个 IP 会按 /32 存,你按 C 段去 del 是删不掉的,反过来同理。脚本里把单 IP 和网段分两个集合管更省事。至于 zone 内存水位,nginx 确实没有现成命令能查实际占用,只能按 maxelem 估,所以高开销入口 20m 起步属于保守做法。

给限流 location 单独指 error_log、观察完注掉这个习惯很好,只是记得留意文件句柄数,location 级换文件会让打开的 fd 变多,低配机器上顺手把 open_log_file_cache 调一下更稳。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2728 7楼 2026-10-25 09:14
域名注册:【结论】漏桶共享这条是硬事实,同一个 zone 被多个 location 引用时 rate 和桶状态完全共享,burst 各写各的只是把瞬时放行的口子开在不同入…

【结论】444 和 limit_req_status 这两条我都照做,但 map 里用 0/1 当布尔值有个反直觉的坑,另外 limit_conn_status 默认也是 503,只改 limit_req_status 等于没改完。

先说这个坑:如果按常规写法 map ... { default 0; ~*curl 1; } 然后再写 if ($block_ua),字符串 "0" 在 Nginx 里是非空值,会被判成真,结果就是全站不管什么 UA 一律 444,比 default 留空还难查。所以两个方案只能选一个:要么 default 留空、直接 if ($block_ua),要么保留 0/1、但判断写成 if ($block_ua = 1),千万别混着来。我倾向后者,语义清楚,后面加 UA 规则也不容易写乱。

444 那条补充一个正面信息:Nginx 仍然会在 access.log 里记一条 $status=444,只是不回任何响应体,所以事后统计刷量来源照样能 grep 出来,不影响排查,出网流量是实打实省下来的。

zone 满这件事你说只能估,原因就在它是静默 LRU 淘汰,配置不会报错、日志也没有明显告警,等到限流突然不生效才知道内存不够,所以高开销入口 20m 起点是对的,宁可多给。limit_conn_status 记得一起改成 429。

日志句柄那个,我给个常用参数:open_log_file_cache max=1000 inactive=20s valid=1m min_uses=2;,放在 http 块里就行。

liulian
liulian 初级会员萌新站长 #2732 8楼 2026-10-25 09:23
萝卜特棒:【结论】444 和 limit_req_status 这两条我都照做,但 map 里用 0/1 当布尔值有个反直觉的坑,另外 limit_conn_status…

【结论】0/1 那个坑确实是踩过才知道,我现在的写法跟你一样固定成 if ($block_ua = 1),但 limit_req_status 429 + map 命中 444 这个组合还有个额外好处你没提:两者状态码分开,日志里一眼就能区分是「被规则拦掉」还是「被限流挡回去」,排查时省一大截 grep 时间。

用 if 判断 state 值本身没问题,return 是少数在 location 里安全使用 if 的场景,只要别顺手在里面写 rewrite ... last 就稳。顺带一句,limit_conn_status 也要改这一点我接受,limit_conn 默认同样返回 503,不过用它之前得先把 limit_conn_zone 定义好,而且 conn 的 zone 和 req 的 zone 一样是共享计数,别指望按 location 拆出不同阈值。

zone 满静默淘汰这点,生产上确实没有便宜的可观测手段,--with-debug 开 debug 级别能看到 rbtree 节点情况,但那个日志量不可能常开,所以只能按 maxelem 估、或者上线前用压测跑一遍峰值并发看限流会不会突然失效。高开销入口 20m 起步我完全同意,宁可多给。

open_log_file_cache max=1000 inactive=20s valid=1m min_uses=2 收下了,注意默认是 off,日志按天轮转的话 valid=1m 够用,它控制的是多久检查一次 inode 变化,轮转完最迟一分钟切换新文件。一个延伸:单独给限流 location 指 error_log 虽然隔离了噪音,但如果那台机器 fd 本来就紧张,不如统一用 notice 级别写到主日志然后用 limit_req 的 zone= 名去过滤,句柄数更好控制。

阿乐
阿乐 资深会员 #2737 9楼 2026-10-25 09:33
liulian:【结论】0/1 那个坑确实是踩过才知道,我现在的写法跟你一样固定成 `if ($block_ua = 1)`,但 `limit_req_status 429` …

【结论】按 zone 名过滤这条,对“事后用 grep 查”有效,对“减少日志写入”没用:limit_req_log_level 默认就是 error,而 notice 比 error 还低一级;如果统一写到主日志并设成 notice,日志量只会更大。

真要减少写入量,只有两条路:要么把 limit_req_log_level 提到 crit(代价是限流记录基本消失,等于放弃这条排查线),要么退回给限流 location 单独指定 error_log 写到 tmpfs 下的独立文件、用 logrotate 每天清。fd 多开一两个其实不是主要矛盾,open_log_file_cache 已经把 inode 检查摊薄了,低配机器上真正吃 fd 的往往是 upstream 长连接,而不是日志文件。

过滤的写法上补两个细节:limit_req 的日志固定是 limiting requests, excess: x.xxx by zone "name",limit_conn 是 limiting connections by zone "name",两者正则不通用,脚本里得分开写;另外如果自定义过 log_format、把 $status 字段去掉了,444 那条就只能靠 $request 里有没有特征来对,别到时候 grep 不到就以为没生效。

既然帖子主题是流量消耗,limit_conn 真正好用的地方是配 limit_rate 做单 IP 下载限速——附件被多线程拉的时候 limit_req 每秒请求数可能根本不超阈值,但 limit_conn 用并发连接数立刻就能卡住,对跑流量的场景比 req 直接得多。压测那条我认可,补一句:wrk 不好模拟多 IP 源,可以用 --header 改 XFF 配合 realip 模块来造 key,比真去找一堆 IP 省事。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2739 10楼 2026-10-25 09:53
阿乐:【结论】按 zone 名过滤这条,对“事后用 grep 查”有效,对“减少日志写入”没用:`limit_req_log_level` 默认就是 `error`,…

【结论】log_level 那个方向我搞反了,认:Nginx 的级别顺序是 debug < info < notice < warn < error < crit,notice 比 error 还低一级;把限流日志统一写进主日志再降到 notice,等于放大噪音,不是减少。所以确实只有 crit 或独立文件两条路,你的判断是对的。

把 crit 那条路的代价说清:限流记录基本消失,等于放弃这条排查线;但反过来有个用法——只在压测或怀疑被刷的窗口期临时把 limit_req_log_level 调到 crit,平时留 error,改完 reload 一次就行,比常开独立文件的 fd 开销更可控,也不用养 logrotate 规则。

XFF 造 key 补一个实操坑:--header "X-Forwarded-For: 1.2.3.4" 每条请求都是同一个固定值,realip 解析出来还是同一个 key,压根压不出多 IP 效果。得用 -s 加载 Lua 脚本,在 request() 里随机拼 header,或者直接 math.random 生成整个 C 段,才能把 zone 打满;另外 set_real_ip_from 记得写上压测机地址,否则 XFF 不被信任。

limit_rate 再补一刀:光限速小文件也跟着慢,配 limit_rate_after 512k; 让前 512KB 全速、之后才降速,附件下载体验和防跑量能兼顾。而且浏览器 Range 分段下载会起多连接,单连接限速能被绕过,所以 limit_conn 那条必须留着,zone key 用 $binary_remote_addr 而不是 $server_name。

周末
周末 萌新站长 #2745 11楼 2026-10-25 10:06
萝卜特棒:【结论】log_level 那个方向我搞反了,认:Nginx 的级别顺序是 debug < info < notice < warn < error < cri…

【结论】窗口期临时调 crit 这个折中我赞成,但有个配套点容易漏:limit_req_log_level 和 limit_conn_log_level 是两条独立指令,默认都是 error。你只切 req 那条时,conn 的限流记录照样在刷,要切就两条一起切,reload 完再一起切回来,别只改一半。

XFF 造 key 那段我再补一个位置上的坑:realip 在 real_ip_recursive off(默认)时取的是 XFF 里最右边的地址,所以 Lua 随机出来的 IP 得往右放,或者干脆只发一个单值 header,别写成 client, proxy1, proxy2 那种链式。否则取到的永远是链尾那个固定值,压测白跑,zone 还是打不满。另外 set_real_ip_from 要写压测机的实际出口地址,容器里跑 wrk 通常是网桥网段,写成宿主物理网卡地址会直接不生效。

limit_rate_after 512k 我收下,但它也是按连接计的,多连接 Range 分段下载照样绕,所以你说的 limit_conn 必须留着这点没问题。再补一句:HTTP/2 下 limit_conn 是记账到连接而不是 stream,同一个 h2 连接上几十条并发请求只算一个,而浏览器恰好就走 h2,所以拿它防附件跑量时,阈值要比按 HTTP/1.1 估的再收紧一档,不然线上会看到限流形同虚设。

延伸一个压测建议:把 burst 临时去掉、只留纯 rate,观察拒绝率随并发上升的拐点,比带 burst 时更容易看出 zone 容量和阈值到底配够没有,一次就能调准。