目录

Nginx-12 性能调优实战

前置阅读:Nginx-11 日志与监控网络-10 TCP 实战调优

1. 调优方法论

先说最重要的一条:不要盲目抄参数。

网上大量的「Nginx 性能优化 20 条」文章会让你把 worker_connections 设到 100 万、把所有超时都调到 300 秒。这些配置在你的场景下大概率是有害的。

正确的顺序:

① 建立基线(不改任何配置,先测出当前能力)
② 明确目标(QPS 要多少?P99 延迟要多少?)
③ 压测,找出瓶颈在哪一层(CPU / 内存 / 网络 / 磁盘 / 后端)
④ 只针对瓶颈调整,一次只改一项
⑤ 重新压测,对比数据
⑥ 有效则保留,无效或变差则回滚
回到 ③

一次只改一项是纪律。同时改五个参数、性能提升了 20%,你不知道是哪一项起了作用,也不知道有没有某一项其实是负作用被其他项掩盖了。

还有一条更重要:绝大多数「Nginx 性能问题」的病根不在 Nginx。

表面现象 常见真实原因
Nginx 响应慢 后端业务慢(慢 SQL、外部 API 超时)
504 大量出现 后端处理能力不足或死锁
502 大量出现 后端进程崩溃/OOM/重启
CPU 100% TLS 握手(没开会话复用)、gzip 级别过高、正则过多
连接数爆满 后端慢导致连接堆积,或者没开 upstream keepalive

在动 Nginx 配置之前,先确认瓶颈真的在 Nginx。方法很简单:看 $upstream_response_time$request_time 的比例。如果 90% 的时间都在等后端,调 Nginx 是白费功夫。

2. 系统层调优

2.1 文件描述符

# 查看当前限制
ulimit -n
cat /proc/sys/fs/file-max
cat /proc/$(pgrep -f "nginx: master")/limits | grep "open files"
# /etc/security/limits.conf
*     soft  nofile  655350
*     hard  nofile  655350
root  soft  nofile  655350
root  hard  nofile  655350
# systemd 管理的服务不读 limits.conf!必须单独配
# /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=655350
systemctl daemon-reload
systemctl restart nginx

# 验证(这一步必须做)
cat /proc/$(pgrep -f "nginx: master")/limits | grep "open files"
# Max open files   655350   655350   files

systemd 环境下改了 limits.conf 却不生效,是最常见的坑之一。 systemd 启动的服务完全不读 /etc/security/limits.conf,必须用 LimitNOFILE

# Nginx 侧对应
worker_rlimit_nofile 65535;    # 必须 ≤ 系统的 hard limit

2.2 TCP 参数

# /etc/sysctl.d/99-nginx.conf

# ---------- 连接队列 ----------
# accept 队列长度,必须 ≥ nginx 的 listen backlog
net.core.somaxconn = 65535
# SYN 队列(半连接队列)长度
net.ipv4.tcp_max_syn_backlog = 65535
# 网卡收包队列,网卡快 CPU 处理不过来时用得上
net.core.netdev_max_backlog = 65535

# ---------- TIME_WAIT ----------
# 允许复用 TIME_WAIT 状态的端口用于新的出向连接(安全,可以开)
net.ipv4.tcp_tw_reuse = 1
# TIME_WAIT 总数上限
net.ipv4.tcp_max_tw_buckets = 262144
# FIN_WAIT_2 状态的超时
net.ipv4.tcp_fin_timeout = 15
# ⚠️ 注意:tcp_tw_recycle 在 Linux 4.12 已被移除,
#    在 NAT 环境下会导致连接被丢弃,不要再配它

# ---------- 端口范围 ----------
# 做反向代理时需要大量本地端口连上游
net.ipv4.ip_local_port_range = 1024 65000

# ---------- 缓冲区 ----------
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# 自动调整接收缓冲区(默认开,别关)
net.ipv4.tcp_moderate_rcvbuf = 1

# ---------- keepalive ----------
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3

# ---------- 防护 ----------
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_abort_on_overflow = 0    # 队列满时静默丢弃(让客户端重传)而不是发 RST

# ---------- 拥塞控制 ----------
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq

# ---------- 文件系统 ----------
fs.file-max = 2097152
fs.nr_open = 2097152

# ---------- 连接跟踪(如果开了 iptables/nf_conntrack)----------
net.netfilter.nf_conntrack_max = 1048576
net.netfilter.nf_conntrack_tcp_timeout_established = 600
sysctl --system
# 或
sysctl -p /etc/sysctl.d/99-nginx.conf

2.3 几个参数的重点说明

net.core.somaxconn 和 Nginx 的 backlog

server {
    listen 443 ssl backlog=65535;
}

backlog 是 accept 队列的长度。它的实际值受 somaxconn 限制——somaxconn 是 1024 的话,写 backlog=65535 也只会得到 1024。两个都要调。

队列满的表现:新连接被丢弃或超时,ss -lnt 里能看到:

ss -lnt | grep :443
# State  Recv-Q Send-Q  Local Address:Port
# LISTEN 0      65535   0.0.0.0:443
#        ↑             ↑
#     当前排队数     队列上限
# Recv-Q 持续接近 Send-Q → 队列在溢出

# 看溢出统计
nstat -az TcpExtListenOverflows TcpExtListenDrops
# 这两个数字持续增长 = 队列在丢连接

BBR 拥塞控制

# 确认可用
lsmod | grep tcp_bbr || modprobe tcp_bbr
sysctl net.ipv4.tcp_congestion_control

BBR 在有一定丢包的长肥管道(跨国、跨区域、移动网络)上比默认的 CUBIC 提升明显(吞吐可能翻倍)。在内网低延迟低丢包环境下差别很小,甚至可能略差。

判断标准:主要服务国内同城用户 → 保持 CUBIC;有跨国流量或移动端用户占比高 → 开 BBR。

tcp_tw_reuse vs tcp_tw_recycle

  • tcp_tw_reuse = 1安全,建议开。只影响主动发起连接的一方(Nginx 连上游时),复用 TIME_WAIT 的端口。依赖 TCP timestamps。
  • tcp_tw_recycle在 Linux 4.12 已被移除。它在 NAT 环境下会因为 timestamp 递增判断失败而丢弃合法连接,导致部分用户随机无法访问。老内核上如果发现有人配了它,删掉。

nf_conntrack 表满

如果服务器开了 iptables/firewalld,连接跟踪表满了会导致新连接被丢弃:

# 看当前使用量
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

# 表满的报错(在 dmesg 里)
dmesg | grep "nf_conntrack: table full"

高流量的 Nginx 机器最好直接关掉 conntrack(不用 iptables 的状态匹配),或者把 nf_conntrack_max 调到百万级。

2.4 CPU 与中断

# 关闭 CPU 节能(避免频率波动影响延迟稳定性)
cpupower frequency-set -g performance
# 或
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
    echo performance > "$c"
done

# 网卡多队列 + RSS:把网卡中断分散到多个 CPU
ethtool -l eth0                    # 查看队列数
ethtool -L eth0 combined 8         # 设成 CPU 核数

# 确认中断分布均匀
cat /proc/interrupts | grep eth0

# 如果不均匀,开 irqbalance
systemctl enable --now irqbalance

# 关闭透明大页(对 Nginx 影响不大,但对数据库很重要,顺手统一)
echo never > /sys/kernel/mm/transparent_hugepage/enabled

网卡中断集中在 CPU0 是常见问题。表现为 top 里 CPU0 的 si(softirq)接近 100%,其他核很闲,而 Nginx 吞吐上不去。用 cat /proc/interrupts 确认,然后开多队列 + irqbalance。

3. Nginx 参数调优清单

3.1 进程与连接

worker_processes      auto;         # = CPU 核心数
worker_cpu_affinity   auto;         # 绑核,减少缓存失效
worker_rlimit_nofile  65535;
worker_priority       -5;           # 提高调度优先级
worker_shutdown_timeout 30s;        # reload 时老 worker 的最长存活时间

events {
    worker_connections 20480;
    use epoll;
    multi_accept on;                # 短连接高并发场景开
    accept_mutex off;               # 配合 reuseport
}

worker_connections 怎么定?

需要的连接数 = 峰值并发客户端连接 × 2(反向代理时上游也占连接)
                + upstream keepalive 池
                + 一些余量

worker_connections = 需要的连接数 / worker_processes

例:峰值 5 万并发客户端,8 核: (50000 × 2 + 8×64) / 8 ≈ 12564,设 20480 有余量。

每个连接约占 500 字节固定内存ngx_connection_t + 两个 ngx_event_t)。worker_connections 20480 × 8 worker ≈ 82MB。设成 100 万是纯浪费——启动时就直接分配这么多结构体。

3.2 网络与传输

http {
    sendfile     on;
    sendfile_max_chunk 2m;      # 避免单个大文件长时间占住 worker
    tcp_nopush   on;
    tcp_nodelay  on;

    aio          threads;       # 大文件冷读场景
    directio     8m;
    output_buffers 2 512k;

    keepalive_timeout  65s;
    keepalive_requests 1000;
    reset_timedout_connection on;

    # 请求头/体的超时(防慢速攻击,也避免僵死连接占资源)
    client_header_timeout 15s;
    client_body_timeout   15s;
    send_timeout          15s;
}

3.3 缓冲区

缓冲区设小了会写临时文件(磁盘 I/O),设大了浪费内存。

http {
    # ---------- 客户端请求 ----------
    client_header_buffer_size   4k;      # 一般请求头 < 1k,4k 够
    large_client_header_buffers 4 16k;   # 长 Cookie / 长 URL 用这个
    client_body_buffer_size     128k;    # 超了写临时文件
    client_max_body_size        50m;

    # ---------- 上游响应 ----------
    proxy_buffer_size       8k;          # 响应头缓冲
    proxy_buffers        8 16k;          # 响应体缓冲,共 128k
    proxy_busy_buffers_size 32k;
    proxy_max_temp_file_size 1024m;      # 设 0 完全禁用临时文件
    proxy_temp_file_write_size 32k;
}

怎么知道缓冲区设小了? error_log 里会有:

[warn] an upstream response is buffered to a temporary file /var/cache/nginx/proxy_temp/1/00/0000000001
    while reading upstream

看到这个说明响应体超过了 proxy_buffers 的总大小。统计一下:

grep -c "buffered to a temporary file" /var/log/nginx/error.log

如果很多,说明大部分响应都在写磁盘。要么调大 proxy_buffers,要么接受这个开销(对大文件下载来说写临时文件是合理的)。

proxy_buffers 8 16k 的含义是 8 个 16KB 的缓冲区,共 128KB。API 响应通常远小于这个值,够用。如果你的接口返回大 JSON(比如几 MB 的列表),要调大:

proxy_buffers 16 64k;      # 共 1MB
proxy_busy_buffers_size 128k;

这些是「每个活跃连接」的开销proxy_buffers 16 64k × 1 万并发 = 理论上 10GB 内存。所以不能无脑调大,要按实际响应大小和并发量算。

3.4 上游连接

upstream backend {
    server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;

    keepalive 64;               # ★ 收益最大的一项
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

location / {
    proxy_pass http://backend;
    proxy_http_version 1.1;         # ★ 必须
    proxy_set_header Connection "";  # ★ 必须

    proxy_connect_timeout 3s;
    proxy_send_timeout    30s;
    proxy_read_timeout    30s;

    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 10s;
}

上游 keepalive 是性价比最高的优化(第 06 篇详述)。开启前后的对比:

# 开启前
ss -tan | grep :8080 | awk '{print $1}' | sort | uniq -c
#   50 ESTAB
# 5000 TIME-WAIT      ← 每个请求都新建连接

# 开启后
#   64 ESTAB
#    2 TIME-WAIT

收益:省掉每个请求的 TCP 三次握手(内网 0.1-1ms,跨可用区 1-5ms),消灭 TIME_WAIT 导致的端口耗尽。

3.5 压缩

gzip on;
gzip_static on;             # ★ 静态资源用预压缩,零 CPU 开销
gzip_comp_level 5;          # 不要用 9
gzip_min_length 1k;
gzip_vary on;
gzip_types ...;             # 不要包含图片/视频/压缩包/woff2

gzip_comp_level 的实测差异(1MB 的 JS 文件):

level 压缩后 CPU 时间
1 320 KB 1x
5 275 KB 2x
6 272 KB 2.5x
9 268 KB 6x

从 5 到 9 只多压了 2.5%,但 CPU 花了 3 倍。 所以 5 是性价比拐点。高流量站点用 9 是明显的资源浪费。

3.6 TLS

ssl_session_cache shared:SSL:50m;    # ★ 必须用 shared,收益最大
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_buffer_size 4k;                  # 网页服务用 4k 降低首字节延迟
ssl_ecdh_curve X25519:prime256v1;
# 双证书:ECDSA + RSA

3.7 文件缓存

# 缓存打开文件的 fd、size、mtime,减少 open/stat 系统调用
open_file_cache          max=100000 inactive=60s;
open_file_cache_valid    60s;
open_file_cache_min_uses 2;
open_file_cache_errors   on;

静态资源服务器上效果明显(省掉每个请求的 open + fstat)。反向代理为主的场景意义不大。

注意 open_file_cache_valid:文件被替换后,最多 60 秒内 Nginx 还在用旧的 fd。前端发版时可能造成短暂的新旧混合。发版后 nginx -s reload 可以立即清掉缓存。

3.8 日志

access_log /var/log/nginx/access.log main buffer=64k flush=5s;
error_log  /var/log/nginx/error.log warn;

# 高频无信息量的请求关日志
location = /health { access_log off; }
location ~* \.(png|jpg|css|js|woff2)$ { access_log off; }

4. 压测

4.1 工具选择

工具 特点 适合
ab 简单,单线程,HTTP/1.0 快速冒烟,别用来做正式压测
wrk 多线程 + 事件驱动,能支持 Lua 脚本 推荐,压测 Nginx 首选
wrk2 wrk 的分支,恒定速率压测,延迟统计更准 测延迟分布
vegeta Go 写的,恒定速率,输出丰富 CI 集成
k6 JS 脚本,场景化压测,云服务 复杂业务流程
hey Go 写的,ab 的现代替代 轻量场景

不要用 ab 做正式压测:它是单线程的,很容易自己先成为瓶颈,测出来的数字远低于服务端真实能力。而且它只支持 HTTP/1.0(不发 Host 头、不支持 keepalive 除非加 -k)。

4.2 wrk 用法

# 基础
wrk -t8 -c200 -d30s --latency http://127.0.0.1/api/test
# -t8    8 个线程(一般设成 CPU 核数)
# -c200  200 个并发连接
# -d30s  持续 30 秒
# --latency  输出详细延迟分布

# 带 POST 和请求头(用 Lua 脚本)
cat > post.lua <<'EOF'
wrk.method = "POST"
wrk.body   = '{"name":"test","value":123}'
wrk.headers["Content-Type"] = "application/json"
wrk.headers["Authorization"] = "Bearer xxx"
EOF
wrk -t8 -c200 -d30s -s post.lua --latency http://127.0.0.1/api/create

# 随机 URL(避免缓存干扰,测真实回源能力)
cat > random.lua <<'EOF'
math.randomseed(os.time())
request = function()
    local id = math.random(1, 1000000)
    return wrk.format("GET", "/api/user/" .. id)
end
EOF
wrk -t8 -c200 -d60s -s random.lua http://127.0.0.1/

# 输出解读
Running 30s test @ http://127.0.0.1/api/test
  8 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     4.82ms    3.15ms  89.32ms   88.45%
    Req/Sec     5.21k     0.43k    6.89k    72.31%
  Latency Distribution
     50%    4.12ms
     75%    5.98ms
     90%    8.45ms
     99%   18.23ms          ← 重点看这个
  1246789 requests in 30.02s, 412.35MB read
Requests/sec:  41531.28     ← 吞吐
Transfer/sec:     13.74MB

看 P99 而不是平均值。平均 4.82ms 看着很好,但 P99 是 18ms——1% 的用户体验是平均值的 4 倍。如果 P99 和 P50 差距超过 5 倍,说明有明显的长尾问题(GC、锁竞争、慢查询、磁盘抖动)。

4.3 压测的常见陷阱

① 压测客户端自己是瓶颈

# 压测时在客户端机器上看
top     # CPU 是否已满
ss -s   # 连接数是否达到上限
# 出现 "Cannot assign requested address" = 本地端口耗尽

# 解法:多台机器同时压,或者用 wrk 的多线程

② 在同一台机器上压测

Nginx 和 wrk 抢同一份 CPU,测出来的数字毫无意义。必须用独立的压测机,且网络带宽要足够(万兆网卡压 10 万 QPS 的小响应没问题,但如果每个响应 100KB,1 万 QPS 就把千兆网卡打满了)。

③ 一直请求同一个 URL

会被各级缓存命中,测出来是缓存的性能不是真实能力。用 Lua 脚本随机化 URL。

④ 忽略了后端

压测 Nginx 反向代理时,如果后端只有一个实例,很可能后端先到瓶颈。要么先单独压后端确认它的能力,要么用一个极简的 mock 后端(比如 return 200 "ok" 的 Nginx)来隔离测试。

⑤ 预热不足

刚启动的 Nginx 和后端都没预热(page cache 冷、JIT 没编译、连接池没建立)。先跑 30 秒热身再开始记录数据。

4.4 分层压测法

要定位瓶颈,必须分层测:

# ① 极限基线:Nginx 直接 return,不碰后端也不碰磁盘
# 这测出的是 Nginx + 内核 + 网络的极限
location = /bench { return 200 "ok"; }
wrk -t8 -c200 -d30s http://nginx/bench
# → 假设 12 万 QPS

# ② 静态文件
location = /bench.txt { root /var/www; }
wrk -t8 -c200 -d30s http://nginx/bench.txt
# → 假设 9 万 QPS(差距来自文件 I/O)

# ③ 直接压后端(绕过 Nginx)
wrk -t8 -c200 -d30s http://backend:8080/api/test
# → 假设 2 万 QPS

# ④ 通过 Nginx 压后端
wrk -t8 -c200 -d30s http://nginx/api/test
# → 假设 1.8 万 QPS

# 结论:Nginx 的代理开销只有 10%(2万→1.8万),
#      瓶颈明显在后端(2万 vs Nginx 自己的 12万)
#      → 应该去优化后端,而不是调 Nginx

这套分层对比是调优的第一步。跳过它直接改 Nginx 配置,大概率是在优化一个不是瓶颈的环节。

5. 瓶颈定位

5.1 CPU

# 总览
top -H -p $(pgrep -d, -f "nginx: worker")
# 关注:
#   %us 高 → 用户态计算(TLS 握手、gzip、正则)
#   %sy 高 → 系统调用多(日志写盘、小包收发)
#   %si 高 → 软中断(网卡中断没分散)

# 每个核的情况
mpstat -P ALL 1
# 如果只有 CPU0 的 %soft 很高 → 网卡中断集中,开多队列

# 火焰图定位热点函数(最直接的手段)
perf record -F 99 -p $(pgrep -f "nginx: worker" | head -1) -g -- sleep 30
perf script | ~/FlameGraph/stackcollapse-perf.pl | ~/FlameGraph/flamegraph.pl > nginx.svg

# 或者用 perf top 直接看
perf top -p $(pgrep -f "nginx: worker" | head -1)

常见的 CPU 热点及对策

火焰图里的热点 原因 对策
ngx_ssl_handshake / OpenSSL 函数占大头 TLS 握手 ssl_session_cache shared、换 ECDSA
deflate / zlib 函数 gzip 压缩 gzip_comp_level 到 5、用 gzip_static
ngx_regex_exec / PCRE 函数 正则太多或太复杂 减少正则 location、用 map 替代 if
ngx_http_log_handler / writev 日志写盘 buffer=64k flush=5s,关掉无用日志
memcpy 缓冲区拷贝 检查 proxy_buffers 是否过小导致反复拷贝
大量 epoll_wait 但 CPU 不高 正常,说明在等 I/O 瓶颈不在 Nginx

5.2 内存

# Nginx 的内存占用
ps -o pid,rss,vsz,cmd -p $(pgrep -d, nginx)

# 详细分解
pmap -x $(pgrep -f "nginx: worker" | head -1) | tail -3

# 系统整体
free -h
cat /proc/meminfo | grep -E "MemAvailable|Cached|Dirty"

Nginx 内存占用的组成

每个 worker 的内存 =
    固定开销(代码段 + 共享内存映射)
  + worker_connections × 约 500 字节(connection + event 结构)
  + 活跃连接数 × (proxy_buffers 总大小 + 请求池)
  + 各种 shared zone(keys_zone / limit_req_zone / ssl_session_cache)

共享内存是所有 worker 共用的,只算一次

proxy_cache_path ... keys_zone=my_cache:200m;   # 200MB
limit_req_zone ... zone=perip:20m;               # 20MB
limit_conn_zone ... zone=conn:10m;               # 10MB
ssl_session_cache shared:SSL:50m;                # 50MB
# 共享内存合计 280MB(不乘 worker 数)

内存不够的表现:

# OOM killer 杀了 worker
dmesg | grep -i "killed process"
grep -i oom /var/log/messages

# error.log 里
# [emerg] could not build the map, you should increase map_hash_bucket_size
# [emerg] ngx_slab_alloc() failed: no memory in cache keys zone

5.3 网络

# 连接状态分布
ss -s
ss -tan | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn

# 具体到端口
ss -tan state established '( sport = :443 )' | wc -l
ss -tan state time-wait | wc -l

# 队列溢出(重点)
nstat -az | grep -E "ListenOverflows|ListenDrops|TCPBacklogDrop"
# 这几个数字持续增长 = accept 队列在丢连接 → 调 somaxconn + backlog

# 重传率(网络质量)
nstat -az | grep -E "TcpRetransSegs|TcpOutSegs"
# 重传率 = RetransSegs / OutSegs,超过 1% 说明网络有问题

# 网卡
sar -n DEV 1
ethtool -S eth0 | grep -iE "drop|error|miss"
# rx_dropped / rx_missed_errors 增长 → 网卡队列不够或 CPU 处理不过来

# 带宽是否打满
iftop -i eth0
nload eth0

几个关键判断

指标 说明
ListenOverflows 增长 accept 队列满 → 调 somaxconn + backlog,或后端 accept 太慢
TIME-WAIT 几万以上 短连接太多 → 开 upstream keepalive、tcp_tw_reuse
CLOSE-WAIT 堆积 应用层 bug(收到 FIN 但没 close),去查后端代码,别调内核参数
重传率 > 1% 网络质量问题,查交换机/云厂商
rx_dropped 增长 网卡队列不够,开多队列、调 netdev_max_backlog

CLOSE_WAIT 堆积一定是应用 bug,这一点很重要。它意味着对端已经关闭了连接(发了 FIN),但你的程序没有调用 close()。改内核参数救不了,必须修代码。详见 网络-10 TCP 实战调优

5.4 磁盘

# I/O 概况
iostat -x 1
# 关注:
#   %util 接近 100% → 磁盘饱和
#   await 高(机械盘 > 20ms,SSD > 2ms) → I/O 延迟大
#   avgqu-sz 大 → 队列深,请求在排队

# 哪个进程在读写
iotop -o -P

# Nginx 在写什么
pidstat -d -p $(pgrep -f "nginx: worker" | head -1) 1

# 具体的文件操作
strace -p $(pgrep -f "nginx: worker" | head -1) -e trace=write,writev,openat -f 2>&1 | head -50

Nginx 的磁盘 I/O 来源:

  1. access_log → 用 buffer=64k flush=5s
  2. proxy_temp_file(缓冲区不够写临时文件)→ 调大 proxy_buffers
  3. proxy_cache 读写 → 正常,缓存目录放 SSD
  4. 静态文件读取open_file_cache + sendfile + aio threads

6. 常见性能问题案例

6.1 案例:QPS 上不去,CPU 却不满

现象:8 核机器,QPS 卡在 8000,CPU 只用了 30%。

排查

# 1. 看连接状态
ss -tan | grep :8080 | awk '{print $1}' | sort | uniq -c
#  5000 TIME-WAIT      ← 大量 TIME_WAIT
#    30 ESTAB

# 2. 看 error.log
grep "Cannot assign requested address" /var/log/nginx/error.log | wc -l
# 12453                ← 本地端口耗尽

原因:没配上游 keepalive,每个请求都新建到后端的连接,TIME_WAIT 耗尽了本地端口(ip_local_port_range 默认约 28000 个)。

修复

upstream backend {
    server 10.0.0.1:8080;
    keepalive 64;
}
location / {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}
# /etc/sysctl.conf 辅助
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65000

结果:QPS 从 8000 提到 35000,TIME_WAIT 从 5000 降到个位数。

6.2 案例:HTTPS 站点 CPU 打满

现象:切换到 HTTPS 后 CPU 从 30% 涨到 95%,QPS 掉了一半。

排查

perf top -p $(pgrep -f "nginx: worker" | head -1)
# 32.5%  libcrypto.so  rsaz_1024_mul_avx2
# 18.2%  libcrypto.so  bn_mul_mont
#        ↑ RSA 运算占了大头 = 全是新握手,没有复用

原因ssl_session_cache 没配(默认 none),每个连接都做完整的 RSA 握手。

修复

ssl_session_cache shared:SSL:50m;    # 关键
ssl_session_timeout 1d;
ssl_session_tickets on;

# 再加 ECDSA 证书
ssl_certificate     /etc/nginx/ssl/ecdsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;
ssl_certificate     /etc/nginx/ssl/rsa/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;

验证:

openssl s_client -connect example.com:443 -reconnect < /dev/null 2>&1 | grep -c "^Reused"
# 应该 > 0

结果:CPU 从 95% 降到 40%,QPS 恢复。

6.3 案例:偶发 502,日志显示上游主动关连接

现象upstream prematurely closed connection while reading response header,每分钟几十次,但后端没有报错日志。

原因:Nginx 的 upstream keepalive 超时(60s)长于后端的 keepalive 超时(比如 Go 默认的 IdleTimeout 或 nginx 后端的 keepalive_timeout)。后端先关了连接,而 Nginx 恰好在这个瞬间往这条连接上发了请求。

修复:让 Nginx 侧的 keepalive 超时短于后端的

upstream backend {
    server 10.0.0.1:8080;
    keepalive 64;
    keepalive_timeout 30s;       # 短于后端的 60s
    keepalive_requests 1000;     # 也要短于后端的上限
}

后端(Go):

srv := &http.Server{
    Addr:         ":8080",
    IdleTimeout:  60 * time.Second,   // 比 nginx 的 30s 长
    ReadTimeout:  15 * time.Second,
    WriteTimeout: 30 * time.Second,
}

规律:连接池的空闲超时必须由「持有池的一方」先关闭。 让 Nginx 主动淘汰连接,而不是等后端关。

顺便加个兜底重试(502 是安全的重试条件):

proxy_next_upstream error timeout http_502;
proxy_next_upstream_tries 2;

6.4 案例:大文件下载把整个站点拖慢

现象:有用户下载大文件时,其他所有请求的延迟都飙升。

原因

  1. 大文件冷读(不在 page cache)时 sendfile 阻塞了 worker,这个 worker 上的所有连接都被卡住
  2. 大文件把 page cache 里的热点小文件挤了出去(缓存污染)
  3. 没有限速,一个下载占满了带宽

修复

location /download/ {
    root /data;

    sendfile on;
    sendfile_max_chunk 2m;      # 单次 sendfile 上限,避免长时间占住 worker
    aio threads;                # 异步 I/O,不阻塞 worker
    directio 8m;                # 大文件绕过 page cache,避免污染
    output_buffers 2 512k;

    limit_rate 5m;              # 单连接限速
    limit_rate_after 10m;       # 前 10MB 不限速
    limit_conn dl_conn 2;       # 单 IP 最多 2 个并发下载

    gzip off;
    access_log off;
}
# main 块
thread_pool default threads=32 max_queue=65536;

6.5 案例:reload 后进程越来越多,内存吃紧

现象ps 里有几十个 nginx: worker process is shutting down

原因:老 worker 上有长连接(WebSocket / SSE / 大文件下载)没结束,一直不退出。频繁 reload 后老 worker 累积。

修复

worker_shutdown_timeout 30s;    # 超过 30 秒强制关闭连接后退出

同时减少 reload 频率。如果是配置管理系统在频繁 reload(比如每次服务注册变化就 reload),改成:

  • upstreamresolve 参数(1.27.3+ 开源版支持)让 DNS 变化自动生效
  • 或者用 OpenResty 的动态 upstream(lua-resty-balancer
  • 或者用 K8s 的 Service,让 kube-proxy/CNI 处理后端变化

6.6 案例:P99 延迟毛刺

现象:P50 是 5ms,P99 是 800ms,很规律地每隔几秒出现一批慢请求。

排查方向

# 1. 是不是日志写盘
# 临时关掉 access_log 对比
# 或者看 flush 周期是否和毛刺周期一致

# 2. 是不是 accept_mutex 的 500ms 延迟
grep -E "accept_mutex" /etc/nginx/nginx.conf
# accept_mutex on 且没开 reuseport → 低并发时会有 500ms 毛刺

# 3. 是不是后端 GC
# 看 $upstream_response_time 的分布是否也有同样的毛刺

# 4. 是不是磁盘抖动
iostat -x 1 | grep -E "await|util"

# 5. 是不是 CPU 频率波动
turbostat --interval 1

常见原因和修复

原因 修复
accept_mutex on 的 500ms 延迟 accept_mutex off + listen ... reuseport
日志同步写盘 access_log ... buffer=64k flush=5s
后端 GC 停顿 后端调 GC 参数;Nginx 侧加 proxy_next_upstream timeout 兜底
磁盘 I/O 抖动 缓存/日志放 SSD,开 aio threads
CPU 节能降频 cpupower frequency-set -g performance
网卡中断集中在一个核 开多队列 + irqbalance

7. 调优前后的完整配置对照

7.1 未调优(默认配置)

worker_processes 1;

events {
    worker_connections 1024;
}

http {
    include mime.types;
    sendfile on;
    keepalive_timeout 65;

    server {
        listen 80;
        location /api/ {
            proxy_pass http://127.0.0.1:8080;
        }
    }
}

7.2 调优后

user  nginx;
worker_processes      auto;
worker_cpu_affinity   auto;
worker_rlimit_nofile  65535;
worker_priority       -5;
worker_shutdown_timeout 30s;

error_log /var/log/nginx/error.log warn;
pid       /var/run/nginx.pid;

thread_pool default threads=32 max_queue=65536;

events {
    worker_connections 20480;
    use epoll;
    multi_accept on;
    accept_mutex off;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    server_tokens off;

    # ---------- 日志 ----------
    log_format main escape=json
        '{"time":"$time_iso8601","id":"$request_id","addr":"$remote_addr",'
        '"host":"$host","method":"$request_method","uri":"$request_uri",'
        '"status":$status,"bytes":$body_bytes_sent,"rt":$request_time,'
        '"urt":"$upstream_response_time","uaddr":"$upstream_addr",'
        '"cache":"$upstream_cache_status"}';
    access_log /var/log/nginx/access.log main buffer=64k flush=5s;

    # ---------- 传输 ----------
    sendfile           on;
    sendfile_max_chunk 2m;
    tcp_nopush         on;
    tcp_nodelay        on;
    aio                threads;
    directio           8m;
    output_buffers     2 512k;

    # ---------- 连接 ----------
    keepalive_timeout  65s;
    keepalive_requests 1000;
    reset_timedout_connection on;
    client_header_timeout 15s;
    client_body_timeout   15s;
    send_timeout          15s;

    # ---------- 缓冲区 ----------
    client_header_buffer_size   4k;
    large_client_header_buffers 4 16k;
    client_body_buffer_size     128k;
    client_max_body_size        50m;

    # ---------- 压缩 ----------
    gzip on;
    gzip_static on;
    gzip_vary on;
    gzip_min_length 1k;
    gzip_comp_level 5;
    gzip_proxied any;
    gzip_types text/plain text/css text/xml application/json
               application/javascript application/xml+rss image/svg+xml;

    # ---------- 文件缓存 ----------
    open_file_cache          max=100000 inactive=60s;
    open_file_cache_valid    60s;
    open_file_cache_min_uses 2;
    open_file_cache_errors   on;

    # ---------- TLS ----------
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve X25519:prime256v1;
    ssl_session_cache shared:SSL:50m;
    ssl_session_timeout 1d;
    ssl_session_tickets on;
    ssl_buffer_size 4k;
    ssl_stapling on;
    ssl_stapling_verify on;

    resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
    resolver_timeout 5s;

    # ---------- 缓存 ----------
    proxy_cache_path /var/cache/nginx/proxy levels=1:2
                     keys_zone=api:200m max_size=20g
                     inactive=2h use_temp_path=off;

    # ---------- 限流 ----------
    limit_req_zone  $binary_remote_addr zone=perip:20m rate=30r/s;
    limit_conn_zone $binary_remote_addr zone=conn:20m;
    limit_req_status  429;
    limit_conn_status 429;

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    upstream backend {
        least_conn;
        server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
        server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
        keepalive 64;
        keepalive_requests 1000;
        keepalive_timeout 30s;
    }

    server {
        listen 443 ssl backlog=65535 reuseport;
        http2 on;
        server_name api.example.com;

        ssl_certificate     /etc/nginx/ssl/ecdsa/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;
        ssl_certificate     /etc/nginx/ssl/rsa/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;

        add_header Strict-Transport-Security "max-age=63072000" always;

        limit_conn conn 30;
        limit_req  zone=perip burst=60 nodelay;

        location = /health {
            access_log off;
            return 200 "ok\n";
        }

        location /api/ {
            proxy_pass http://backend;
            proxy_http_version 1.1;
            proxy_set_header Connection        "";
            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-Request-ID      $request_id;

            proxy_connect_timeout 3s;
            proxy_send_timeout    30s;
            proxy_read_timeout    30s;

            proxy_buffering on;
            proxy_buffer_size 8k;
            proxy_buffers  8 16k;
            proxy_busy_buffers_size 32k;

            proxy_next_upstream error timeout http_502 http_503 http_504;
            proxy_next_upstream_tries 2;
            proxy_next_upstream_timeout 10s;

            proxy_cache api;
            proxy_cache_valid 200 5m;
            proxy_cache_lock on;
            proxy_cache_use_stale error timeout updating http_5xx;
            proxy_cache_background_update on;
            proxy_cache_revalidate on;

            add_header X-Cache-Status $upstream_cache_status always;
            add_header X-Request-ID   $request_id always;
        }
    }
}

8. 调优 checklist

上线前逐项确认:

系统层

  • ulimit -n ≥ 65535,且 systemd 的 LimitNOFILE 已配(必须验证 /proc/<pid>/limits
  • net.core.somaxconn ≥ Nginx 的 backlog
  • net.ipv4.ip_local_port_range 已扩大(反代场景)
  • tcp_tw_reuse = 1不要配 tcp_tw_recycle
  • 网卡多队列已开,中断分散到多核
  • CPU governor = performance
  • 跨国/移动流量多的话开 BBR

Nginx 进程层

  • worker_processes auto
  • worker_cpu_affinity auto
  • worker_rlimit_nofileworker_connections
  • worker_connections 按实际并发算过(不是拍脑袋设 100 万)
  • listen ... reuseportaccept_mutex off
  • worker_shutdown_timeout 已设

代理层

  • keepalive + proxy_http_version 1.1 + proxy_set_header Connection "" 三件套齐全
  • keepalive_timeout 短于后端的空闲超时
  • 超时值合理(proxy_connect_timeout 1-3s,不是 60s)
  • proxy_next_upstream 不含 non_idempotenthttp_500
  • proxy_next_upstream_tries 已限制
  • proxy_buffers 匹配实际响应大小(看 error.log 有没有 temp file 警告)

静态与压缩

  • sendfile on + tcp_nopush on + tcp_nodelay on
  • gzip_comp_level 5(不是 9)
  • gzip_static on,构建时生成 .gz
  • gzip_types 不含图片/视频/woff2
  • 大文件目录配了 aio threads + directio + limit_rate
  • open_file_cache 已配(静态资源为主时)

TLS

  • ssl_session_cache shared:...不是 builtin,不是不配
  • ssl_session_tickets on,多机 ticket key 已同步
  • ECDSA + RSA 双证书
  • ssl_protocols TLSv1.2 TLSv1.3
  • ssl_stapling on 且配了 resolver
  • 证书链完整,有过期监控

日志与监控

  • access_log ... buffer=64k flush=5s
  • 健康检查/静态资源关了 access_log
  • log_format$request_time$upstream_response_time$request_id
  • escape=json(用 JSON 格式时)
  • logrotate 配了 postrotate kill -USR1
  • stub_status 或 vts 已暴露,接了 Prometheus
  • 核心告警规则已配(5xx 率、P95 延迟、连接丢弃、证书过期)

验证

  • nginx -t 通过
  • 分层压测做过,知道瓶颈在哪
  • 压测后 nstat 确认没有 ListenOverflows
  • ss -tan 确认 TIME_WAIT 数量正常
  • openssl s_client -reconnect 确认会话复用生效

9. 面试题

Q:Nginx 性能调优你会从哪里入手?

先建立基线和明确目标,然后分层压测定位瓶颈:Nginx return 200(测极限)→ 静态文件 → 直压后端 → 通过 Nginx 压后端。对比这四组数据就知道瓶颈在哪一层。绝大多数情况瓶颈在后端而不是 Nginx,判断依据是 $upstream_response_time$request_time 的比例。确认瓶颈在 Nginx 后,按火焰图找热点函数,针对性优化,一次只改一项并验证。

Q:worker_connections 设多大合适?设很大有什么代价?

峰值并发客户端连接 × 2 / worker_processes 算(反向代理时每个请求占客户端侧和上游侧共 2 个连接),再留些余量。代价是每个连接约占 500 字节固定内存(ngx_connection_t + 两个 ngx_event_t),且启动时就一次性预分配——设 100 万意味着启动就吃掉几百 MB。另外必须保证 worker_rlimit_nofile 和系统 ulimit -n 都不小于它。

Q:gzip_comp_level 设 9 好不好?

不好。从 5 到 9 压缩率只提升约 2-3%,但 CPU 开销涨 3 倍。5 是性价比拐点。静态资源应该用 gzip_static on + 构建时用 -9 预压缩,这样既有最高压缩率又零运行时 CPU 开销。

Q:acceptshandled 不相等怎么办?

说明有连接被丢弃了。检查:(1) worker_connections 是否达到上限;(2) worker_rlimit_nofile 和系统 ulimit -n 是否够;(3) nstat -az | grep ListenOverflows 看是否 accept 队列溢出,需要调 net.core.somaxconnlisten ... backlog

Q:TIME_WAIT 很多怎么处理?CLOSE_WAIT 呢?

TIME_WAIT 说明本端主动关闭了大量连接,通常是没开上游 keepalive 导致每个请求都新建连接。修复:配 keepalive + proxy_http_version 1.1 + proxy_set_header Connection "",辅以 net.ipv4.tcp_tw_reuse = 1 和扩大 ip_local_port_range注意不要用 tcp_tw_recycle(4.12 已移除,NAT 环境会丢连接)。

CLOSE_WAIT 堆积是应用层 bug:对端已发 FIN,但本端程序没调用 close()。调内核参数无效,必须去修代码。

Q:改了 /etc/security/limits.conf 但 Nginx 的 fd 限制没生效?

systemd 管理的服务完全不读 limits.conf。必须在 /etc/systemd/system/nginx.service.d/override.conf 里配 LimitNOFILE=655350,然后 systemctl daemon-reload && systemctl restart nginx。改完一定要用 cat /proc/$(pgrep -f "nginx: master")/limits 验证。

Q:为什么 Nginx 的 upstream keepalive 超时要短于后端的?

如果 Nginx 侧超时更长,后端会先关闭空闲连接,而 Nginx 可能恰好在这个瞬间往这条连接上发请求,导致 upstream prematurely closed connection 引发偶发 502。让 Nginx 主动淘汰连接(keepalive_timeout 设 30s,后端设 60s)就能避免。规律:连接池的空闲超时应由持有池的一方先关闭。

Q:P99 延迟有规律的毛刺,怎么排查?

按可能性排查:(1) accept_mutex on 且没开 reuseport → 低并发时有 accept_mutex_delay(默认 500ms)的延迟,改成 accept_mutex off + listen ... reuseport;(2) 日志同步写盘 → 加 buffer=64k flush=5s;(3) 后端 GC 停顿 → 看 $upstream_response_time 是否有同样的毛刺;(4) 磁盘 I/O 抖动 → iostat -x 1await;(5) CPU 节能降频 → 设 governor 为 performance;(6) 网卡中断集中在一个核 → mpstat -P ALL 看是否只有一个核 %soft 高。

Q:BBR 一定比 CUBIC 好吗?

不一定。BBR 在有丢包的长肥管道(跨国、移动网络)上优势明显,吞吐可能翻倍。但在内网低延迟低丢包环境下差别很小,某些场景甚至略差(BBR 会更激进地占用带宽,多流竞争时公平性不如 CUBIC)。判断标准:主要服务同城用户就保持 CUBIC,有跨国或移动端流量再开 BBR。

Q:proxy_buffers 设小了会怎样?怎么发现?

响应体超过缓冲区总大小时,Nginx 会把超出部分写到临时文件(磁盘 I/O),并在 error.log 记录 an upstream response is buffered to a temporary filegrep -c 这条日志就知道频率。如果很多,要么调大 proxy_buffers,要么接受这个开销(大文件下载写临时文件是合理的)。注意这些缓冲区是每个活跃连接的,不能无脑调大。


上一篇:Nginx-11 日志与监控 | 下一篇:Nginx-13 实战案例(Go 服务 + Docker)