nginx 做反向代理,默认配置能跑,但离「跑得好」有距离。这些年攒下来的调整,按影响从大到小排。每一项都附上怎么验证,因为盲目抄配置比不配还危险。
一、上游连接默认是短连接
这是最容易被忽略、收益又最大的一项。nginx 到后端默认每个请求建一次 TCP 连接,用完就关。表现是后端机器上一堆 TIME_WAIT,以及每个请求多出一个 RTT 的握手开销。
ss -tan state time-wait | wc -l
开启上游长连接需要三处配合,缺一不可:
upstream app_backend {
server 10.0.1.11:8080 max_fails=3 fail_timeout=15s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=15s;
keepalive 64; # 每个 worker 保持的空闲连接数
keepalive_timeout 60s;
keepalive_requests 1000; # 单连接最多复用多少次请求
}
server {
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1; # 必须,默认 1.0 不支持长连接
proxy_set_header Connection ""; # 必须,清掉默认的 close
}
}
只写 keepalive 64; 而漏掉后两行是常见错误——配置能加载,但长连接根本没生效。验证方式是压测时观察后端的连接建立速率:
ss -tanp state established '( dport = :8080 )' | wc -l
# 开启前:随并发线性增长且大量 TIME_WAIT
# 开启后:稳定在 keepalive 数量附近
keepalive 的值不是越大越好。它是每个 worker 进程的空闲连接数,实际上游连接数要乘以 worker_processes。我们的经验是设成「峰值 QPS × 平均响应时间 ÷ worker 数」再留 30% 余量。
二、缓冲区太小会导致落盘
nginx 默认会把上游响应先缓冲下来再发给客户端,这样后端可以尽快释放。但缓冲区放不下时,nginx 会写临时文件到磁盘——高并发下这是灾难。
判断是否发生了落盘,看 error log 里的这条警告:
[warn] an upstream response is buffered to a temporary file
/var/cache/nginx/proxy_temp/3/07/0000000073 while reading upstream
调整方式是先量出响应体的大小分布,再据此设置。我们的 API 响应 P95 在 24 KB 左右,配置就按这个走:
proxy_buffering on;
proxy_buffer_size 16k; # 存放响应头,头大时(比如很多 Set-Cookie)要调大
proxy_buffers 8 16k; # 8 个 16k 的缓冲区,共 128k
proxy_busy_buffers_size 32k; # 可以边收边发的上限
proxy_max_temp_file_size 0; # 禁止落盘:装不下就直接转发,不写磁盘
proxy_max_temp_file_size 0 是我很喜欢的一个设置:它让 nginx 在缓冲区不足时退化成边收边发,而不是去写磁盘。副作用是慢客户端会占住上游连接更久,所以要配合合理的超时。
有两类接口应该显式关闭缓冲:SSE(服务器推送事件)和大文件下载。前者需要立即透传,后者缓冲没有意义:
location /api/events {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding off;
}
三、超时的默认值都太长
三个超时的默认值都是 60 秒,对内网后端来说长得离谱:
| 指令 | 默认 | 建议 | 含义 |
|---|---|---|---|
proxy_connect_timeout | 60s | 2s | 与上游建立 TCP 连接 |
proxy_send_timeout | 60s | 10s | 两次写操作之间的间隔 |
proxy_read_timeout | 60s | 15s | 两次读操作之间的间隔 |
proxy_connect_timeout 尤其关键。内网建连正常在个位数毫秒,如果 2 秒还连不上,那台机器基本是死的,早点失败早点切走。设成 60 秒意味着一台机器挂掉后,打到它的请求要等一分钟才会失败,连接池很快就被耗尽了。
注意 proxy_read_timeout 不是「响应总时长上限」,而是两次成功读取之间的最大间隔。持续输出的流式响应不会被它掐断。真要限制总时长,用 proxy_next_upstream_timeout 或者在应用层做。
配套的重试策略也要收紧:
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
不要把 non_idempotent 加进去,否则 POST 请求也会被重试,可能造成重复下单这类问题。
四、真实客户端 IP
加了一层代理之后,后端看到的 remote_addr 全是 nginx 的地址,限流、风控、审计全部失效。
proxy_set_header Host $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-Forwarded-Host $host;
如果 nginx 前面还有 CDN 或云负载均衡,需要用 realip 模块把可信来源的 X-Forwarded-For 还原成 $remote_addr:
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
只信任你确实控制的网段。把 set_real_ip_from 0.0.0.0/0 写上去等于让任何人伪造自己的 IP,限流和封禁都会被绕过。
五、把上游耗时打进日志
默认日志格式不包含上游信息,出问题时无法区分「nginx 慢」还是「后端慢」。这个日志格式我在每个项目里都会加:
log_format upstream_json escape=json
'{"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$uri",'
'"status":$status,'
'"body_bytes":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time",'
'"cache_status":"$upstream_cache_status"}';
access_log /var/log/nginx/access.log upstream_json buffer=64k flush=5s;
有了这几个字段,排查思路就清晰了:
request_time远大于upstream_response_time:慢在客户端网络或者 nginx 自己(比如缓冲区落盘、gzip 压缩)。upstream_connect_time明显偏高:上游连接池或者网络有问题。upstream_header_time接近upstream_response_time:后端在生成首字节前就慢,通常是数据库。upstream_addr出现逗号分隔的多个地址:发生过重试,说明某台上游不健康。
buffer=64k flush=5s 让日志批量写入,高 QPS 下能省下可观的系统调用。
六、gzip 的边界
压缩省带宽但费 CPU,两个常见错误是压太小的响应和重复压缩:
gzip on;
gzip_vary on;
gzip_comp_level 4; # 5 以上收益迅速递减,CPU 却明显上升
gzip_min_length 1024; # 小于 1KB 压了可能更大
gzip_proxied any;
gzip_types text/plain text/css application/json
application/javascript text/xml application/xml image/svg+xml;
注意 gzip_types 里不要写 text/html,它永远是被压缩的,写了会产生一条警告。图片、视频、字体(woff2 本身已压缩)也不要加进去。
更好的做法是静态资源在构建期就压好,运行时直接发预压缩文件:
gzip_static on; # 存在 app.js.gz 就直接发它,零 CPU 开销
压测对比
用 wrk 在同一台内网机器上对比,200 并发,持续 60 秒:
wrk -t8 -c200 -d60s --latency https://api.internal.example.com/v1/items
| 指标 | 默认配置 | 调优后 |
|---|---|---|
| QPS | 9,420 | 16,830 |
| P50 延迟 | 18.2 ms | 9.6 ms |
| P99 延迟 | 212 ms | 47 ms |
| 后端 TIME_WAIT 峰值 | 28,400 | 310 |
提升主要来自上游长连接(QPS 和 P50)和禁止缓冲落盘(P99)。超时和日志那两项不影响吞吐,但在故障时能省下大量时间。
最后:改配置的流程
# 1. 语法检查
nginx -t
# 2. 平滑重载,不断连接
nginx -s reload
# 3. 确认新旧 worker 交接完成
ps -o pid,ppid,etime,cmd -C nginx
reload 期间旧 worker 会处理完手上的请求再退出。如果 ps 里长时间存在多组 worker,说明有长连接一直没断——检查是不是有 WebSocket 或者 SSE 连接。设置 worker_shutdown_timeout 30s; 可以给它们一个上限。