Nginx反向代理与负载均衡:从踩坑到压测实战
发布日期: 2026/08/02 阅读总量: 0

一次雪崩:从单点到集群的阵痛

周二下午 14:37,监控告警:订单服务 502 持续 10 分钟。登录服务器一看,PHP-FPM 进程全部卡死,CPU 100%,MySQL 连接数打满。单台 4C8G 的服务器扛了两年,那天流量只比平时高了 30%,却直接雪崩。

重启不解决问题。必须上集群。但集群化之后要面对三个现实问题:

  • 两个应用节点如何对外只暴露一个入口
  • 请求如何在节点间分配,保证不倾斜
  • 某一台挂了,如何让用户无感知

我当时的方案是 Nginx(版本 1.24.0)做反向代理+负载均衡,后端两台 PHP 8.3 + Laravel 11 应用。本文记录完整配置过程和压测数据。

方案选型:Nginx vs HAProxy vs LVS

做负载均衡的不只有 Nginx。业界常用三个:LVS(Linux Virtual Server)、HAProxy、Nginx。我的对比结果如下(测试环境:2 台 4C8G 客户端机器,wrk 压测,后端 3 个 Nginx 静态文件服务):

方案工作层级吞吐量(QPS)配置复杂度动态上游支持适用场景
LVS四层15w+高(需 keepalived)超大流量入口
HAProxy 2.8四层/七层10w+支持(socket/api)TCP 流量、高吞吐
Nginx 1.24七层3w-5w(静态)弱(需 reload)HTTP 流量、缓存、动静分离

选 Nginx 的原因:项目是 Laravel 应用,POST/GET 请求多,需要操作 HTTP 层(路径重写、CORS、缓存)。LVS 和 HAProxy 对 HTTP 深度处理能力不如 Nginx。内部服务 QPS 正常情况不到 5000,Nginx 足够。

如果你们的入口流量超过 5w QPS 或者需要四层转发,直接上 LVS+keepalived;如果是纯 TCP 转发,HAProxy 更合适。

环境与架构

架构如下:

客户端
   │
   ▼
Nginx 负载均衡(1 台,10.0.0.10:80/443)
   │          │          │
   ▼          ▼          ▼
Web1       Web2       Web3
10.0.0.11  10.0.0.12  10.0.0.13
(Nginx + PHP-FPM 8.3 + Laravel 11)

后端三台机器搭建:使用 Docker Compose(版本 2.24)定义 Laravel 应用服务,宿主机映射 8081/8082/8083 端口,方便负载均衡直接复用。

# docker-compose.yml(后端节点用,每台机器一份)
version: "3.8"

services:
  app:
    image: laravel:11-php8.3-fpm-nginx
    container_name: laravel_app
    restart: always
    ports:
      - "8081:80"   # 各节点分别映射 8081/8082/8083
    volumes:
      - /var/www/html:/var/www/html
    environment:
      - APP_ENV=production
      - APP_DEBUG=false
      - DB_HOST=10.0.0.20
      - DB_PORT=3306
      - DB_DATABASE=laravel
      - DB_USERNAME=laravel
      - DB_PASSWORD=secret
    networks:
      - backend

networks:
  backend:
    driver: bridge

后端容器内的 Nginx 配置不需要做任何特殊处理,因为负载均衡器的 proxy_pass 会直接转发 HTTP 请求到容器暴露的端口。

反向代理:最基础的配置

单台反向代理是最简单的形态。负载均衡器 Nginx 的配置:

# /etc/nginx/conf.d/reverse_proxy.conf(Nginx 1.24.0)
server {
    listen 80;
    server_name api.example.com;

    access_log /var/log/nginx/api_access.log main;
    error_log  /var/log/nginx/api_error.log warn;

    location / {
        proxy_pass http://10.0.0.11:8081;
        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    30s;
        proxy_send_timeout    30s;
    }
}

三个头信息必须带上:

  • Host:不传的话 Laravel 里 url() 生成的链接会是内网 IP
  • X-Real-IP:PHP 里 $request->ip() 读的是这个
  • X-Forwarded-For:记录客户端真实 IP 链

Laravel 需要在 app/Http/Middleware/TrustProxies.php 里信任负载均衡器,否则 `$request->ip()` 拿到的是 Nginx 的 IP。

// app/Http/Middleware/TrustProxies.php(Laravel 11)
protected $proxies = '10.0.0.10';        // 负载均衡器 IP
protected $headers =
    Request::HEADER_X_FORWARDED_FOR |
    Request::HEADER_X_FORWARDED_HOST |
    Request::HEADER_X_FORWARDED_PORT |
    Request::HEADER_X_FORWARDED_PROTO;

测试验证:

curl -I http://api.example.com/api/health
# HTTP/1.1 200 OK
# Server: nginx/1.24.0
# X-Powered-By: PHP/8.3.2
curl http://api.example.com/api/ip
# 返回客户端真实 IP,而不是 10.0.0.10

从反向代理到负载均衡

单点反向代理不解决高可用问题。把 upstream 加进来:

# /etc/nginx/conf.d/load_balancer.conf
upstream laravel_backend {
    # 默认轮询(round-robin)
    server 10.0.0.11:8081 weight=2;
    server 10.0.0.12:8082 weight=1;
    server 10.0.0.13:8083 weight=1;

    keepalive 32;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://laravel_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;
    }
}

关键的 10 行配置说明:

  • weight=2:10.0.0.11 机器配置高(8C16G),分两倍流量
  • keepalive 32:负载均衡器到后端保持 32 个空闲长连接,避免频繁 TCP 三次握手
  • proxy_http_version 1.1 + Connection "":必须配对,否则 keepalive 不生效

验证负载均衡是否生效:

for i in {1..9}; do
  curl -s http://api.example.com/api/info | grep hostname
done
# 输出:
# hostname: web1
# hostname: web2
# hostname: web3
# hostname: web1
# hostname: web1
# hostname: web3
# hostname: web2
# hostname: web1
# hostname: web2
# 加权轮询下 web1 出现频率约 50%

三种负载均衡算法怎么选

1. 加权轮询(默认)

按 weight 比例分配请求。适合后端机器配置不同、请求处理时间相近的场景。特点:绝对均衡,但请求分布会均匀到每个后端。

2. ip_hash

同一客户端 IP 固定打到同一台后端。适合没有 Redis 做 Session 共享的旧系统。

# /etc/nginx/conf.d/load_balancer_ip_hash.conf
upstream laravel_backend {
    ip_hash;
    server 10.0.0.11:8081;
    server 10.0.0.12:8082;
    server 10.0.0.13:8083;
}

3. least_conn

动态选择当前活跃连接数最少的后端。适合请求处理时间差异巨大的场景(比如有的接口查 MySQL 要 1 秒,有的读 Redis 只要 10 毫秒)。Laravel 比较适合这种。

# /etc/nginx/conf.d/load_balancer_least_conn.conf
upstream laravel_backend {
    least_conn;
    server 10.0.0.11:8081;
    server 10.0.0.12:8082;
    server 10.0.0.13:8083;
}

我的实测结论:Laravel 接口响应时间波动大(50ms~800ms),least_conn 比轮询的 P99 延迟低 12%。如果你的接口响应均匀,轮询就够了,least_conn 的维护成本只有一行,但大脑负担多一分。

健康检查:不配等于白搭

默认情况下,upstream 里的 Nginx 只做「被动健康检查」:转发请求失败(连接被拒、超时)时,把节点标记为不可用。这意味着:只要节点还活着但处理慢,它就不会摘除。

更严重的是:Docker 容器里的 Nginx 进程还活着,但 PHP-FPM 已经卡死,返回 502。这时候负载均衡器依然往这个节点发流量,用户看到一半失败一半成功。

Nginx 开源版没有主动健康检查模块(那是商业版 Nginx Plus 的功能)。我用的方案:max_fails + fail_timeout 手动配置,再加一层简单的 HTTP 检测。

# /etc/nginx/conf.d/load_balancer_health.conf
upstream laravel_backend {
    least_conn;

    server 10.0.0.11:8081 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8082 max_fails=3 fail_timeout=10s;
    server 10.0.0.13:8083 max_fails=3 fail_timeout=10s;

    keepalive 32;
}

server {
    listen 80;
    server_name api.example.com;

    # 健康检查专用入口(可以不带业务逻辑)
    location = /health {
        proxy_pass http://laravel_backend/health;
        access_log off;
    }

    location / {
        proxy_pass http://laravel_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        # ... 其余 header 同前
    }
}

后端 Laravel 需要实现一个轻量健康检查接口:

// routes/web.php(Laravel 11)
Route::get('/health', function () {
    try {
        DB::select('SELECT 1');
        return response()->json(['status' => 'ok'], 200);
    } catch (\Throwable $e) {
        return response()->json(['status' => 'error'], 500);
    }
});
# 测试健康检查接口
for port in 8081 8082 8083; do
  curl -s -o /dev/null -w "%{http_code}\n" http://10.0.0.1$port/health
done
# 全部返回 200

max_fails=3 fail_timeout=10s 的含义:10 秒内失败 3 次,将该节点标记为不可用,接下来的 10 秒不再转发。但它仍然不是主动探测——如果节点恢复,要等下一次请求被转发过去才能发现。这种方案的故障转移耗时最坏情况下 = fail_timeout + 单次请求超时(约 15 秒)。

如果对故障转移时间有严格 SLA,建议编译安装 nginx_upstream_check_module(GitHub 有补丁),配置:

upstream laravel_backend {
    least_conn;
    server 10.0.0.11:8081;
    server 10.0.0.12:8082;
    server 10.0.0.13:8083;
    check interval=3000 rise=2 fall=3 timeout=1000;
}

这个模块能每 3 秒主动请求一次后端健康接口,连续 2 次成功标记为健康,连续 3 次失败标记为不可用,故障转移秒级完成。缺点是每次 Nginx 升级都要重新打补丁编译。

会话保持:不只是 ip_hash

很多人以为负载均衡配了 ip_hash 就完事了。实际场景里有两个坑。

坑一:手机用户切 WiFi 到 4G,IP 变了,会话直接丢。这时候应该用 Cookie 保持。

坑二:Laravel 默认 Session 是文件驱动,要求同一用户的请求始终落在同一台后端。第一次请求落在 web1,第二次落在 web2,Session 就读不到。

三种方案对比:

方案实现优缺点
ip_hashNginx 直接配置简单;但 IP 变化导致会话丢失;负载可能倾斜(一个公司出口 IP 全是同一节点)
Cookie(sticky)Nginx 商业版 / 第三方模块精准;需要额外模块维护
Session 共享后端改 Redis/数据库存储彻底解决问题;需要改应用代码

我的建议:对于 Laravel 项目,直接把 Session 驱动改为 Redis,彻底和节点绑定解耦。最省事。

// config/session.php(Laravel 11)
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => 'session',
'lifetime' => 120,
# config/database.php 中的 redis 配置(Laravel 11)
'redis' => [
    'client' => 'phpredis',
    'default' => [
        'host' => '10.0.0.20',
        'password' => 'secret',
        'port' => 6379,
        'database' => 0,
    ],
],

这样无论请求打到哪台后端,Session 都能从 Redis 读取。这也是现代应用的标准做法。

性能调优:从 3200 到 9500 QPS

压测工具:ab(ApacheBench 2.3)和 wrk(4.2.0)。压测接口:Laravel 的一个简单查库接口(从 MySQL 读一行数据),平均响应 40ms。机器:负载均衡器 4C8G,后端 3 台 4C8G。

先压单台后端(不经过 Nginx 负载均衡):

ab -n 10000 -c 200 -k http://10.0.0.11:8081/api/user/1
# 结果:Requests per second: 3246.18
# Time per request: 61.587ms
# 单台瓶颈在 PHP-FPM 进程池(pm.max_children=30)和 CPU

再压三种负载均衡配置:

配置平均 QPSP99 延迟错误率
单台后端(无 LB)3246310ms0%
轮询(无 keepalive)5120280ms0%
轮询 + keepalive7680190ms0%
least_conn + keepalive7950148ms0%
least_conn + keepalive + gzip9360105ms0%

有一个数据值得注意:不加 keepalive 时,三台后端只跑出了 5120 QPS,跟单台 3246 相比提升不到 2 倍。原因:Nginx 到后端每请求新建 TCP 连接,握手开销 CPU 占用高。加上 keepalive 后,QPS 直接到 7680。

gzip 压缩是另一个大头。Laravel 返回的 JSON 有些字段重复多,gzip 后体积减少 60%。配置如下:

# /etc/nginx/nginx.conf 中 http 块新增
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript application/xml;
gzip_vary on;

压测命令同样适用:

# 第二轮压测(加 gzip 后)
ab -n 10000 -c 200 -k -H "Accept-Encoding: gzip" http://api.example.com/api/user/1
# Requests per second: 9360.42
# Transfer rate: 1262.56 KB/s(压缩前 3150 KB/s)

进程数优化:worker_processes auto 让 Nginx 按 CPU 核数生成进程。压测时如果 worker 数太少,CPU 跑不满,QPS 上不去。

# /etc/nginx/nginx.conf 核心优化项
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 10240;
    use epoll;
}

http {
    keepalive_timeout 65;
    keepalive_requests 1000;
    reset_timedout_connection on;
    client_body_timeout 10s;
    send_timeout 10s;
}

这些参数在 4C8G 的机器上压测效果:worker_connections 10240 让单 worker 能维持更多连接;reset_timedout_connection 及时释放死连接。

避坑指南

下面是实际操作中真实踩过的坑,每一个都付出了生产事故的代价。

1. proxy_pass 末尾斜杠

# 错误写法
location /api/ {
    proxy_pass http://laravel_backend/;
}
# 客户端请求 /api/users?访问后端时变成 /users

# 正确写法
location /api/ {
    proxy_pass http://laravel_backend;
}
# 请求路径原样转发:/api/users

proxy_pass 的 URI 部分会替换掉 location 匹配的部分。不带 URI 的 proxy_pass(只是主机+端口)才会原样转发。这是 Nginx 新手最容易踩的坑。

2. 配了 upstream 忘记 reload

修改 upstream 配置后,执行 nginx -s reload 是平滑重载,不影响现有连接。但有人习惯 systemctl restart nginx,这在负载均衡器上意味着几毫秒的连接断开,生产环境会报 502。

# 检查配置语法
nginx -t
# 平滑重载
nginx -s reload

3. keepalive 只配在 upstream 里不够

还需要 proxy_http_version 1.1proxy_set_header Connection ""。否则 keepalive 不生效,Wireshark 抓包能看到每个请求都在三次握手。

# 验证 keepalive 是否生效
# 方法:观察 TCP 连接数量
ss -ant | grep 8081 | wc -l
# 压测时如果连接数稳定在 30 左右而不是持续增长,说明 keepalive 生效

4. Laravel 的 TrustProxies 不配置,IP 全是负载均衡器

现象:日志里客户端 IP 全部是 10.0.0.10,用户反馈无法登录(因为限流把所有 IP 当成同一个)。配置 TrustProxies 后解决。如果是 Laravel 旧版本(8 以下),还要注意代理头格式不同。

5. 健康检查节点在 fail_timeout 内一直不恢复

默认情况下,节点被标记为不可用期间,不会有一丝流量。如果运维没有及时发现,节点就一直躺尸。解决:把 fail_timeout 调短,比如 5 秒,同时设置告警:

# 每 10 秒检查节点状态,发现摘除立即告警(可使用 cron + curl)
for port in 8081 8082 8083; do
    code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://10.0.0.1$port/health)
    if [ "$code" != "200" ]; then
        echo "ALERT: node on port $port is DOWN"
        # 接入企业微信/钉钉 webhook
    fi
done

6. 压测时负载均衡器 CPU 100%,后端却闲着

问题出在 SSL 卸载。如果配置了 HTTPS,而你是用 Nginx 的 HTTP 模块压测,所有 TLS 握手都压在负载均衡器上。建议:压测时用 HTTP,或者给负载均衡器配更高 CPU。

总结

从单点 3200 QPS 到集群 9360 QPS,这条路的核心不是「多买几台机器」,而是:

  • 反向代理只做转发,别掺和业务逻辑
  • 负载均衡算法根据响应时间分布选,不是越复杂越好
  • 健康检查必须配,但不能指望被动模式
  • keepalive 和 gzip 是性价比最高的两个调优点
  • Session 共享比 ip_hash 更可靠

架构没有银弹。按这个配置跑起来之后,订单服务再也没有雪崩过。哪怕一台后端被运维重启,用户也无感知——因为负载均衡器会在 10 秒内剔除故障节点。