Nginx 高性能配置调优与反向代理最佳实践
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
一、问题背景:性能问题往往不是"参数没调",而是"用错了"先说三个真实场景,都是我在生产环境里反复遇到的。 场景一:QPS 上不去,CPU 却不高。 一台 8 核 16G 的 Nginx,前面挂着四个 Java 后端,官方压测工具报 3000 QPS 时响应时间突然从 30ms 跳到 800ms。登上去看, 场景二:大文件下载把内存吃光。 静态资源站点,用户反馈"下 2G 的镜像包,Nginx 内存直接涨到 12G 然后被 OOM Killer 干掉"。看配置, 场景三:一条 有人在
这三个场景的共同点:都不是靠改 这篇文章围绕"一台 Nginx 从默认配置变成能扛生产流量"这条主线展开,覆盖:进程与连接模型、事件驱动与多路复用、静态文件传输路径、反向代理连接复用与缓冲、超时体系、限流与熔断、缓存、TLS 优化、日志与可观测性、故障排查。每一节都给出配置、生效方式、验证命令和判断依据。 二、适用场景与不适用场景2.1 适用场景本文的配置策略针对以下场景做过验证:
2.2 不适用场景有些场景不适合套用本文的配置,需要单独讨论:
2.3 版本边界本文的配置以开源版 Nginx 稳定分支 1.30.x 为基线(写作时的最新稳定版为 1.30.6),同时标注了 1.29.x / 1.31.x 引入的行为变化,因为这些变化会直接影响"默认值"的理解。 开源版和商业版(NGINX Plus)的差异必须说清楚,否则照抄 Plus 文档会踩坑:
关键提醒:网上大量教程里的 三、验收标准:先定义"高性能"是什么调优前必须先把目标量化,否则无法判断调完是好是坏。建议按下面五个维度定基线:
这里有个容易被忽略的点:Nginx 的
四、环境假设本文所有配置和命令基于以下环境,如果你的环境不同,结论可能有出入:
关于安装来源:发行版仓库(
五、前置检查:动手前必须确认的事5.1 确认版本与编译参数
预期输出:能看到 常见异常:
判断依据: 5.2 确认当前配置的语法与生效状态
注意:
5.3 确认文件描述符上限
判断依据: 注意: 5.4 确认内核参数
这几个参数的作用和调整依据:
5.5 确认后端是否支持长连接这是最容易被跳过的一步。Nginx 侧配了
判断依据:
同时要确认后端的 六、核心知识:Nginx 为什么快,以及快在哪一步要把参数调对,得先知道 Nginx 的架构约束在哪。这一节讲四个东西:进程模型、事件驱动、连接生命周期、内存分配。 6.1 进程模型:master + workerNginx 启动后是两类进程:一个 master,N 个 worker。
预期输出:
master 只做四件事:读配置、绑定端口、管理 worker(fork/退出/信号)、reopen 日志。它不处理任何请求。所有请求都由 worker 处理。 这个模型带来三个直接推论: 推论一:worker 数不该超过 CPU 核数。
超过核数只会增加上下文切换。唯一的例外是有大量阻塞型操作(比如访问慢速网络文件系统、执行大量 subrequest),这时可以略微超过,但在现代内核上收益很小。 推论二:每个 worker 是单线程事件循环,任何阻塞操作都会拖死整个 worker。 这意味着:不要在 推论三:共享状态必须通过共享内存。
没有 6.2 事件驱动:epoll 与 worker_connectionsLinux 上 Nginx 用 epoll(
几个参数的真实含义:
容量计算公式:
8 核 × 10240 / 2 = 40960 并发客户端连接,需要 6.3 连接的生命周期与四个状态
预期输出:
四个数字怎么读:
判断依据:
6.4 内存:哪些地方会吃内存Nginx 的内存占用不像 Java 那样有个大堆,它是分布式的,容易误判。主要消耗在:
最容易出事的是 判断依据:
单个 worker RSS 稳定在 20~80MB 是正常范围(取决于配置和缓存)。持续增长不回落,通常是 buffer 泄漏信号——优先查 七、整体实施思路调优不是一次性动作,而是一条有顺序的路径。顺序错了会白干。 7.1 六个阶段的顺序
为什么是这个顺序:每一层的效果依赖上一层。比如第④步的 7.2 每一步都要有验证不能"改完就上线"。每一步至少验证三件事:
7.3 变更安全基线Nginx 配置变更看起来风险低(reload 是平滑的),但有几类变更会造成真实故障:
reload 的真实行为:
验证 reload 成功:
八、实战一:系统层与内核参数8.1 目标让系统层面不成为瓶颈:fd 够用、端口够用、连接队列够深、TCP 行为符合反向代理场景。 8.2 文件描述符
但对 systemd 管理的 Nginx,
验证:
预期输出:
异常:如果还是 1024,说明 注意 8.3 内核参数
逐项说明调整依据:
验证:
预期 8.4 文件系统与日志日志写入是最容易被忽略的性能杀手。高 QPS 下访问日志能吃掉可观的 CPU 和 IO。
如果日志量极大(每秒几万条),考虑:
关于日志切割:用
九、实战二:静态文件传输路径静态资源是 Nginx 最强的场景。默认配置已经不错,但有几处能显著提升吞吐。 9.1 sendfile:绕过用户态拷贝
原理:传统 判断依据:静态文件占比越高收益越大。纯反向代理场景 9.2 tcp_nopush / tcp_nodelay:一对反向的参数
这两个名字容易混,实际效果相反:
为什么两个能同时开:Nginx 的处理逻辑是——响应头单独一个小包立即发出( 不要做的事: 9.3 open_file_cache:缓存文件元信息
作用:缓存 参数含义:
验证方式:
压测 10 秒后看 风险: 9.4 静态资源的完整 location 配置
几个关键点:
9.5 Range 请求与大文件
不要做的事:不要用 十、实战三:反向代理连接复用(收益最大的一步)这一节是全文性价比最高的部分。在绝大多数"QPS 上不去"的案例里,根因都在这里。 10.1 版本变化:默认值已经改了必须在开头说清楚,因为网上大量教程还是老版本的写法:
判断依据:如果你的 Nginx 是 1.29.7 之前的版本,必须显式写 10.2 正确的 upstream 配置
每个参数的作用和取值依据:
10.3 版本新增: |
| 场景 | 建议 | 原因 |
|---|---|---|
| 普通 API,响应小(< 1MB) | on(默认) | 后端能尽快释放 worker 线程,吞吐更高 |
| SSE / 流式输出 | off | 必须实时推送 |
| 大文件下载(> 10MB) | off 或调大 proxy_max_temp_file_size | 避免写临时文件;同时 proxy_max_temp_file_size 0 可禁用临时文件 |
| WebSocket | off(其实 upgrade 后自动不缓冲) | — |
SSE 的正确配置:
nginx location /api/stream {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
gzip off; # gzip 会攒数据,破坏流式
chunked_transfer_encoding on;
proxy_read_timeout 3600s; # 长连接必须调大
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no;
}
add_header X-Accel-Buffering no 是给后端看的:如果后端是 Node/Java,它可能自己设置了缓冲,这个头告诉它关掉。某些框架(如 Spring 的 ResponseBodyEmitter)会尊重这个头。
超时参数的语义:
| 参数 | 含义 | 默认 | 调整依据 |
|---|---|---|---|
proxy_connect_timeout | 与后端建连超时 | 60s | 必须缩短,2~5s。建连失败应该快速失败 |
proxy_send_timeout | 两次写操作之间的间隔 | 60s | 后端处理慢但不读请求体时会触发 |
proxy_read_timeout | 两次读操作之间的间隔 | 60s | 不等于总处理时间,是"多久没收到数据" |
proxy_next_upstream_timeout | 重试的总时限 | 0(不限) | 配合 tries 防止重试拖死请求 |
proxy_read_timeout 的误解最多。它不是"后端必须在这个时间内返回完整响应",而是"两次 recv() 之间的最大间隔"。一个 10 分钟才返回的报表查询,只要中间有数据(或连接没断),就不会超时。
重试配置的风险:
proxy_next_upstream 默认只对 error timeout 重试,不包含 http_502/503/504。加上之后要小心:
$request_method 判断:nginx map $request_method $can_retry {
GET 1;
HEAD 1;
default 0;
}
upstream app_backend {
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
# ...
}
location /api/ {
proxy_next_upstream error timeout;
proxy_next_upstream_tries $can_retry; # 非幂等请求不重试
}
proxy_next_upstream_tries 0 表示不重试。用 map 控制是个实用技巧。
这是必须做的验证,否则你不知道配置有没有起作用。
bash # 方法一:看 Nginx 到后端的连接数(ESTAB 应该稳定而非持续增长)
ss -tn state established '( dport = :8080 )' | wc -l
# 方法二:连续发 100 个请求,看建连次数
# 在后端侧看 accept 计数增长,或者用 tcpdump 数 SYN
tcpdump -i any -c 200 -nn 'tcp[tcpflags] & tcp-syn != 0 and dst port 8080'
# 方法三:看 Nginx 的 stub_status,requests/handled 比值
curl -s http://127.0.0.1:8081/nginx_status
判断依据:
proxy_http_version 是否为 1.1、Connection 头是否被清空、后端是否回 Connection: close。同一台机器、同 4 个后端,只改连接复用:
| 配置 | QPS | P99 延迟 | 后端 SYN/s | Nginx TIME_WAIT |
|---|---|---|---|---|
proxy_http_version 1.0(1.29.7 前的默认) | 2800 | 780ms | 11200 | 无法建立(端口耗尽) |
proxy_http_version 1.1 + Connection "",无 keepalive | 6200 | 210ms | 24800 | 35000 |
上一行 + keepalive 64 | 14500 | 42ms | 数十 | 数百 |
第三行的提升不是"调参调出来的",是消除了每个请求三次握手的固定成本。每个请求省下 0.1~0.3ms 的握手 + 内核协议栈开销,在 1 万 QPS 下就是每秒省下 1~3 秒的 CPU 时间。
一个请求经过"客户端 → Nginx → 后端",超时必须层层递减,才能保证最外层先放弃:
text 客户端超时(浏览器/SDK) > Nginx 超时 > 后端超时
60s 30s 20s
如果顺序反了(后端 60s,Nginx 30s):Nginx 30 秒断开,后端还在跑 30 秒,资源白白浪费,日志里只有 Nginx 的 504,后端却显示"成功完成"。
如果 Nginx 比客户端长(客户端 20s,Nginx 30s):客户端先断,Nginx 继续等 30 秒,占着连接和 buffer,还可能触发重试,放大后端压力。
nginx # 客户端侧
client_header_timeout 10s; # 读完请求头的时间
client_body_timeout 30s; # 读完请求体的时间(两次读之间)
send_timeout 30s; # 向客户端写响应的间隔
keepalive_timeout 65s; # 客户端连接空闲保持时间
# 后端侧
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# 上游连接相关的 TCP 参数
proxy_socket_keepalive on; # 开启 TCP keepalive 探测死连接
proxy_socket_keepalive on 值得单独说:它让 Nginx 到后端的连接开启 SO_KEEPALIVE。如果后端机器直接宕机(没有 FIN),Nginx 不会立刻发现,会一直复用一个已死的连接。开启后,内核会在探测失败后关闭连接,Nginx 下次取到就会失败并重试。代价是探测周期很长(默认 2 小时),所以主要靠 keepalive_timeout 和 max_fails 兜底,socket_keepalive 是补充。
keepalive_timeout 65s 为什么是 65 而不是 60:常见负载均衡器(AWS ALB、阿里 SLB)的空闲超时是 60 秒。Nginx 设 65 秒,让自己后关,避免在 SLB 已关闭的连接上发请求导致偶发 502。这是一个实践经验,不是硬性规则。
nginx # 防慢客户端(Slowloris)
client_header_timeout 10s;
client_body_timeout 20s;
client_max_body_size 50m;
client_body_buffer_size 128k;
# 大上传走磁盘缓冲
client_body_temp_path /var/cache/nginx/client_temp 1 2;
# 限制单连接带宽,防拖死(单位 bytes/s,0 为不限)
# limit_rate 0;
慢客户端是真实威胁:一个客户端用 10 字节/秒读取响应,Nginx 的连接、buffer 全被占用。send_timeout 只在"完全不动"时触发,慢速读取不触发。这时需要 limit_rate 或 limit_conn 限制。
nginx # 按 IP 限制并发连接数
limit_conn_zone $binary_remote_addr zone=conn_perip:10m;
limit_conn_status 429;
limit_conn_log_level warn;
server {
limit_conn conn_perip 20;
# ...
}
注意 limit_conn 的语义:它限制的是同一时刻的并发连接数,不是请求速率。下载大文件的用户会长时间占用连接,容易被误伤。必须结合 limit_conn_dry_run on(1.17.6+,开源版可用)先观察:
nginx limit_conn_dry_run on; # 只记录日志不实际拒绝
日志里会出现 limiting connections by zone "conn_perip", dry run,用这个先跑一周,确认阈值合理再关掉 dry-run。这是开源版上很有价值的功能,很多人不知道。
开源版只有被动健康检查,靠 max_fails / fail_timeout 实现:
nginx upstream app_backend {
zone app_backend 64k;
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s backup;
}
语义:在 fail_timeout(10 秒)内失败 max_fails(3 次),则该 server 在接下来的 fail_timeout 内被标记为不可用,不再分配请求。
几个坑:
max_fails=0 表示禁用失败计数,不是"永不失败"。写反了会导致故障节点永远被选中。http_502 等不算(除非配了 proxy_next_upstream http_502)。zone 时每个 worker 独立计数,8 个 worker 意味着实际失败阈值是 3 × 8 = 24 次。fail_timeout 过后会自动重新分配请求试探,不需要人工干预。但试探失败会再次进入不可用。灰度摘流的标准做法:不要依赖 max_fails 摘节点(太慢,要等 10 秒以上)。正确方式是改配置 reload,把 server 标记为 down:
nginx server 10.0.1.11:8080 down; # 立即不分配流量,但保留在列表里
reload 是平滑的,down 的节点不再收到新请求,已有请求正常完成。这比直接从列表里删掉更安全(删掉会让 keepalive 池里的连接被直接清理)。
这三个状态码在 Nginx 侧的含义完全不同:
| 状态码 | 谁产生的 | 含义 | 首要排查 |
|---|---|---|---|
| 502 | Nginx | 后端返回无效响应 / 连接被拒 / 连接被重置 | 后端是否在监听、后端是否提前关闭连接 |
| 504 | Nginx | 后端在 proxy_read_timeout 内没有响应 | 后端处理慢、后端线程池满、后端等锁 |
| 499 | Nginx | 客户端提前关闭连接 | 客户端超时设置、用户主动取消 |
502 的细分:
bash # error.log 里的关键行
grep -E 'upstream sent|connect\(\) failed|recv\(\) failed|upstream prematurely' /var/log/nginx/error.log | tail -20
connect() failed (111: Connection refused) while connecting to upstream → 后端没监听或端口错。查 ss -lntp | grep 8080。upstream prematurely closed connection while reading response header → 后端接受了连接又立刻关闭。常见于后端线程池满、后端 keepalive_timeout 比 Nginx 短、后端崩溃重启。upstream sent invalid header → 后端返回了畸形响应(比如返回了纯二进制、或者 HTTP 版本不对)。no live upstreams while connecting to upstream → 所有后端都被标记失败。这是故障扩大的信号,要立即介入。504 的判断:
bash # 对比 Nginx 记录的 upstream 响应时间
awk '$0 ~ /urt=/ {print}' /var/log/nginx/access.log | tail -100 | grep -o 'urt=[0-9.]*' | sort -t= -k2 -n | tail -10
如果 urt 接近 proxy_read_timeout,说明后端确实超时。如果 urt=-,说明根本没连上(那是 502 的范畴)。
499 的判断:
499 大量出现,说明客户端等不及了。两个方向:
$request_time 分布——如果集中在某个值附近,就是客户端超时。499 有个副作用:客户端断开后,Nginx 默认会继续从后端读完响应再关闭(避免脏连接进入 keepalive 池)。这会浪费后端资源。可以用:
nginx proxy_ignore_client_abort on; # 默认 off,客户端断开后继续读完后端响应
保持默认 off 是对的:客户端断开 Nginx 就停止读,后端写入会失败并释放线程,反而更快。设为 on 会让后端白干。
nginx # $binary_remote_addr 比 $remote_addr 省一半内存(4 字节 vs 7~15 字节)
limit_req_zone $binary_remote_addr zone=api_perip:10m rate=20r/s;
limit_req_zone $server_name zone=api_total:10m rate=2000r/s;
limit_req_status 429;
limit_req_log_level warn;
location /api/ {
# burst=40:允许突发 40 个请求排队;nodelay:排队的不延迟处理
limit_req zone=api_perip burst=40 nodelay;
limit_req zone=api_total burst=200;
# ...
}
rate 的单位:r/s 或 r/m。rate=20r/s 表示平均每秒 20 个请求,Nginx 内部按毫秒精度换算(每 50ms 放行一个)。
burst 与 nodelay 的语义:
nodelay:超出的请求排队,按 rate 的节奏逐个放行,响应会变慢但不报错。适合"宁可慢也不要失败"的场景。nodelay:突发额度内的请求立即处理,超出 burst 的直接 429。适合 API 场景。burst 大小的取值依据:burst = 允许的瞬时峰值 - rate × 处理时长。比如页面加载会同时发 30 个请求,rate=20r/s,那么 burst 至少要 30 才不会误伤首页加载。
多级限流的顺序:Nginx 会按配置顺序检查所有 limit_req,任何一个超限就拒绝。所以先配粗粒度(按 IP),再配细粒度(按接口) 会先拒绝掉异常 IP,减少后续判断。
nginx # 登录接口严格限流,防撞库
map $uri $login_key {
~^/api/login $binary_remote_addr;
default "";
}
limit_req_zone $login_key zone=login:10m rate=1r/s;
location = /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://app_backend;
}
关键技巧:map 的 default 返回空字符串时,limit_req_zone不会对该请求计数(key 为空则跳过)。这样就能做"只对特定路径限流"。
注意:1.17.0 起 limit_req 支持在 location 里用变量控制是否启用:
nginx set $limit_key $binary_remote_addr;
if ($uri !~ ^/api/login) {
set $limit_key ""; # 空 key 不计数
}
但 if 有副作用,优先用 map。
按 IP 限流保护不了"大量不同 IP 同时打一个热点接口"。需要按 server 或按 upstream 限流:
nginx # 按虚拟主机总速率限制
limit_req_zone $server_name zone=per_vhost:10m rate=5000r/s;
server {
server_name api.example.com;
limit_req zone=per_vhost burst=1000 nodelay;
}
更好的方案:结合 $upstream_addr 做"按后端实例限流",但这需要 Lua 或者 Plus 的 limit_req_zone 扩展。开源版只能用 $server_name 或自定义变量(比如从请求头取)。
nginx # 从请求头取租户 ID,按租户限流
map $http_x_tenant_id $tenant_key {
"" "";
default $http_x_tenant_id;
}
limit_req_zone $tenant_key zone=per_tenant:10m rate=100r/s;
安全提示:$http_x_tenant_id 是客户端可控的。如果前面的网关或鉴权层已经清洗过这个头(覆盖为可信值),才可以用。否则攻击者随便换头就绕过限流。
bash # 用 ab 或 wrk 压测,观察 429 比例
wrk -t4 -c100 -d10s --latency http://127.0.0.1/api/test
# 单独数 429
awk '$9 == 429 {c++} END {print c+0}' /var/log/nginx/access.log
预期:rate=20r/s + burst=40,100 并发压 10 秒,前面的 40 个突发请求通过,之后每秒约 20 个通过,其余 429。
异常情况:
rate 单位写错(rate=20 会被当成 20 r/s,但如果写成 rate=20r/m 就是每分钟 20 个)。limit_req_zone 的 key 变量为空(比如 $binary_remote_addr 在 stream 模块里不可用)。验证方法:bash # 看 zone 状态需要商业版 API,开源版只能靠行为判断
# 检查配置是否被加载
nginx -T | grep limit_req
开源版没有真正的熔断器。能做的近似方案:
max_fails 快速摘除:max_fails=2 fail_timeout=30s,配合 proxy_next_upstream http_503。error_page 兜底静态页:nginx proxy_intercept_errors on;
error_page 502 503 504 = @fallback;
location @fallback {
internal;
# 注意:return 的 content-type 默认是 text/plain,要改
default_type application/json;
return 503 '{"code":503,"msg":"service temporarily unavailable"}';
}
default_type 必须写在 return 之前才生效——Nginx 按书写顺序执行指令,return 会立即结束该阶段的处理,写在它后面的 default_type 不会被执行。所以正确顺序是:
nginx location @fallback {
internal;
default_type application/json;
return 503 '{"code":503,"msg":"service temporarily unavailable"}';
}
location 里用 if 判断时间窗口,或者用 ngx_http_geo_module 按 IP 段做灰度降级。不要用 if 做复杂逻辑。Nginx 的 if 在 location 内是"伪指令",官方文档明确写了"if is evil"。能用 map 就用 map,能用多 location 就用多 location。
nginx # 定义缓存区(http 块)
proxy_cache_path /data/nginx/cache
levels=1:2
keys_zone=api_cache:100m
max_size=10g
inactive=60m
use_temp_path=off; # 1.7.10+,避免临时目录跨文件系统拷贝
# 定义缓存 key
proxy_cache_key "$scheme$request_method$host$request_uri";
参数含义与取值依据:
| 参数 | 含义 | 取值依据 |
|---|---|---|
levels=1:2 | 两级目录,第一级 1 个字符,第二级 2 个 | 每个目录下文件数控制在几百以内,避免单目录 inode 过多 |
keys_zone=api_cache:100m | 元信息共享内存 | 100MB ≈ 80 万条缓存 key |
max_size=10g | 磁盘上限 | 按磁盘容量和预期命中率定 |
inactive=60m | 60 分钟未访问则删除 | 与业务的内容更新频率匹配 |
use_temp_path=off | 临时文件和缓存文件同目录 | 避免跨设备 rename 失败(必须 EXDEV) |
use_temp_path=off 是个容易忽略的坑:如果 proxy_temp_path 和 proxy_cache_path 在不同文件系统,Nginx 会用"拷贝 + 删除"代替 rename,大文件下性能暴跌。
nginx location /api/products/ {
proxy_pass http://app_backend;
proxy_cache api_cache;
proxy_cache_valid 200 301 302 10m; # 成功响应缓存 10 分钟
proxy_cache_valid 404 1m; # 404 缓存 1 分钟,避免穿透
proxy_cache_valid any 1m;
proxy_cache_methods GET HEAD; # POST 默认不缓存
proxy_cache_min_uses 1;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # 后台异步更新过期缓存
proxy_cache_lock on; # 同一 key 并发请求只有一个回源
proxy_cache_lock_timeout 5s;
# 缓存状态写入响应头,便于排查
add_header X-Cache-Status $upstream_cache_status always;
}
proxy_cache_use_stale 是缓存最重要的价值:后端挂了,还能用旧缓存顶住。
error timeout:连不上或超时。updating:缓存正在后台更新时,返回旧的。http_502/503/504:后端返回错误时用旧的。注意:use_stale 返回的旧响应状态码仍是原始状态码(比如 200),不是 5xx。这对用户体验是好事,但要注意它可能返回过期很久的数据。配合 proxy_cache_background_update on 使用,能保证"先返回旧数据,后台刷新"。
proxy_cache_lock 的必要性:缓存过期瞬间,100 个并发请求同时回源,后端被打穿(缓存击穿)。proxy_cache_lock on 让只有一个请求回源,其余等待。proxy_cache_lock_timeout 5s 后未拿到结果的不再等,自己回源(防止长时间阻塞)。
nginx # 按设备类型区分缓存
proxy_cache_key "$scheme$host$request_uri$http_accept_encoding";
# 按语言区分
map $http_accept_language $lang_key {
~*zh zh;
~*en en;
default zh;
}
proxy_cache_key "$scheme$host$request_uri$lang_key";
不要做的:
$cookie_* 放进 key。Cookie 值千变万化,缓存命中率接近 0,还会撑爆 keys_zone。$remote_addr 放进 key。除非确实需要按 IP 缓存,否则等于没缓存。Set-Cookie 的响应。默认 proxy_cache 不缓存带 Set-Cookie 的响应(这是正确的),如果强行缓存要用 proxy_ignore_headers Set-Cookie,这会把 A 用户的会话 cookie 给 B 用户,是严重的安全事故。bash # 验证缓存是否生效:连续两次请求,看 X-Cache-Status
curl -sI http://127.0.0.1/api/products/1 | grep -i x-cache-status
# 第一次:MISS,第二次:HIT
$upstream_cache_status 的可能值:
| 值 | 含义 | 是否需要关注 |
|---|---|---|
MISS | 未命中,回源 | 首次请求正常 |
HIT | 命中 | 正常 |
EXPIRED | 缓存过期,回源 | 正常 |
STALE | 用了过期缓存(use_stale 生效) | 后端有问题,需告警 |
UPDATING | 缓存正在后台更新 | 正常 |
REVALIDATED | 用 If-Modified 验证后仍有效 | 正常 |
BYPASS | 被 proxy_cache_bypass 跳过 | 看业务预期 |
清理缓存:开源版没有清缓存的 API。三种方式:
rm -rf /data/nginx/cache/* 然后 nginx -s reload。注意:只删文件不 reload,Nginx 的共享内存里还有索引,可能返回 404 或乱码。proxy_cache_purge:需要第三方模块 ngx_cache_purge,官方包不带。/api/products/1?v=2,key 变了自然穿透。不需要清缓存。方式 1 的风险:缓存文件量大的时候,rm -rf 会产生大量 IO,且 Nginx 在缓存区被清空期间的所有请求都会回源,可能打挂后端。应该在低峰期做,或者分批删。
nginx server {
listen 443 ssl;
http2 on; # 1.25.1 起用独立指令,旧的 listen ... http2 已废弃
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
# 协议版本:TLS 1.2 起步,1.3 优先
ssl_protocols TLSv1.2 TLSv1.3;
# 密码套件:TLS 1.3 的套件由 OpenSSL 固定,这里的配置只影响 1.2
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # TLS 1.3 时代应设 off,让客户端决定
# 会话复用:显著降低握手开销
ssl_session_cache shared:SSL:50m; # 50m ≈ 20 万个会话
ssl_session_timeout 1d;
ssl_session_tickets on;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/example.com.chain.pem;
resolver 223.5.5.5 119.29.29.29 valid=300s;
resolver_timeout 5s;
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
几个关键点说明:
ssl_prefer_server_ciphers off:这是 1.11.0 起的默认值。老教程写 on 是为了防止客户端选弱套件,但在 TLS 1.3 + AEAD 套件普及后,on 反而会让性能更好的客户端优先套件失效。
ssl_session_cache shared:SSL:50m:不加这个,默认是 none(1.23.4 起默认 shared:SSL:10m,但显式声明更可控)。每个会话约 256 字节,50m 能存约 20 万个。多 worker 必须用 shared:,否则每个 worker 独立缓存,命中率除以 worker 数。
OCSP Stapling 的验证:
bash echo | openssl s_client -connect api.example.com:443 -servername api.example.com -status 2>/dev/null | grep -A3 'OCSP Response Status'
预期输出:
text OCSP response:
======================================
OCSP Response Data:
OCSP Response Status: successful (0x0)
如果显示 no response sent,说明 stapling 没工作。常见原因:resolver 没配(Nginx 需要自己解析 OCSP 服务器地址)、证书链不完整(ssl_trusted_certificate 没配或者配错了文件)。
注意:OCSP Stapling 的价值在下降。Let’s Encrypt 已经宣布停止 OCSP 服务(2025 年起),改用 CRL。如果证书来自 LE,配置 stapling 没有意义,还可能因为 OCSP 服务器不可达而拖慢握手。
http2 on 的版本差异:
listen 443 ssl http2;listen 443 ssl; + http2 on;the "listen ... http2" directive is deprecated 警告。HTTP/3 的配置(需要编译 --with-http_v3_module,OpenSSL 3.2+ 或 quictls):
nginx listen 443 quic reuseport;
listen 443 ssl;
http2 on;
# 告诉浏览器可以用 h3
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_early_data on; # 0-RTT,注意重放风险
ssl_early_data on 有重放风险:0-RTT 数据可能被攻击者截获重放。只对幂等请求(GET)开放,或者在应用层做 nonce 校验。对 POST 接口开 early_data 是危险的。
nginx # 每连接并发流(默认 128)
http2_max_concurrent_streams 128;
# 1.29.x 起默认行为变化:HTTP/2 支持 RFC 9218 优先级(1.31.7 完整实现)
# 老版本的 http2_push 已被移除(1.25.1 起)
HTTP/2 的常见误区:
误区一:上了 HTTP/2 就快了。 对高延迟链路(移动网络、跨国)收益明显,因为多路复用省掉了队头阻塞和多次握手。但对低延迟内网,收益很小。
误区二:HTTP/2 一定要开 server push。http2_push 已在 1.25.1 被移除,因为实际效果差(浏览器缓存了还会推,浪费带宽)。现在用 103 Early Hints 替代:
nginx # 1.29.0 起开源可用
early_hints "Link" "</app.css>; rel=preload; as=style";
误区三:HTTP/2 不用管连接数。 实际上 HTTP/2 把"每域名 6 个连接"的限制打开了,浏览器可能一个域名就用几十个连接。http2_max_concurrent_streams 设得太大(默认 128)会让单个连接就能压满后端线程池。
TLS 握手(尤其是完整握手)是 CPU 密集的。RSA 2048 的完整握手约消耗 1ms CPU,ECDSA P-256 约 0.3ms。10 万次/秒的新建连接就是 100 秒 CPU 时间。
优化手段按收益排序:
ssl_session_cache + ssl_session_tickets):复用率 80% 以上时收益最大。nginx ssl_certificate /etc/nginx/ssl/example.com.ecdsa.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.ecdsa.key;
ssl_certificate /etc/nginx/ssl/example.com.rsa.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.rsa.key;
Nginx 会按客户端能力自动选择。
验证 TLS 版本分布:
nginx # 在日志里记录协议和套件
log_format tls '$remote_addr [$time_local] "$request" $status '
'proto=$server_protocol ssl=$ssl_protocol cipher=$ssl_cipher '
'sni=$ssl_server_name sess_reused=$ssl_session_reused';
$ssl_session_reused 为 r 表示复用,. 表示新建。统计复用率:
bash awk '{for(i=1;i<=NF;i++) if($i ~ /^sess_reused=/) print $i}' /var/log/nginx/tls.log | sort | uniq -c
判断依据:复用率低于 60% 要查原因(会话缓存太小、客户端不支持、中间设备剥离)。
日志不是越详细越好——每增加一个变量,Nginx 就要多做一次取值和格式化,高 QPS 下是可观的 CPU 开销。
最小可用集(约 10 个字段,覆盖 90% 排查场景):
nginx log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time ustat=$upstream_status';
排障增强集(+5 个字段,用于链路追踪):
nginx log_format trace '$remote_addr - [$time_local] "$request" $status '
'$body_bytes_sent rt=$request_time '
'urt=$upstream_response_time uaddr=$upstream_addr ustat=$upstream_status '
'rid=$request_id host=$host scheme=$scheme '
'ct=$content_type cs=$upstream_cache_status';
几个重要变量的说明:
| 变量 | 含义 | 注意 |
|---|---|---|
$request_time | 从读首字节到发完响应的总时间(秒,毫秒精度) | 含客户端网络时间 |
$upstream_response_time | 后端响应时间,多次尝试用逗号分隔 | 不能直接当数字用 |
$upstream_addr | 后端地址,多次尝试用逗号分隔 | 与上面对齐 |
$upstream_status | 后端状态码 | - 表示没连上 |
$request_id | 自动生成的 16 进制 ID | 不写入日志就白生成了 |
$upstream_bytes_received | 从后端收到的字节数 | 排查"后端返回巨大响应" |
$connection_requests | 当前连接上的第几个请求 | 判断 keepalive 效果 |
$request_id 的正确用法:它需要在 proxy_set_header 里传给后端,后端再打到自己的日志里,才能做全链路串联:
nginx proxy_set_header X-Request-ID $request_id;
如果客户端自己带了 X-Request-ID(比如移动端生成的),要优先用客户端的:
nginx map $http_x_request_id $rid {
"" $request_id;
default $http_x_request_id;
}
proxy_set_header X-Request-ID $rid;
安全提示:透传客户端提供的 X-Request-ID 前要校验格式(长度、字符集),否则会有人往日志里注入换行符,制造假日志。
不装 ELK 也能做基础分析。以下都是可直接运行的命令。
bash # TOP 10 请求量最大的 URL
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
# TOP 10 响应时间最长的请求(需要日志里有 rt=)
grep -o 'rt=[0-9.]*' /var/log/nginx/access.log | cut -d= -f2 | sort -rn | head -10
# P50 / P95 / P99 响应时间
grep -o 'rt=[0-9.]*' /var/log/nginx/access.log | cut -d= -f2 | sort -n | \
awk '{a[NR]=$1} END {print "P50="a[int(NR*0.5)], "P95="a[int(NR*0.95)], "P99="a[int(NR*0.99)], "count="NR}'
# 状态码分布
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
# 5xx 的 TOP URL
awk '$9 ~ /^5/ {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
# 按分钟统计 QPS
awk -F'[][]' '{print $2}' /var/log/nginx/access.log | cut -d: -f1-3 | uniq -c | sort -k2
# 上游响应时间 TOP(注意处理逗号分隔的多次尝试)
grep -o 'urt=[0-9.,]*' /var/log/nginx/access.log | \
awk -F= '{n=split($2,a,","); s=0; for(i=1;i<=n;i++) s+=a[i]; print s}' | sort -rn | head -10
awk -F'[][]' 的用法:Nginx 日志的时间格式是 [10/Oct/2026:14:23:45 +0800],用方括号做分隔符,$2 就是时间部分。这个技巧比正则快很多。
注意日志格式和 awk 字段的对应关系:上面命令里 $7 是 URL、$9 是状态码,这只对 combined 格式成立。如果你自定义了格式,字段位置会变。生产环境建议保持标准格式,或者用 awk 按 $i 定位前先 head -1 确认。
Nginx 开源版没有 Prometheus 端点。要接入监控,有两个方案:
方案一:nginx-prometheus-exporter(官方推荐)
bash # 需要先启用 stub_status
curl -s http://127.0.0.1:8081/nginx_status
# 启动 exporter
docker run -d --name nginx-exporter -p 9113:9113 \
nginx/nginx-prometheus-exporter:1.4.0 \
--nginx.scrape-uri=http://192.168.1.10:8081/nginx_status
注意:stub_status 只提供 7 个指标(active/accepts/handled/requests/reading/writing/waiting)。想拿到 upstream 的响应时间、缓存命中率,必须靠日志,或者用 nginx-module-vts 这类第三方模块。
方案二:日志转指标
用 mtail 或 grok_exporter 从日志提取指标:
text # mtail 规则示例:/etc/mtail/nginx.mtail
counter nginx_requests_total by status
counter nginx_request_duration_seconds
/^(\S+) - \S+ \[[^\]]+\] "(\S+) (\S+) [^"]*" (\d{3}) .* rt=([0-9.]+)/ {
nginx_requests_total[$4]++
nginx_request_duration_seconds += $5
}
关键指标与告警建议:
| 指标 | 来源 | 告警阈值建议 |
|---|---|---|
| 5xx 比例 | 日志 | > 1% 持续 5 分钟 |
| P99 延迟 | 日志 rt | > 基线 2 倍持续 10 分钟 |
accepts - handled | stub_status | > 0 且持续增长(fd 不够) |
waiting / active | stub_status | < 20%(连接复用失效) |
| 499 比例 | 日志 | > 5%(客户端放弃) |
X-Cache-Status: STALE | 日志 | 出现即告警(后端故障) |
上游 connect() failed | error.log | 出现即告警 |
阈值必须结合业务基线。上面给的是通用起始值,上线后要按自己的流量特征调整。比如一个只有 10 QPS 的内部系统,5xx 比例 1% 就是每分钟 6 个错误,可能完全不可接受。
现象:压测 3000 QPS 时延迟从 30ms 涨到 800ms,Nginx 和后端 CPU 都不高。
初判:CPU 不高说明不是计算瓶颈,而是等待。等待的位置可能是:TCP 建连、后端线程池、锁。
检查:
bash # ① 连接复用是否生效
curl -s http://127.0.0.1:8081/nginx_status
# 看 requests / handled 的比值,接近 1 说明没复用
# ② TIME_WAIT 数量
ss -s | grep -i timewait
# ③ Nginx 到后端的连接数
ss -tn state established '( dport = :8080 )' | wc -l
# ④ 后端 accept 队列是否溢出
nstat -az TcpExtListenOverflows TcpExtListenDrops
指标判读:
TcpExtListenOverflows 增长 → 后端 accept 队列满,后端处理不过来或 somaxconn 太小。TIME_WAIT 十万以上 → 没复用连接。established 到后端的连接数 = QPS × 平均响应时间,如果远大于这个值,说明连接泄漏。根因:proxy_http_version 为 1.0(或没设 keepalive),每个请求新建 TCP 连接,端口和 CPU 都消耗在建连上。
修复:按第十节配置 proxy_http_version 1.1 + keepalive 64 + Connection ""。
验证:重压后 requests / handled 应上升到 10 以上,TIME_WAIT 降到几千。
回滚:把 keepalive 去掉即可,无状态变更。
复盘:压测前先确认连接复用,不要等上线才发现。
现象:每小时几十个 502,不成片,随机分布。
初判:间歇性说明不是后端完全挂掉(那会全部 502),而是特定条件下的连接失败。
检查:
bash # 看 error.log 的具体错误信息
grep -E 'upstream (prematurely|sent)' /var/log/nginx/error.log | tail -50
# 统计各后端被 502 的次数
awk '$9 == 502 {print $9}' /var/log/nginx/access.log | wc -l
grep -o 'uaddr=[0-9.:]*' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
典型错误信息与根因:
| error.log 内容 | 根因 | 处理 |
|---|---|---|
upstream prematurely closed connection while reading response header | 后端 keepalive_timeout 比 Nginx 短,或后端线程池满主动关闭 | 调大后端 keepalive;调小 Nginx upstream keepalive_timeout |
connect() failed (111: Connection refused) | 后端进程挂了或端口不对 | 查后端进程;查 ss -lntp |
upstream sent too big header | 后端响应头超过 proxy_buffer_size | 调大 proxy_buffer_size 16k; |
no live upstreams | 所有后端都被 max_fails 摘除 | 立即介入,看后端健康 |
recv() failed (104: Connection reset by peer) | 后端进程崩溃、被 OOM 杀、或 SO_LINGER 强关 | 查后端日志和 dmesg |
最典型的一个:upstream prematurely closed connection
根因几乎总是"后端先关连接"。Tomcat 的 maxKeepAliveRequests 默认 100,处理 100 个请求后主动关闭。而 Nginx 不知道,从池里取出这条连接发请求,写入成功但读不到响应 → 502 → 重试。
修复(两侧都要改):
nginx # Nginx 侧:让 Nginx 先关,而不是让后端关
upstream app_backend {
keepalive 64;
keepalive_requests 90; # 小于后端的 100,Nginx 主动关
keepalive_timeout 50s; # 小于后端的 idle timeout
}
properties # Tomcat 侧:server.xml 或 application.properties
server.tomcat.max-keep-alive-requests=1000
server.tomcat.connection-timeout=20000
验证:grep -c 'prematurely closed' /var/log/nginx/error.log 在修复后应停止增长。
回滚:改回原值,reload。
现象:某个接口在高峰期大量 504,urt 接近 proxy_read_timeout。
初判:后端处理慢,不是连接问题。
检查:
bash # 找出 504 的 URL
awk '$9 == 504 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
# 看这些请求的 upstream 响应时间
grep 'ustat=504' /var/log/nginx/access.log | grep -o 'urt=[0-9.,]*' | sort | uniq -c | sort -rn | head
# 后端线程池状态(Java 场景)
curl -s http://10.0.1.11:8080/actuator/metrics/tomcat.threads.busy
判断依据:
urt 恰好等于 proxy_read_timeout → 纯粹的等待超时。修复方向(按优先级):
proxy_read_timeout(治标,但能减少用户可见错误)。注意:调大会让慢请求占用连接更久,可能引发连锁。不要做的:不要一上来就调大 proxy_read_timeout 到 300s。这会把 504 变成"用户等 5 分钟",体验更差,而且连接和内存占用翻几倍。
现象:前端上传 100MB 文件,报 413 或 502。
413 Request Entity Too Large:client_max_body_size 太小(默认 1m)。
nginx client_max_body_size 100m;
502 出现在上传过程中:通常是 client_body_buffer_size 太小导致落盘,而 client_body_temp_path 磁盘满了:
bash df -h /var/cache/nginx/client_temp
ls /var/cache/nginx/client_temp | wc -l
grep 'No space left' /var/log/nginx/error.log
修复:把 client_body_temp_path 指到大磁盘,并清理残留(Nginx 异常退出时会留下临时文件)。
上传慢:client_body_timeout 默认 60s,是"两次读之间的间隔"。慢速网络上传大文件,只要不断流就不会超时。但如果中间有停顿超过 60s,会报 client intended to send too large body 或直接断开。
正确的上传配置:
nginx location /upload/ {
client_max_body_size 200m;
client_body_buffer_size 1m;
client_body_temp_path /data/nginx/client_temp 1 2;
client_body_timeout 300s;
proxy_pass http://app_backend;
proxy_request_buffering off; # 边收边发,不落盘,适合大文件
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
proxy_request_buffering off 让 Nginx 把请求体边收边转发给后端,省掉"完整落盘再读出来"的一轮磁盘 IO。代价:后端要能处理慢速上传(后端自己的读超时要够);如果需要重试,请求体已经发出去了一部分,无法重放。所以 proxy_request_buffering off 与 proxy_next_upstream 是有冲突的,开了 buffering off 就不要指望重试。
现象:改了配置、nginx -s reload 也执行了,行为没变。
检查步骤:
bash # ① reload 是否真的成功
nginx -t
# 语法有错时 reload 会失败,但错误可能只在 error.log 里
tail -20 /var/log/nginx/error.log
# ② 实际生效的配置是什么(含 include)
nginx -T | grep -A10 'server_name api.example.com'
# ③ worker 是否重启了(PID 变了)
ps -eo pid,lstart,cmd | grep '[n]ginx: worker'
常见原因:
| 原因 | 判断方法 |
|---|---|
配置文件有 include,改的文件没被包含 | nginx -T 里搜不到你改的内容 |
存在多个 nginx.conf(比如 /etc/nginx/nginx.conf 和 /usr/local/nginx/conf/nginx.conf) | ps -ef | grep nginx 看 -c 参数 |
server_name 没匹配上,走了默认 server | 日志里 host= 字段确认 |
location 优先级搞错(= > ^~ > 正则 > 前缀) | nginx -T 看所有匹配的 location |
| 浏览器/客户端缓存 | 用 curl -H 'Cache-Control: no-cache' 验证 |
| reload 时旧 worker 还在处理旧配置的连接 | ps 里看是否有多个 worker |
location 匹配优先级(必须记牢):
text 1. = /exact/path 精确匹配,最高优先级
2. ^~ /prefix/ 前缀匹配且不再检查正则
3. ~ regex 正则匹配(区分大小写),按出现顺序
4. ~* regex 正则匹配(不区分大小写),按出现顺序
5. /prefix/ 普通前缀匹配,取最长匹配
最容易踩的坑:正则 location 的顺序很重要。location ~ \.php$ 写在 location ~ /api/ 前面,会导致 /api/x.php 走 PHP 处理而不是 API 代理。
现象:Nginx worker 的 RSS 从 30MB 涨到 2GB,不回落。
检查:
bash # 各 worker 的 RSS
ps -o pid,rss,cmd -p $(pgrep -P $(cat /run/nginx.pid) | tr '\n' ',' | sed 's/,$//')
# 看是否有大量活跃连接
curl -s http://127.0.0.1:8081/nginx_status
# 看是否有大响应的请求正在处理
ss -tn state established '( sport = :80 )' | wc -l
可能原因与判断:
| 原因 | 判断依据 | 处理 |
|---|---|---|
proxy_buffering on + 大响应 + 高并发 | proxy_buffers 配置过大,活跃连接数高 | 调小 buffer,或对大文件 proxy_buffering off |
proxy_cache 的 keys_zone 太大 | 共享内存在启动时就分配,不算 RSS 增长 | 正常现象,不用处理 |
| 内存碎片 | RSS 涨但 smaps 显示实际使用不多 | 定期 reload(新 worker 从干净状态开始) |
| 连接泄漏 | active 持续增长不降 | 查 keepalive 配置、客户端行为 |
| 第三方模块泄漏 | 只在特定模块启用后出现 | 逐个禁用排查 |
判断内存是否"真"在用:
bash grep -E 'Rss|Pss' /proc/<worker_pid>/smaps_rollup
Pss 比 Rss 更准(共享内存按比例分摊)。如果 Pss 远小于 Rss,说明大部分是共享内存(正常)。
reload 是有效的缓解手段:新 worker 从干净状态开始,旧 worker 处理完现有请求后退出。但这是"缓解"不是"解决",根本原因还要查。频繁 reload 本身有成本(连接重置、缓存冷启动),不能当成常态方案。
这一节把前面散落的命令集中起来,按"我要干什么"组织,每条都给出判断依据。
bash # 语法检查(不改任何东西)
nginx -t
# 输出完整生效配置(含 include 展开)
nginx -T
# 只输出某个 server 块的配置
nginx -T | awk '/server_name api.example.com/,/^}/'
# 平滑重载(不断已有连接)
nginx -s reload
# 优雅停止(等现有请求处理完)
nginx -s quit
# 立即停止(中断所有连接,危险)
nginx -s stop
# 重新打开日志文件(logrotate 用)
nginx -s reopen
# 查看编译参数
nginx -V 2>&1 | tr ' ' '\n' | grep -- '--'
# 查看已加载的动态模块
nginx -m
判断依据:nginx -t 返回 0 且输出 syntax is ok / test is successful 才能 reload。有任何一个 emerg,reload 会失败且旧配置继续生效——这是好事(不会因为改错配置挂掉),但容易误以为"改了没生效"。
bash # 状态页
curl -s http://127.0.0.1:8081/nginx_status
# 当前连接总数(按状态)
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c
# 到某个端口的 ESTAB 连接数
ss -tn state established '( dport = :8080 )' | wc -l
# 按源 IP 统计连接数(找异常客户端)
ss -tn state established '( sport = :80 )' | awk 'NR>1 {print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -20
# TIME_WAIT 统计
ss -tan state time-wait | wc -l
# 端口范围使用情况(判断是否耗尽)
ss -tan | awk 'NR>1 {print $4}' | grep -o ':[0-9]*$' | cut -d: -f2 | sort -n | uniq | wc -l
判断依据:
ESTAB 到后端的连接数应该接近 upstream 实例数 × keepalive × worker 数,不应随时间线性增长。TIME_WAIT 十万以上 → 连接复用失效或者 ip_local_port_range 太小。bash # 所有 nginx 进程
ps -eo pid,ppid,rss,etime,cmd | grep '[n]ginx'
# worker 数与 CPU 核数对比
echo "worker: $(pgrep -P $(cat /run/nginx.pid) | wc -l), cpu: $(nproc)"
# 每个 worker 的 fd 使用
for p in $(pgrep -P $(cat /run/nginx.pid)); do echo "$p $(ls /proc/$p/fd | wc -l)"; done
# 主进程 fd 上限
cat /proc/$(cat /run/nginx.pid)/limits | grep -i 'open files'
# worker 内存明细
ps -o pid,rss,vsz,cmd -p $(pgrep -P $(cat /run/nginx.pid) | tr '\n' ',' | sed 's/,$//')
判断依据:worker 数 == CPU 核数 是期望值。worker 数超过核数 2 倍以上,检查 worker_processes 是否被写死成大数字。单个 worker 的 fd 数接近 worker_connections 时,说明到了上限。
bash # 实时跟踪错误(去掉健康检查噪音)
tail -f /var/log/nginx/error.log | grep -v 'health'
# 最近 5 分钟的 5xx
awk -v d="$(date -d '5 min ago' '+%d/%b/%Y:%H:%M')" '$0 ~ d && $9 ~ /^5/' /var/log/nginx/access.log
# 按状态码统计
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
# 慢请求 TOP 20(rt > 1s)
awk -F'rt=' '{if (NF>1) {split($2,a," "); if (a[1]+0 > 1) print a[1], $0}}' /var/log/nginx/access.log | sort -rn | head -20
# 上游错误分类
grep -oE 'upstream (prematurely|sent|timed out)[^,]*' /var/log/nginx/error.log | sort | uniq -c | sort -rn
# 某个 request_id 的完整链路
grep 'a1b2c3d4e5f6' /var/log/nginx/access.log
注意:awk -F'rt=' 依赖日志格式里有 rt= 字段。如果你的 log_format 没有,先加上,否则所有基于它的命令都失效。
bash # 简单并发压测(ab,注意 ab 对 keepalive 支持不好)
ab -n 10000 -c 200 -k http://127.0.0.1/api/test
# wrk(支持 keepalive,更适合 Nginx)
wrk -t8 -c400 -d30s --latency http://127.0.0.1/api/test
# 带 header 压测
wrk -t8 -c400 -d30s -H 'Host: api.example.com' -H 'Authorization: Bearer xxx' http://127.0.0.1/api/test
# 只测建连能力
h2load -n 10000 -c 100 https://api.example.com/
# TLS 握手测试
openssl s_time -connect api.example.com:443 -new -time 10
ab 的 -k 参数:开启 keepalive。不加 -k 时 ab 每个请求新建连接,测出来的 QPS 会远低于实际能力。这是"ab 测出来和实际差很多"的主要原因。
bash # 证书链与有效期
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates
# 完整证书链
echo | openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts 2>/dev/null | \
grep -E 'i:|s:'
# 支持的协议版本
for v in tls1 tls1_1 tls1_2 tls1_3; do
r=$(echo | openssl s_client -connect api.example.com:443 -servername api.example.com -$v 2>&1 | grep -c 'Cipher is')
echo "$v: $r"
done
# OCSP Stapling
echo | openssl s_client -connect api.example.com:443 -servername api.example.com -status 2>/dev/null | \
grep -A2 'OCSP Response Status'
# 会话复用
echo | openssl s_client -connect api.example.com:443 -servername api.example.com -reconnect 2>&1 | grep -i 'reused'
判断依据:
tls1 和 tls1_1 应该返回 0(不支持),否则有合规风险。-reconnect 输出里出现 Reused 说明会话复用生效。bash # 抓 502 现场的 HTTP 交互
tcpdump -i any -s0 -A 'tcp port 8080 and tcp[tcpflags] & (tcp-push|tcp-ack) != 0' -c 100
# 统计各状态码的 TCP 连接重置
nstat -az TcpExtListenOverflows TcpExtListenDrops TcpExtTCPAbortOnClose
# 查看 Nginx 的 syscall 分布(性能瓶颈定位)
strace -c -f -p $(pgrep -P $(cat /run/nginx.pid) | head -1)
# 用 perf 看 CPU 热点
perf top -p $(pgrep -P $(cat /run/nginx.pid) | head -1)
nstat 的三个关键指标:
| 指标 | 含义 | 出现增长说明 |
|---|---|---|
TcpExtListenOverflows | accept 队列溢出 | 后端 accept 太慢,somaxconn 太小 |
TcpExtListenDrops | accept 队列满导致丢弃 | 同上,更严重 |
TcpExtTCPAbortOnClose | 有未读数据时关闭连接 | 客户端提前断开,或 Nginx 主动 abort |
Nginx 配置是代码,不是手改的文本。 生产环境应该有:
.conf 文件进 Git,包括 conf.d/、snippets/。proxy_pass 目标、server_name、location 优先级。nginx -t → 灰度单台 → 观察指标 → 全量 → 归档。不能直接在全部机器上 reload。为什么强调这个:Nginx 配置的"改完没事"是假象。一个 location 优先级写错,可能只影响某个特定路径的请求,而那个路径恰好是低频接口,一周后才被发现。
nginx -s reload 看起来无风险,实际有三类副作用:
| 副作用 | 表现 | 缓解 |
|---|---|---|
| 旧 worker 不退出 | 长连接(WebSocket、SSE、大文件下载)让旧 worker 存活数小时 | 监控 worker 数;必要时 nginx -s quit 后重启 |
| 内存阶段性翻倍 | 新旧 worker 并存 | 确认内存有 2 倍余量 |
| 缓存冷启动 | reload 不清缓存,但新 worker 的连接池是空的 | 短暂的回源增加 |
长连接场景的 reload 策略:
bash # 1. 先摘流(前置 SLB 摘掉这台)
# 2. reload
nginx -s reload
# 3. 观察旧 worker 是否退出
watch -n2 'ps -eo pid,etime,cmd | grep "[n]ginx: worker"'
# 4. 超过预期时间(如 30 分钟)仍未退出,强制优雅退出
nginx -s quit
nginx -s quit 会让 worker 处理完现有请求后退出,不接受新连接。但如果有 WebSocket 长连接一直不断,worker 会一直不退出。
| 变更类型 | 推荐窗口 | 原因 |
|---|---|---|
加 location / 改静态资源规则 | 任意(低峰更好) | 影响面小 |
改 upstream 目标 / 权重 | 低峰 | 连接池重建,短暂抖动 |
改 worker_processes / worker_connections | 低峰 + 摘流 | 需要 restart 或内存翻倍 |
| 改 TLS 证书 | 任意,但要先验证 | reload 生效 |
改 limit_req 阈值 | 低峰,先 dry-run | 阈值过严会误杀 |
| 升级 Nginx 版本 | 低峰 + 摘流 + 完整回归 | 需要 restart |
升级 Nginx 版本(尤其是跨大版本)可能改变默认值。1.29.7 的 proxy_http_version 默认值变化就是例子。
bash # 升级前
nginx -V 2>&1 | tr ' ' '\n' | grep -- '--' | sort > /tmp/nginx-modules-before.txt
cp -a /etc/nginx /etc/nginx.bak.$(date +%F)
# 升级
dnf upgrade nginx
# 升级后
nginx -V 2>&1 | tr ' ' '\n' | grep -- '--' | sort > /tmp/nginx-modules-after.txt
diff /tmp/nginx-modules-before.txt /tmp/nginx-modules-after.txt
nginx -t && systemctl restart nginx
升级检查清单:
configure arguments 是否变化(模块增减)nginx -t 会警告)dnf downgrade nginx 或保存旧 RPM)一定要看 release notes 的 “Change” 而不是 “Feature”。行为变更(默认值、语义)比新功能重要得多,往往藏在不起眼的位置。
nginx # 隐藏版本号
server_tokens off;
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# 限制状态页访问
location /nginx_status {
stub_status;
allow 10.0.0.0/8;
allow 127.0.0.1;
deny all;
}
# 禁止执行上传目录中的脚本
location ~* ^/upload/.*\.(php|jsp|sh|py)$ {
deny all;
}
server_tokens off 的实际效果:把 Server: nginx/1.30.6 变成 Server: nginx。这能减少针对性扫描,但不能替代及时打补丁。真正的安全来自版本更新。
status 页必须限制访问。暴露 stub_status 会泄露流量特征,是很常见的低危漏洞。
Nginx 历史上高危漏洞不多,但有几个必须知道:
| CVE | 影响版本 | 类型 | 处置 |
|---|---|---|---|
| CVE-2021-23017 | < 1.21.1 | DNS resolver 堆溢出 | 升级 |
| CVE-2019-9511/9513/9516 | < 1.17.3 | HTTP/2 DoS(内存放大) | 升级 |
| CVE-2021-3618 | < 1.21.0 | 多协议混淆(ALPACA) | 升级,TLS 严格校验 |
| CVE-2023-44487 | HTTP/2 相关 | Rapid Reset(HTTP/2 层间接影响) | 升级,限并发流 |
订阅官方安全通告:https://nginx.org/en/security_advisories.html。遇到新 CVE 时,先判断自己的配置是否受影响(比如没开 HTTP/2 就不受 HTTP/2 漏洞影响),再做升级决策。
| 资源 | 估算方法 | 备注 |
|---|---|---|
| 内存 | 基础 50MB + 连接数 × 200KB + 缓存 zone | buffer 大小决定系数 |
| CPU | 静态文件约 1 核 / 5000 QPS;TLS 约 1 核 / 3000 握手 | 实测为准 |
| fd | worker_processes × worker_connections × 1.2 | 反向代理翻倍 |
| 磁盘 | 日志(按 QPS × 日志行长度 × 保留天数)+ 缓存 max_size | 日志压缩后约 1/10 |
| 端口 | QPS × 平均连接时长,不超过 ip_local_port_range 大小 | 开了 keepalive 后基本不成问题 |
这些数字是量级参考,不是精确值。真实容量必须在目标环境上压测得出。同一份配置在 NVMe 和机械盘上的静态文件 QPS 能差 10 倍。
把前面的内容整合成一份配置,可以直接作为新项目的起点。
nginx # /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
worker_rlimit_nofile 102400;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 10240;
multi_accept on;
accept_mutex off;
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 rt=$request_time '
'urt=$upstream_response_time uaddr=$upstream_addr '
'ustat=$upstream_status rid=$request_id host=$host '
'cache=$upstream_cache_status';
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
# ---------- 基础传输 ----------
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65s;
keepalive_requests 1000;
server_tokens off;
types_hash_max_size 2048;
client_max_body_size 50m;
client_body_buffer_size 128k;
client_header_timeout 10s;
client_body_timeout 30s;
send_timeout 30s;
# ---------- 文件缓存 ----------
open_file_cache max=20000 inactive=60s;
open_file_cache_valid 80s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# ---------- 压缩 ----------
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml+rss application/atom+xml image/svg+xml;
# 注意:不要 gzip 图片和视频,它们已经是压缩格式
# ---------- 限流 zone ----------
limit_req_zone $binary_remote_addr zone=perip:20m rate=50r/s;
limit_req_zone $server_name zone=pervhost:10m rate=5000r/s;
limit_conn_zone $binary_remote_addr zone=connip:20m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
# ---------- 缓存路径 ----------
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=apicache:100m
max_size=20g inactive=60m use_temp_path=off;
# ---------- upstream ----------
upstream app_backend {
zone app_backend 64k;
least_conn;
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.13:8080 max_fails=3 fail_timeout=10s;
keepalive 64;
keepalive_requests 900;
keepalive_timeout 50s;
}
# ---------- 状态页 ----------
server {
listen 127.0.0.1:8081;
server_name _;
location /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
}
include /etc/nginx/conf.d/*.conf;
}
nginx # /etc/nginx/conf.d/api.conf
map $http_x_request_id $rid {
"" $request_id;
default $http_x_request_id;
}
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/example.com.chain.pem;
resolver 223.5.5.5 valid=300s;
limit_req zone=pervhost burst=2000 nodelay;
limit_conn connip 50;
# ---------- API ----------
location /api/ {
limit_req zone=perip burst=100 nodelay;
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $rid;
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
}
# ---------- 可缓存的只读接口 ----------
location /api/catalog/ {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Request-ID $rid;
proxy_cache apicache;
proxy_cache_valid 200 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_key "$scheme$host$request_uri";
add_header X-Cache-Status $upstream_cache_status always;
}
# ---------- SSE ----------
location /api/stream {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Request-ID $rid;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no;
}
# ---------- 静态资源 ----------
location /static/ {
alias /data/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
gzip_static on;
aio threads;
directio 4m;
open_file_cache max=50000 inactive=300s;
open_file_cache_valid 600s;
open_file_cache_min_uses 1;
}
location = /index.html {
root /data/www;
add_header Cache-Control "no-cache, must-revalidate";
etag on;
}
# ---------- 兜底 ----------
location / {
return 404;
}
}
这份配置的说明:
worker_rlimit_nofile 102400 是 Nginx 自己的限制,必须小于等于 systemd 的 LimitNOFILE,否则以较小者为准。open_file_cache 单独覆盖为更长的 valid(600s),因为静态文件很少变;API 路由不用文件缓存。location / 返回 404 而不是上楼默认站点,避免误把根路径暴露出去。/api/ 按 IP(perip),整个 vhost 按总量(pervhost)。两级都要有,否则单个 IP 的正常速率(50r/s)乘以大量 IP 仍能打死后端。这篇文章讲了八件事,最后收成四句可执行的话。
第一句:先量,再调。 没有基线就没有优化。开工前用 stub_status、$request_time 分布、ss -s 建立基线,调完对比。没有基线的"调优"只是在制造变更。
第二句:瓶颈通常在连接层,不在参数层。 复盘我遇到的绝大多数"QPS 上不去",根因是连接复用失效、fd 不够、或者后端 keepalive 配置不匹配。worker_processes、worker_connections 这些参数很重要,但它们决定的是天花板,不是实际吞吐。真实吞吐取决于最短的那块板。
第三句:默认值会变,配置要显式。 1.29.7 改了 proxy_http_version 的默认值,这类变化会让"照着三年前的教程写的配置"行为不同。凡是影响正确性的指令(proxy_http_version、Connection、keepalive 系列),都显式写出来,不依赖默认值。
第四句:保护性配置比性能配置更重要。 限流、超时、max_fails、proxy_cache_use_stale——这些在正常情况下看起来"没用",但在异常时决定了系统是"降级"还是"雪崩"。性能调优让系统跑得快,保护性配置让系统不崩。
三个值得长期维护的机制:
$request_id、rt、urt、ustat 这几个字段要长期保留,所有日志分析脚本都依赖它们。改 log_format 等于让所有监控失效。Nginx 的配置项有几百个,但真正影响生产稳定性的不超过二十个。上面这些就是那二十个。剩下的,遇到具体问题再查文档,比一开始全部配上要可靠得多。
阅读原文:点击这里