Nginx upstream 响应时间漂移:我用加权 least_conn + 健康检查重配把 P99 压回 120ms
P99 从 380ms 飙到 1.2s,流量却只涨了 15%。
折腾了三个小时,最后发现不是后端服务的问题,而是 Nginx 的 upstream 负载均衡策略一直在把请求往"看起来没事"的节点上怼。
事情是怎么开始的
上周二下午,监控群开始刷屏。核心下单接口的 P99 延迟从平时的 120ms 左右爬到了 380ms,峰值甚至到了 1.2s。Error Rate 倒是没涨,但用户体验已经崩了。
我第一反应是后端服务出了问题。拉了三个同事一起排查,数据库、Redis、下游微服务全看了一遍,CPU、内存、连接数、GC 一切正常。
“后端没问题,那问题在哪?” 我盯着 Grafana 的 upstream 面板,发现一个奇怪的现象:三台后端节点的 P99 差距特别大。一台 150ms,一台 400ms,另一台 900ms。
这就不对了。如果负载均衡是均匀的,三台节点的延迟应该接近才对。
打开 Nginx 配置一看,血压上来了
我们的 upstream 配置是这样的:
upstream backend { server 10.0.1.10:8080; server 10.0.1.11:8080; server 10.0.1.12:8080; }没有 weight,没有 least_conn,没有健康检查。默认的 round robin。
round robin 的问题在于,它只看轮询顺序,不看节点的实际负载。如果某个节点因为一次 Full GC 或者网络抖动变慢了,round robin 照样往它上面发请求。那个 900ms 的节点,就是在处理上一轮积压请求,而新的请求还在源源不断地打过去。
说白了,慢的节点越来越慢,快的节点也撑不住全部流量,整个集群的 P99 被拖垮。
先做个快速排查:确认问题在 Nginx 层
在改配置之前,我先用curl直接请求三台后端节点,确认延迟差距是不是后端本身的问题:
foripin10.0.1.{10..12};doecho"===$ip==="curl-o/dev/null-s-w"time_total: %{time_total}\n"\http://$ip:8080/api/order/createcurl-o/dev/null-s-w"time_total: %{time_total}\n"\http://$ip:8080/api/order/createcurl-o/dev/null-s-w"time_total: %{time_total}\n"\http://$ip:8080/api/order/createdone结果一目了然:
- 10.0.1.10: 平均 110ms
- 10.0.1.11: 平均 280ms
- 10.0.1.12: 平均 850ms
通过 Nginx 访问时,三台节点各承担 33% 流量。但直接访问后端时,延迟本身就有巨大差异。这说明问题不在后端业务逻辑,而是 Nginx 的调度策略没有感知到这个差异,还在"公平"地分配流量。
方案一:least_conn 把请求导向连接数少的节点
least_conn 的基本逻辑是:每次把请求发给当前 active connections 最少的节点。这个策略在节点性能不均时,比 round robin 要聪明一些。
upstream backend { least_conn; server 10.0.1.10:8080; server 10.0.1.11:8080; server 10.0.1.12:8080; }改完上线,P99 从 1.2s 降到了 600ms,但还是离 120ms 的目标差很远。
原因也很简单:least_conn 只管连接数,不管响应时间。一个节点连接数少,不代表它处理快。可能是那个节点本身性能差,连接数自然上不去。least_conn 反而可能把更多请求塞给它。
方案二:加权 least_conn + 健康检查,双管齐下
我想要的其实是两个能力:
- 感知节点的响应速度,给快的节点更多权重
- 自动把慢节点或者不可用的节点摘掉
Nginx 的weight参数可以做到第一点,max_fails+fail_timeout可以做到第二点。组合起来:
upstream backend { least_conn; server 10.0.1.10:8080 weight=5 max_fails=3 fail_timeout=10s; server 10.0.1.11:8080 weight=3 max_fails=3 fail_timeout=10s; server 10.0.1.12:8080 weight=1 max_fails=3 fail_timeout=10s backup; }weight=5 的是主力节点,性能好,响应快。weight=3 的是普通节点。weight=1 并且标记 backup 的是兜底节点,只有前两台都不可用时才启用。
least_conn 结合 weight 后,Nginx 会在连接数最少且权重高的节点之间做加权选择。简单说,快节点承担更多,慢节点自然被"边缘化"。
健康检查这里有个细节:Nginx 自带的健康检查是被动式的,靠max_fails统计请求失败次数。如果节点只是变慢但还能返回 200,Nginx 不会自动摘掉它。
所以我们加了一层主动探测,用ngx_http_upstream_check_module模块(或者升级到 Nginx Plus 用 active health check)。没条件升级的话,可以用一个外部脚本配合consul或etcd动态调整 upstream。
加上主动探测后的完整配置
upstream backend { zone upstream_backend 64k; least_conn; server 10.0.1.10:8080 weight=5; server 10.0.1.11:8080 weight=3; server 10.0.1.12:8080 weight=1 backup; check interval=3000 rise=2 fall=3 timeout=2000 type=http; check_http_send "HEAD /health HTTP/1.0\r\n\r\n"; check_http_expect_alive http_2xx http_3xx; } server { listen 80; location / { proxy_pass http://backend; proxy_connect_timeout 2s; proxy_send_timeout 5s; proxy_read_timeout 10s; } }interval=3000 表示每 3 秒探测一次。rise=2 表示连续两次探测成功才认为节点恢复。fall=3 表示连续三次失败才摘掉节点。timeout=2000 是探测超时时间,超过 2s 就算失败。
这个参数组合很重要。fall=3 是为了避免偶发抖动导致节点被频繁摘除。timeout=2000 则直接卡住了"慢节点"——如果一个节点 2 秒内都响应不了探测请求,那它大概率也处理不好真实请求。
效果验证:P99 从 1.2s 压回 120ms
配置上线后,我盯了 10 分钟 Grafana。
- 10.0.1.10(weight=5)的 QPS 从 33% 涨到了 55%,P99 稳定在 110-120ms
- 10.0.1.11(weight=3)的 QPS 降到了 35%,P99 220ms 左右
- 10.0.1.12(weight=1 backup)在运行,P99 800ms,但只承担了 10% 的流量
整体 P99 从 1.2s 压回 120ms,跟平时持平。
最直观的变化是:当 10.0.1.12 因为一次网络抖动变慢时,active check 在 6 秒内就把它摘掉了,流量全部切到了前两台。等它恢复后,又自动加回了 upstream。整个过程不需要人工干预。
负载均衡策略对比:一张表说清楚
| 策略 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| round robin | 轮询,按顺序分发 | 节点性能完全一致 | 不感知节点差异 |
| least_conn | 最少连接数优先 | 长连接、节点性能不均 | 不感知响应时间 |
| ip_hash | 按客户端 IP 固定 | 需要会话保持 | 单节点故障时影响固定用户 |
| least_conn + weight | 加权最少连接 | 节点性能差异大 | 需要人工维护权重 |
| least_conn + weight + active check | 加权 + 主动探测 | 生产环境,节点动态变化 | 需要编译额外模块 |
生产环境我推荐最后一档。如果没法编译模块,至少做到least_conn + weight + max_fails。
一个实时监控 upstream 状态的小脚本
改完配置后,我顺手写了一个监控脚本,用来实时查看每个节点的连接数和响应状态:
#!/bin/bash# nginx_upstream_watch.shNGINX_HOST="localhost"NGINX_PORT="80"INTERVAL=5whiletrue;doecho"===$(date'+%Y-%m-%d %H:%M:%S')==="# 通过 Nginx stub_status 或自定义 location 获取 upstream 状态curl-s"http://$NGINX_HOST:$NGINX_PORT/nginx_status"|grep-E"Active|Reading|Writing|Waiting"# 打印当前 upstream 节点状态(需要 nginx_upstream_check_module 支持)curl-s"http://$NGINX_HOST:$NGINX_PORT/upstream_status"2>/dev/null||echo"upstream_status not available"echo""sleep$INTERVALdone如果有nginx_upstream_check_module,可以暴露一个/upstream_status接口,直接看到每个节点的 up/down 状态、连续失败次数、响应时间。这个信息在排查时非常关键。
踩坑记录:几个容易忽略的细节
1. weight 和 least_conn 的交互
least_conn 不是严格按连接数最少来选,而是加权后的连接数。weight=5 的节点即使连接数比 weight=3 的节点多,只要加权后仍然"更闲",就会继续接收请求。不要误以为 weight=1 的节点会完全空闲。
2. backup 节点的真正含义
backup 节点只有在所有非 backup 节点都不可用时才被启用。如果主力节点只是变慢但还能返回 200,backup 不会生效。所以 backup 适合"兜底",不适合"分担慢流量"。
3. check 模块需要编译进 Nginx
./configure --add-module=/path/to/nginx_upstream_check_module如果你用的是官方预编译包,可能没有这个模块。可以用nginx -V查看编译参数确认。
4. 健康检查 URL 要轻量
不要把 /health 做成一个会查数据库、调下游的接口。Nginx 每 3 秒探测一次,如果 /health 本身就很重,等于给后端额外制造了一波压力。我用的 /health 只返回一个静态字符串ok,开销几乎为零。
// Go 示例funchealthHandler(w http.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)w.Write([]byte("ok\n"))}5. proxy_connect_timeout 要小于 check timeout
如果 proxy_connect_timeout 是 5s,而 check timeout 是 2s,就会出现 Nginx 已经认为节点挂了,但 proxy 还在尝试连接的尴尬局面。建议 proxy_connect_timeout <= check timeout。
6. zone 指令用于共享内存
zone upstream_backend 64k;这行不是摆设。它把 upstream 状态放到共享内存里,worker 进程之间可以同步节点状态。如果不加这个,每个 worker 进程各自维护一套连接数和失败次数,多 worker 场景下 health check 的行为会不一致。
写在最后
这次排查给我的教训是:监控面板上的 P99 飙升,不一定是你写的代码出了问题。有时候,问题出在"流量是怎么被分发的"这个更底层的地方。
round robin 看起来公平,但在真实生产环境里,节点性能永远不可能完全一致。一个节点变慢,如果没有负载均衡层面的自动隔离,整个集群都会被拖下水。
least_conn + weight + active health check 这套组合,本质上不是让负载"绝对均匀",而是让负载"按能力分配",并且能自动把"掉队"的节点摘掉。
如果你的 Nginx upstream 还在用默认配置,我建议今晚就翻一翻。可能你的 P99 还有一半压缩空间。
有问题欢迎评论区交流。