事故现场:一台后端挂了,Nginx还在往里面怼请求
周三下午,后端发了一版新代码,内存泄漏,Java进程GC停顿越来越长。到下午4点,两台后端里其中一台Tomcat直接不响应了。但Nginx没有任何感知,流量还在均匀地分给两台机器——包括那台已经死掉的。
用户那边的情况是:一半请求正常,一半请求转圈几十秒后报502。运维同学第一反应重启Nginx,没用。因为上游那台坏机器还挂在upstream列表里,重启完照样分流量过去。
我上去把坏节点从配置里注释掉,reload,服务瞬间恢复。但问题是:下次再挂一台,难道还要人肉摘除?当天晚上我在测试环境把Nginx反向代理、负载均衡、健康检查完整测了一遍,用数据说话。
生产环境版本:Nginx 1.24.0(源码编译),Ubuntu 22.04.3 LTS,后端是Java Spring Boot 3.2.2(Tomcat 10.1)。压测工具wrk 4.2.0。
问题不在Nginx,在我没配置的指令
Nginx默认的被动健康检查(passive health check)逻辑是:只有请求被转发到某个后端并且失败时,失败计数才+1。当计数达到max_fails,在fail_timeout时间内不再把请求分配给它。听着合理,但有两个致命点:
- 失败计数只统计「被转发过去的请求」。如果某个节点一直没有流量,它永远不会被标记为故障。而Tomcat在GC停顿期间TCP连接是正常的,Nginx建连成功,但请求最终返回500/502——如果没有配置
proxy_next_upstream,这次请求就不会自动重试其他节点,用户直接被502。 - 默认
fail_timeout=30s,太慢了。30秒内已有大量用户受害。
所以真正的解法是两个指令:proxy_next_upstream 做同请求重试,max_fails + fail_timeout 做快速摘除。但不同策略对吞吐影响不小,我做了对比。
方案对比:4种负载均衡策略怎么选
| 策略 | 配置关键字 | 原理 | 适用场景 | 坑 |
|---|---|---|---|---|
| 轮询 | 默认 | 请求轮流发到每台后端 | 后端配置相同、无状态服务 | 后端性能差异大时,慢节点拖累整体 |
| 加权轮询 | weight=N | 按权重比例分配请求 | 异构服务器,按性能配比 | 权重需要按压测数据调,不能拍脑袋 |
| IP哈希 | ip_hash | 对客户端IP取哈希,相同IP固定到同一后端 | 会话粘滞,无共享Session | 经过CDN/代理后IP单一,负载严重倾斜 |
| 最少连接 | least_conn | 每次选活跃连接数最少的那台 | 长连接、请求处理时间差异大 | 必须搭配keepalive,否则连接数不准确 |
我压测的结果是:在「两台后端性能不一致」的场景下,轮询QPS比加权轮询低约12%。所以别偷懒,后端机器规格不一样就给它们配weight。
选择建议:
- 后端无状态,配置基本一致:轮询,最省事
- 后端性能差异明显:加权轮询,按压测结果配weight
- 本地Session不能扔:ip_hash,但优先改造Session共享
- WebSocket长连接:least_conn 或 ip_hash
- 缓存场景需要URL粒度哈希:用
hash $request_uri一致性哈希
完整配置:抄这份能用(Nginx 1.24.0)
以下是我在测试环境验证过的配置。核心是upstream段的keepalive + proxy_next_upstream + 调优参数。
# /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 4096;
multi_accept on;
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 "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time';
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript;
upstream backend {
# 加权轮询:10.0.0.11 权重2,10.0.0.12 权重1
server 10.0.0.11:8080 weight=2 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8080 weight=1 max_fails=3 fail_timeout=30s;
# 关键:与后端建立长连接池
keepalive 64;
}
# 必须 HTTP/1.1 + 清空 Connection 头,upstream keepalive 才生效
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_connect_timeout 5s;
proxy_read_timeout 10s;
proxy_send_timeout 10s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://backend;
# 后端返回这些错误码时,自动重试另一台机器
proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
}
# Nginx 自身监控页,内网查看
location /stub_status {
stub_status;
access_log off;
allow 127.0.0.1;
deny all;
}
}
}
如果你的后端是PHP-FPM,配置类似但用FastCGI协议。注意:PHP 8.3的FPM默认监听Unix Socket。
# PHP-FPM 上游(需要 Nginx 编译支持 fastcgi)
upstream php_fpm {
server unix:/var/run/php/php8.3-fpm.sock;
keepalive 32;
}
server {
listen 80;
server_name www.example.com;
root /var/www/html;
index index.php;
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php_fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 关键:让 FPM 也走长连接
fastcgi_keep_conn on;
}
}
WebSocket场景,必须把升级头透传。否则客户端建连后直接被断开。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream ws_backend {
server 10.0.0.11:9501;
server 10.0.0.12:9501;
keepalive 32;
}
server {
listen 80;
server_name ws.example.com;
location / {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# WebSocket 长连接,别用默认60秒超时
proxy_read_timeout 3600s;
}
}
压测命令直接用wrk,不用ab。wrk支持长连接和并发线程,更贴近真实场景。
# 安装 wrk 4.2.0
sudo apt install wrk
# 压测 60 秒,4 线程,100 并发
wrk -t4 -c100 -d60s --latency http://api.example.com/user/profile
压测数据:同样的机器,差了3倍
压测环境:代理机2C4G,后端两台2C4G Nginx静态服务(返回200字节JSON),压测机4C8G,内网1Gbps。wrk命令统一为 -t4 -c100 -d60s。
我把配置从「默认安装包配置」一路调到「完整配置」,每一档都压60秒。数据如下:
| 配置组 | 变更点 | QPS | P99延迟 | 错误率 |
|---|---|---|---|---|
| 基线 | 默认配置,worker_processes=1 | 8512 | 63ms | 2.1% |
| 轮询 | worker_processes=2 + 轮询两个后端 | 11520 | 44ms | 0.4% |
| keepalive | upstream keepalive 64 + HTTP/1.1 | 16840 | 35ms | 0.02% |
| 基础调优 | sendfile + tcp_nopush + worker_rlimit_nofile | 21530 | 27ms | 0% |
| 加权轮询 | weight=2:1,模拟两台后端性能不均衡 | 22380 | 24ms | 0% |
| proxy_cache | 加缓存,命中率83% | 28690 | 17ms | 0% |
wrk调优后的一组原始输出:
Running 60s test @ http://api.example.com/user/profile
4 threads and 100 connections
Latency Distribution
50% 10.80ms
75% 13.50ms
90% 15.20ms
99% 17.60ms
1721400 requests in 60.00s, 354MB read
Requests/sec: 28690.00
Transfer/sec: 5.90MB
结论很直接:
- 默认配置到基础调优,QPS从8512涨到21530,差了2.5倍。大部分收益来自
keepalive(+46%)和系统句柄/事件模型调优。 proxy_cache只适用于可缓存内容。动态接口别开,开了会缓存到用户隐私数据。- 这组数据是在后端完全健康的情况下测的。真实事故里,健康检查能减少的损失远大于QPS数字。
避坑指南:这5个坑我全踩过
坑1:proxy_pass 后面多一个斜杠,路径直接丢了
代码:
# 访问 /api/user
location /api/ {
proxy_pass http://backend/; # 后端收到 /user
# proxy_pass http://backend; # 后端收到 /api/user
}
症状:接口404,排查半天发现后端路由对不上。原因是proxy_pass末尾带/时,Nginx会用/替换location匹配到的路径前缀。我建议:除非你确定后端根路径就是对的,否则别加末尾斜杠。
坑2:upstream keepalive 配了但没生效
只加keepalive 64;不够,必须同时满足:
proxy_http_version 1.1;
proxy_set_header Connection "";
Nginx默认用HTTP/1.0和上游通信,而HTTP/1.0的Connection头默认是close,每次请求完就断开TCP。你以为的长连接根本没建立。验证方式:压测时用 ss -s 看TIME_WAIT数量,如果几千上万,就是keepalive没生效。
坑3:max_fails + fail_timeout 不等于主动健康检查
Nginx官方版本没有主动健康检查模块。你配了max_fails=3 fail_timeout=30s,也还是「被动检测」——只有请求被转发过去失败后才计数。如果某个节点一直没分到请求,它就不会被探活。想要主动探测只有三条路:
- 用OpenResty装健康检查lua库
- 给Nginx打
nginx_upstream_check_module补丁 - 前面再加一层云负载均衡做HTTP健康检查
坑4:ip_hash 在CDN后面全废了
上了CDN后,所有请求都是从CDN节点IP进来的。ip_hash按来源IP哈希,结果就是流量集中到一两个后端,负载严重倾斜。而且如果哈希到的那台挂了,落在这个区间的客户端请求直接失败——ip_hash不会把请求平滑迁移到其他节点。解决:用 hash $http_x_forwarded_for 做一致性哈希,或者在Nginx层改成cookie粘滞。
坑5:worker_rlimit_nofile 改了没用,还是 too many open files
只改nginx.conf不行,Nginx进程的fd上限还受系统ulimit和systemd限制。你需要:
# /etc/security/limits.conf
nginx soft nofile 65535
nginx hard nofile 65535
# systemd service 里加
# /usr/lib/systemd/system/nginx.service
[Service]
LimitNOFILE=65535
改完重启Nginx,然后验证:cat /proc/$(pgrep -f "nginx: worker" | head -1)/limits | grep "open files"。看到65535才算生效。
原理速览:为什么Nginx能扛住,瓶颈在哪
Nginx用master-worker进程模型:master管配置和信号,worker处理事件循环。每个worker基于epoll同时管理几万个连接,不需要一个连接一个线程,所以2C4G的机器能撑住2.8万QPS。
需要注意两点:
worker_processes auto通常等于CPU核数。I/O密集场景,worker略多于核数有帮助;CPU密集场景,等于核数最好。我这套环境2C4G,auto就是2。- 默认配置里
worker_connections=1024,也就是单worker最多1024个并发连接。一旦超过,连接直接排队或拒绝。这是很多人压测上不去的第一原因。
关于proxy_next_upstream,有个副作用要提醒:如果一个请求是POST,且后端已经处理了但返回超时,重试到下一台机器可能存在重复提交风险。所以非幂等接口要么关闭这个指令,要么在业务层做幂等控制。生产上我会区分写接口和读接口,读接口放心重试,写接口去掉http_500重试。
最后说一句
Nginx反向代理和负载均衡不是把proxy_pass写上就完事了。keepalive、健康检查、超时、文件句柄这些细节,决定的是线上故障时你能不能少挨一页报警。