一次雪崩:从单点到集群的阵痛
周二下午 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()生成的链接会是内网 IPX-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_hash | Nginx 直接配置 | 简单;但 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
再压三种负载均衡配置:
| 配置 | 平均 QPS | P99 延迟 | 错误率 |
|---|---|---|---|
| 单台后端(无 LB) | 3246 | 310ms | 0% |
| 轮询(无 keepalive) | 5120 | 280ms | 0% |
| 轮询 + keepalive | 7680 | 190ms | 0% |
| least_conn + keepalive | 7950 | 148ms | 0% |
| least_conn + keepalive + gzip | 9360 | 105ms | 0% |
有一个数据值得注意:不加 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.1 和 proxy_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 秒内剔除故障节点。