nginx 反向代理调优笔记

·约 1800 字 · nginx性能运维

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_timeout60s2s与上游建立 TCP 连接
proxy_send_timeout60s10s两次写操作之间的间隔
proxy_read_timeout60s15s两次读操作之间的间隔

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
指标默认配置调优后
QPS9,42016,830
P50 延迟18.2 ms9.6 ms
P99 延迟212 ms47 ms
后端 TIME_WAIT 峰值28,400310

提升主要来自上游长连接(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; 可以给它们一个上限。

← 返回首页