Web 服务

Nginx 负载均衡实战:upstream、限速与健康检查

Nginx 的 proxy_pass 只是入口,真正的流量分配逻辑都写在 upstream 块里。本文讲清三种默认调度策略的取舍与负载分布陷阱、max_fails 与 proxy_next_upstream 如何配合实现故障摘除与重试、keepalive 连接池的实际收益与代价,以及 WebSocket 场景下必须同时设置的三组参数和一个可直接复用的限速配置。

作者:巧匠团队·11 分钟阅读·更新于 2026-10-02

upstream 块:真正的流量分配逻辑在这里

很多人只写 `proxy_pass http://backend;` 就以为在做负载均衡,但 `backend` 必须先在 `http` 块里定义成 `upstream`。只有定义成 upstream 的名字,才能挂载权重、故障参数、连接复用和健康检查这些能力;直接写 IP:port 的话,每个 `proxy_pass` 是一个独立的配置单元,Nginx 甚至不会复用后端连接。

`upstream` 块只能放在 `http {}` 层级,不能放在 `server {}` 或 `location {}` 里,也不能写在 main 层级。这个位置限制是新手最常犯的语法错误之一,表现为 `nginx -t` 直接报 `"upstream" directive is not allowed here`。

upstream 里的 server 可以写主机名而不只是 IP。Nginx 在启动时解析一次域名并缓存,因此配置不会随 DNS 变化自动更新。要支持动态后端发现,要么用 Nginx Plus 的 resolve 能力,要么在社区版本里配合 resolver 与变量形式的 proxy_pass(代价是丢失 upstream 块的大部分能力),要么干脆把 Service VIP 作为后端地址。

另一个常被忽略的事实是:Nginx 默认工作在代理模式,`proxy_pass` 指向的是「逻辑后端」,Nginx 本身不做 TCP 层的负载均衡(那是 L4 的事)。默认 `proxy_http_version 1.0` 且没有 keepalive,一次请求一条连接,这个细节直接决定了 keepalive 配置的必要性。

http {
    upstream backend {
        server 10.0.1.11:8080 weight=3 max_fails=3 fail_timeout=30s;
        server 10.0.1.12:8080 weight=3 max_fails=3 fail_timeout=30s;
        # 备份节点:只有所有主节点都不可用时才启用
        server 10.0.1.20:8080 backup;
        keepalive 64;
    }

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

三种默认调度策略的取舍与分布陷阱

默认策略是轮询(round robin),每个请求依次分配给下一个后端。它对所有请求等权——不管这个请求是 100 字节的健康检查还是 8MB 的文件下载。在长连接场景下,如果客户端复用 keepalive 连接,后续请求会一直打到同一台后端,实际负载严重倾斜。

加权轮询(`weight=N`)让权重高的后端获得成比例的请求数,是最常用也最可预测的选择。要注意 `weight` 只影响分配概率,不影响请求耗时;一个权重 5 但处理速度慢 5 倍的后端,实际负载可能远超权重 1 的健康节点。灰度发布场景下权重还能当流量比例开关用。

`ip_hash` 让同一个客户端 IP 固定落到同一台后端,适合有会话粘性需求(本地 session、内存缓存)的情况。它有两个明显代价:一是 IP 相同的一批用户(比如同一个 NAT 出口的企业网关)会全部压到一台机器上;二是后端扩缩容后哈希分布会重排,大量用户的会话会跳到新节点上造成缓存冷启动。

第三方模块提供了 `least_conn`(最少连接)与 `hash $key`(按自定义键哈希)。`least_conn` 在请求耗时差异很大时比轮询更均衡;`hash` 指令是商业版本提供的,社区版本需要 ngx_http_upstream_hash 模块。无论选哪种策略,都要用压测验证实际分布——很多「看起来均衡」的问题实际来自 keepalive 连接没有关闭或后端长尾耗时。

# 三种策略对比:同一组配置只改一个关键字
upstream by_roundrobin {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;
}

upstream by_weight {
    server 10.0.1.11:8080 weight=5;   # 承接 50% 流量
    server 10.0.1.12:8080 weight=3;
    server 10.0.1.13:8080 weight=2;
}

upstream by_ip {
    ip_hash;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

# 校验配置后再 reload
nginx -t && nginx -s reload

故障摘除与重试:max_fails、fail_timeout、proxy_next_upstream

`max_fails=N fail_timeout=T` 定义了失败判定:在 `T` 秒的窗口内,某后端累计失败超过 `N` 次,Nginx 就在该窗口内不再向它转发新请求(官方称为「被认为不可用」),窗口过后自动恢复。这是纯被动摘除——Nginx 靠真实的转发失败来判断,不需要额外的探针。

前提是 `proxy_next_upstream` 必须配置。这个指令决定了「什么情况下把请求重试到下一个后端」,取值包括 `error`(连接错误、响应中途异常)、`timeout`、`invalid_header`、`http_500`、`http_502`、`http_503`、`http_504`、`http_403`、`http_404`、`non_idempotent`。默认只有 `error timeout`。如果没配,即便 max_fails 设了,配额也会一直打到已故障的节点上——因为不重试就永远只会失败一次、永远达不到失败计数之外的恢复逻辑。

一个必须知道的坑:非幂等请求(POST、PUT、DELETE)的重试会造成重复写入。要把这些状态码放进 `proxy_next_upstream`,必须显式加上 `non_idempotent`,否则 Nginx 不会对它们重试。如果后端不支持接口幂等,建议按 location 把写接口单独拆出来,用不带 `non_idempotent` 的指令,保证重试只发生在只读路径上。

失败计数只统计「连接层面」的失败,不包含业务层面的 5xx 之外的语义错误。另外,Nginx 判断后端健康用的是其配置里的地址,与真实客户端感知可能有偏差——例如后端进程还在但已经无法处理请求,Nginx 仍认为它是健康的。这类「假活」需要主动健康检查来兜底,而开源版本 Nginx 需要借助 Nginx Plus 的 `health_check` 指令,或通过第三方模块、或在 upstream 前放一层支持主动探针的组件。

upstream backend {
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
}

server {
    listen 80;

    # 只读路径:允许对多种错误重试到下一台
    location /api/ {
        proxy_pass http://backend;
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 10s;
    }

    # 写路径:明确不重试,避免重复写入
    location ~ ^/api/(orders|payments)/ {
        proxy_pass http://backend;
        proxy_next_upstream off;
        proxy_connect_timeout 3s;
        proxy_read_timeout 30s;
    }
}

keepalive 连接池:收益、代价与正确姿势

上游 keepalive 让 Nginx 与后端之间复用 TCP 连接,避免每次请求都经历三次握手与慢启动。对高并发短请求的服务,这是能立刻体现在 P99 上的优化。但它必须同时满足三个条件,缺一个都不生效。

第一,`upstream` 块里要有 `keepalive N;` 声明连接池大小。第二,location 里要有 `proxy_http_version 1.1;`。默认的 1.0 只支持「响应结束后关闭连接」,根本无从复用。第三,`proxy_set_header Connection "";`——必须把默认的 `Connection: close` 请求头清空,空字符串表示告诉上游「这条连接可以保持」。

代价集中在长尾请求上。当某个连接被占用去处理一个耗时 30 秒的请求时,该连接的 keepalive 超时(默认 60 秒)内不能服务其它请求,池子里可用连接被这个慢请求占住,整体并发能力下降。做法是让 `keepalive` 时间与后端的 `keepalive_timeout` 协调,并确保应用侧的单请求耗时不会远超这个值。

另一个常见误解是 `keepalive` 的大小设得越大越好。池子里的连接数量应与后端能并行处理的请求数相匹配,超过后端处理能力只会让 Nginx 多堆积连接、增加内存占用并可能触发后端的连接数限制。经验起点是每台后端(`keepalive 64` 乘以 `worker_processes` 的实际并发)不超过后端应用可承受的空闲连接上限。

http {
    upstream backend {
        server 10.0.1.11:8080;
        server 10.0.1.12:8080;
        keepalive 64;          # 每个 worker 的空闲连接数
        keepalive_timeout 60s; # 空闲连接保留时长(upstream 级,1.15.3+)
    }

    server {
        location / {
            proxy_pass http://backend;
            # 以下三行缺一不可,否则 keepalive 不生效
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
        }
    }
}

限速与 WebSocket:两组容易配错的参数

WebSocket 走 Nginx 代理时,握手完成后连接会从 HTTP 升级成长连接。此后默认的 `proxy_read_timeout`(60 秒)会在空闲时把它掐断,客户端表现为「每隔一分钟就重连」。解决办法是把 `proxy_read_timeout` 调到足够长(例如 3600s),或者在 WebSocket 的 location 里设置 `proxy_send_timeout` 与 `proxy_read_timeout` 一起加大。

同时必须正确设置 Upgrade 相关的请求头。`proxy_set_header Upgrade $http_upgrade;` 把客户端的升级请求头透传,`proxy_set_header Connection "upgrade";` 告诉上游这是升级请求。第二个头有两条硬规则:写死 `"upgrade"` 会让所有请求(包括普通 HTTP 请求)都带上升级语义,在同一个 location 混用两种协议时应改用 `map` 指令按 `$http_upgrade` 变量分发。

限速用 `limit_req_zone`,必须定义在 `http` 块内,key 通常是 `$binary_remote_addr`(二进制形式省内存)或对已认证用户用 `$arg_user`。zone 名称不要含 `=` 等符号,size 是共享内存区大小,按预估 key 数量估算。`limit_req` 指令写在 location 里,`burst` 允许短时突发,`nodelay` 表示突发请求立即处理而不排队延迟。

对 WebSocket 场景另有一层限制:`limit_conn` 统计的是**连接数**而非请求数,长连接会一直占用配额。给 WebSocket location 配 `limit_conn` 时要按「同时在线数」而不是「QPS」来设定容量,否则正常的在线用户会被误判为超出连接限制而收到 503。

http {
    # WebSocket / 普通请求分开处理 Connection 头
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    limit_req_zone $binary_remote_addr zone=api_zone:10m rate=20r/s;
    limit_conn_zone $binary_remote_addr zone=conn_zone:10m;

    server {
        listen 80;

        location /ws {
            proxy_pass http://backend;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host $host;
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
            # 长连接按「同时在线数」限制,而不是 QPS
            limit_conn conn_zone 200;
        }

        location /api/ {
            limit_req zone=api_zone burst=40 nodelay;
            proxy_pass http://backend;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
        }
    }
}

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。