Nginx反向代理+负载均衡:从502到每秒2.8万请求
发布日期: 2026/08/14 阅读总量: 0

事故现场:一台后端挂了,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秒。数据如下:

配置组变更点QPSP99延迟错误率
基线默认配置,worker_processes=1851263ms2.1%
轮询worker_processes=2 + 轮询两个后端1152044ms0.4%
keepaliveupstream keepalive 64 + HTTP/1.11684035ms0.02%
基础调优sendfile + tcp_nopush + worker_rlimit_nofile2153027ms0%
加权轮询weight=2:1,模拟两台后端性能不均衡2238024ms0%
proxy_cache加缓存,命中率83%2869017ms0%

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、健康检查、超时、文件句柄这些细节,决定的是线上故障时你能不能少挨一页报警。