12804 字
64 分钟
第 4 篇:Nginx 负载均衡与高可用

第 4 篇:Nginx 负载均衡与高可用——从 upstream、故障转移到安全重试边界#

专栏:《Nginx 从入口代理到云原生流量治理》
文章序号:第 4 篇
Nginx 实验版本:Nginx Open Source 1.30.3(稳定版)
当前主线版本:1.31.2
后端实验版本:Spring Boot 4.1.0、Java 21
实验环境:Docker Compose、Linux、curl
更新时间:2026-07-06


本文在专栏中的位置#

前三篇已经完成了三层认知搭建:第一篇确定 Nginx 在后端架构中的位置;第二篇讲清配置模型以及 serverlocation 的匹配;第三篇把单个请求从客户端经过 Nginx 转发到 Spring Boot 的链路完整拆开。

但真实生产系统通常不会只有一个后端实例。只要 Spring Boot 服务开始横向扩容,就会出现新的问题:

  • Nginx 应该把下一个请求发给哪个实例;
  • 不同规格的实例如何承担不同流量;
  • 某个实例宕机后,Nginx 如何暂时避开它;
  • 后端恢复后,什么时候重新接收流量;
  • 长连接会不会破坏“均匀分流”;
  • 请求失败后能否安全切换到另一台服务器;
  • Nginx 自己宕机后,入口是否仍然可用。

这些问题共同组成了负载均衡与高可用的核心。

需要先建立一个重要边界:负载均衡不是简单的平均分配请求,高可用也不等于配置了三个 upstream server。

负载均衡解决的是“在当前可选节点中如何做选择”;故障判断解决的是“哪些节点暂时不应被选择”;请求重试解决的是“一次失败后是否还能换节点”;Nginx 高可用解决的则是“入口本身是否存在单点”。这四件事相互关联,但不是同一个问题。

独立插图 01:从单实例代理演进到 upstream 多实例架构
对应文件:01-upstream-multi-instance-architecture.png

从单实例代理演进到 upstream 多实例架构

下一篇将在入口已经具备多实例能力的基础上,继续建设 HTTPS、证书管理和安全入口。


学习目标#

学完本文后,你应当能够:

  1. 解释 upstream 在 Nginx 请求处理链路中的职责;
  2. 独立配置多实例 Spring Boot 服务的 HTTP 负载均衡;
  3. 区分 Round Robin、Weighted Round Robin、Least Connections、IP Hash、Generic Hash、Consistent Hash 与 Random Two Choices;
  4. 根据请求耗时、连接时长、会话状态和扩缩容频率选择合适算法;
  5. 正确理解 weightbackupdownmax_failsfail_timeout
  6. 解释 Nginx Open Source 的被动故障判断为什么不等于主动健康检查;
  7. 说明单节点 upstream 中 max_failsfail_timeout 的特殊行为;
  8. 区分“失败计数”“暂时不可用”“主动探测恢复”三个概念;
  9. 配置并限制 proxy_next_upstream 的重试条件、次数和总时间;
  10. 解释为什么 POSTPATCH 等非幂等请求不能被无条件重试;
  11. 理解 upstream Keep-Alive、Worker 连接缓存和负载均衡之间的关系;
  12. 判断 WebSocket、SSE、长轮询为何会导致连接数分布与请求数分布不同;
  13. 设计 Nginx 双机、云负载均衡器或 Kubernetes 场景下的入口高可用;
  14. 通过 $upstream_addr$upstream_status 和时间字段定位故障转移过程;
  15. 完成一个包含三后端、权重、Least Connections、Hash、备用节点与故障模拟的 Docker Compose 实验。

一、为什么单个 proxy_pass 不足以支撑横向扩容#

1.1 单实例代理的结构性限制#

第三篇使用过类似配置:

location /api/ {
proxy_pass http://backend:8080;
}

它只能指向一个上游地址。当后端扩容为三个实例时,如果继续把某个固定实例写入 proxy_pass,另外两个实例不会自动获得流量:

Nginx -> backend-a:8080
backend-b:8080 未使用
backend-c:8080 未使用

应用层横向扩容只有在入口层能够发现并选择多个实例时才真正生效。

1.2 upstream 的作用#

ngx_http_upstream_module 用于定义一组可以被 proxy_passfastcgi_passuwsgi_passscgi_passmemcached_passgrpc_pass 引用的服务器。

最小配置如下:

upstream backend_cluster {
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
}
server {
listen 8088;
location /api/ {
proxy_pass http://backend_cluster;
}
}

这里发生了两次选择:

  1. location 决定请求交给哪个 upstream 组;
  2. upstream 的负载均衡算法决定组内选择哪个 server。

因此,proxy_pass http://backend_cluster 不是一个真实主机名,而是对 Nginx upstream 组的引用。

1.3 一次负载均衡请求的内部步骤#

可以把请求过程抽象为:

客户端请求
-> 匹配 server/location
-> 解析 proxy_pass 指向的 upstream 组
-> 排除被标记 down 或暂时不可用的节点
-> 使用当前算法选择候选节点
-> 新建或复用到该节点的连接
-> 发送请求
-> 根据结果更新失败状态
-> 必要时根据 proxy_next_upstream 选择下一节点
-> 返回最终响应

这里“选中节点”并不保证请求一定只访问一次。如果配置允许重试,一条客户端请求可能先后与多个 upstream server 通信。因此日志中 $upstream_addr$upstream_status$upstream_response_time 可能出现逗号分隔的多组值。

独立插图 02:Nginx upstream 选择与请求执行流程
对应文件:02-upstream-selection-pipeline.png

upstream 选择流程

1.4 upstream 不等于服务注册中心#

静态 upstream 主要描述一组上游服务器,它本身并不是完整的服务注册中心。你需要区分:

  • 配置文件中写死 IP 或域名;
  • DNS 名称在 Nginx 启动时解析;
  • 使用 resolve 动态监听域名 IP 变化;
  • NGINX Plus API 动态修改 upstream;
  • Kubernetes Controller 根据 Service/EndpointSlice 生成配置;
  • 云厂商负载均衡器自行发现后端实例。

在 Nginx 1.27.3 之前,upstream server 的 resolve 参数主要属于商业订阅能力;从 1.27.3 开始,官方开源版已支持该参数。使用时 upstream 组必须处于共享内存 zone 中,并配置 resolver

例如:

upstream backend_dynamic {
zone backend_dynamic 64k;
resolver 127.0.0.11 valid=10s ipv6=off;
server backend-a:8080 resolve;
server backend-b:8080 resolve;
}

在 Docker 网络中,127.0.0.11 通常是容器内置 DNS;在生产环境中,应替换为实际可信 DNS 解析器。


二、upstream 配置模型与 server 参数#

2.1 基本结构#

一个较完整的 upstream 可以写成:

upstream backend_cluster {
zone backend_cluster 64k;
least_conn;
server backend-a:8080 weight=3 max_fails=2 fail_timeout=10s;
server backend-b:8080 weight=2 max_fails=2 fail_timeout=10s;
server backend-c:8080 backup;
keepalive 32;
keepalive_requests 1000;
keepalive_time 1h;
keepalive_timeout 60s;
}

需要注意配置顺序:负载均衡方法应在 server 之外、同一个 upstream 上下文中声明。一个 upstream 组只能激活一种主负载均衡方法。

独立插图 03:upstream 配置组成与参数职责
对应文件:03-upstream-configuration-anatomy.png

upstream 配置结构

2.2 server 地址#

server 可以使用:

server 10.0.0.11:8080;
server backend-a.example.internal:8080;
server unix:/run/backend.sock;

选择 IP、域名还是 Unix Socket 取决于部署方式:

地址类型优点风险与边界
固定 IP行为直接、无需 DNS扩缩容和迁移时需要改配置
域名便于服务发现与迁移需要理解解析时机与缓存
Unix Socket同机通信开销低、权限边界清晰只能用于同一主机,容器共享更复杂

2.3 weight#

weight 参与算法决策,默认值为 1。

upstream backend_weighted {
server backend-a:8080 weight=3;
server backend-b:8080 weight=1;
server backend-c:8080 weight=1;
}

在默认加权轮询中,长期观察时大致会呈现:

backend-a : backend-b : backend-c ≈ 3 : 1 : 1

但它不是严格每五个请求固定按照 A、A、A、B、C 排列。Nginx 使用平滑加权轮询,目标是在较长窗口内接近权重比例,同时避免高权重节点连续承受过多瞬时请求。

权重适合表达相对处理能力,例如:

  • 8 核实例权重 4;
  • 4 核实例权重 2;
  • 2 核实例权重 1。

不过 CPU 核数不是唯一依据。JVM 堆、数据库连接池、接口类型、历史延迟与限额都可能影响真实承载能力。

2.4 backup#

upstream backend_failover {
server backend-a:8080;
server backend-b:8080;
server backend-c:8080 backup;
}

backup 节点仅在主要节点都不可用时接收请求。它适合:

  • 容灾机房;
  • 降级服务;
  • 低规格备用实例;
  • 主集群不可用时返回有限功能。

但它不是跨地域容灾的完整方案。网络路由、数据一致性、数据库主从、DNS 与入口本身都需要同时考虑。

backup 不能与 haship_hashrandom 等部分算法组合使用,配置前必须核验当前版本的指令限制。

2.5 down#

server backend-b:8080 down;

down 表示人工把节点标记为永久不可用,Nginx 不会选择它。它常用于:

  • 临时摘除节点维护;
  • Hash 算法中保留原有映射位置;
  • 灰度撤流;
  • 配置预留。

down 不等于健康检查结果,而是配置层明确声明。

2.6 max_failsfail_timeout#

server backend-a:8080 max_fails=2 fail_timeout=10s;

这两个参数同时承担两层含义:

  1. fail_timeout 时间窗口内,达到 max_fails 次不成功通信后,将节点暂时标记为不可用;
  2. 节点被标记后,通常在 fail_timeout 持续时间内避免继续选择它。

默认情况下:

max_fails = 1
fail_timeout = 10s

max_fails=0 会禁用针对该 server 的失败计数。

需要特别注意:如果 upstream 组中只有一个 server,max_failsfail_timeout 会被忽略,该 server 不会因为这些参数而被标记为不可用。 因为没有第二个节点可以切换,直接把唯一节点摘除只会让所有请求立即失败。


三、负载均衡算法:选择的不是“最好”,而是最匹配业务的算法#

3.1 默认 Round Robin#

未显式指定算法时,Nginx 使用加权 Round Robin。

upstream backend_rr {
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
}

特征:

  • 不依赖客户端状态;
  • 配置简单;
  • 请求足够多且耗时接近时,分布较均匀;
  • 对短请求、无状态 API 很合适;
  • 不感知每个请求的真实耗时和节点当前积压。

如果接口耗时差异很大,一个实例可能接到多个长请求,而另一个实例持续处理短请求。虽然请求数量看起来均匀,实际连接数和 CPU 占用并不均匀。

3.2 Weighted Round Robin#

upstream backend_weighted {
server backend-a:8080 weight=5;
server backend-b:8080 weight=3;
server backend-c:8080 weight=2;
}

适合:

  • 混合规格机器;
  • 新旧实例性能差异;
  • 需要先给新实例少量流量;
  • 不需要强会话绑定。

不适合把权重当作精确百分比 SLA。长连接、重试、节点失败、客户端取消以及请求耗时差异都会使短时间比例发生偏移。

独立插图 04:平滑加权轮询的流量分配示意
对应文件:04-weighted-round-robin-distribution.png

加权轮询分布

3.3 Least Connections#

upstream backend_least_conn {
least_conn;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
}

Least Connections 会把请求交给当前活动连接数最少的 server,并考虑权重。如果多个节点相同,则使用加权轮询进行选择。

适合:

  • 请求耗时差异明显;
  • 长轮询;
  • 下载接口;
  • 部分长连接业务;
  • 后端实例能力相近但当前连接负载差异大。

边界:

  • 一个连接不等于相同成本;
  • HTTP/2 多路复用可能让“连接数”不能直接等价为请求数;
  • 一个轻量 SSE 与一个高 CPU 推理请求都只算一个活动连接;
  • 后端内部线程池、队列和数据库连接池并不会被 Nginx 直接感知。

3.4 ip_hash#

upstream backend_ip_hash {
ip_hash;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
}

ip_hash 使用客户端 IP 作为映射键,使同一客户端大概率持续进入同一个节点。

需要注意:

  • IPv4 使用前三个八位组作为键;
  • IPv6 使用完整地址;
  • 节点不可用时会映射到其他节点;
  • 多个用户位于同一个 NAT、企业出口或 CDN 后方时,可能都表现为同一 IP;
  • 如果 Nginx 看到的是上一层代理 IP,而 Real IP 模块未正确恢复客户端地址,会造成严重倾斜;
  • 会话粘性会掩盖应用无状态设计问题。

ip_hash 不应成为“应用不愿意共享 Session”的长期补丁。更稳妥的方案通常是把会话放入 Redis、数据库或使用无状态 Token。

3.5 Generic Hash#

upstream backend_hash {
hash $http_x_user_id consistent;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
}

hash key 允许使用文本、变量或组合值作为映射键,例如:

  • 用户 ID;
  • 租户 ID;
  • 请求 URI;
  • 缓存 Key;
  • API Key 的稳定派生值。

普通 Hash 在节点增删时可能重映射大量键。加上 consistent 后使用 Ketama 一致性哈希,节点变化时只有较少键迁移,更适合:

  • 分片缓存;
  • 局部状态绑定;
  • 需要提高缓存命中率的场景。

必须保证 Key 可信且稳定。若直接使用客户端可以任意伪造的 Header,攻击者可能把流量集中到特定节点。

3.6 Random 与 Random Two Choices#

upstream backend_random_two {
random two least_conn;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
}

random 从候选节点中随机选择;random two least_conn 先随机选两个节点,再从中选择活动连接数更少者。

“两次随机选择”在大规模节点组中常能以较低决策开销获得接近全局 Least Connections 的效果。它更适合节点数量较多的集群;只有两三个节点时,相比直接 Least Connections 的优势不明显。

3.7 Least Time 的版本边界#

least_time 同时考虑平均响应时间和活动连接数,可以基于响应头时间或完整响应时间选择节点。

upstream backend_fastest {
least_time header;
server backend-a:8080;
server backend-b:8080;
}

但必须明确版本:

  • 在 1.31.0 之前,least_time 属于商业订阅能力;
  • 从 1.31.0 起,官方文档标明该指令不再仅限商业版;
  • 本文实验固定使用稳定版 1.30.3,因此不在实验配置中使用 least_time
  • 使用主线 1.31.x 前,应评估组织对主线版本的升级与稳定性策略。

3.8 算法选择矩阵#

场景首选思路主要风险
无状态、短请求、实例同规格Round Robin不感知请求耗时
实例规格不同Weighted Round Robin权重不等于精确百分比
请求耗时差异明显Least Connections连接数不等于真实成本
临时会话绑定ip_hashNAT、代理 IP、流量倾斜
按用户/租户稳定映射Generic HashKey 伪造与热点租户
缓存节点增删频繁Consistent Hash节点容量差异与热点 Key
大规模节点组Random Two Choices小集群收益有限
希望感知历史延迟Least Time版本和能力边界

独立插图 05:主要负载均衡算法的选择逻辑
对应文件:05-load-balancing-algorithm-selection.png

负载均衡算法选择


四、被动故障判断:Nginx 如何从真实请求中学习节点状态#

4.1 什么是被动检查#

Nginx Open Source 原生提供的是 in-band / passive health checks:只有真实客户端请求访问节点并发生通信失败时,Nginx 才能积累失败信息。

它不会在没有业务流量时定时请求 /health

典型过程:

节点正常
-> 一次真实请求连接失败或超时
-> 失败计数 +1
-> 在 fail_timeout 窗口内达到 max_fails
-> 节点暂时不可用
-> fail_timeout 到期
-> 使用新的真实请求试探
-> 成功则恢复,失败则继续不可用

独立插图 06:被动健康判断状态机
对应文件:06-passive-health-check-state-machine.png

被动健康检查状态机

4.2 哪些失败会计入#

失败是否计入 server 的不成功通信,与 proxy_next_upstream 的定义相关:

  • error
  • timeout
  • invalid_header
  • 配置中显式指定的部分 HTTP 5xx/429;
  • 连接被拒绝、无法建立连接、读取响应头失败等。

业务返回一个合法的 HTTP 500,并不必然自动把节点判为不可用。只有当相关状态码被纳入 proxy_next_upstream 条件时,才会作为不成功尝试参与判断。

这避免了把“应用返回业务错误”与“实例通信能力失效”完全混为一谈。

4.3 被动检查的优点#

  • 无额外探测流量;
  • 基于真实业务路径;
  • Nginx Open Source 原生支持;
  • 配置简单;
  • 对连接拒绝、网络超时等故障有效。

4.4 被动检查的局限#

  • 没有请求就无法发现故障;
  • 第一个或前几个真实用户会承担探测成本;
  • 只能看到被访问的接口;
  • 可能把下游数据库故障与应用实例故障混在一起;
  • 恢复时需要真实流量试探;
  • 如果阈值过低,瞬时抖动可能导致频繁摘除;
  • 如果阈值过高,故障节点会持续伤害请求。

4.5 max_failsfail_timeout 调优#

例如:

server backend-a:8080 max_fails=3 fail_timeout=30s;

表示在 30 秒窗口内发生 3 次符合条件的失败后,将节点视为不可用,并通常避开 30 秒。

调优需要考虑:

条件倾向设置
网络偶发抖动适当增加 max_fails
故障必须快速摘除缩短窗口、降低阈值
后端启动恢复慢延长恢复观察,或使用主动检查/编排就绪探针
请求量极低被动检查发现慢,不应单独依赖
请求量极高阈值太低可能导致节点抖动

不要复制固定值。一个每秒 10 万请求的系统和每分钟 10 个请求的系统,max_fails=3 fail_timeout=30s 含义完全不同。

4.6 单节点 upstream 的特殊规则#

upstream only_one {
server backend-a:8080 max_fails=1 fail_timeout=30s;
}

因为只有一个节点,Nginx 会忽略 max_failsfail_timeout,该 server 不会被标记为不可用。请求仍会继续尝试它并向客户端返回失败。

因此,不要通过“写成 upstream”误以为单实例已经具备故障转移能力。


五、主动健康检查:开源版、商业版与外部编排的边界#

5.1 主动检查是什么#

主动检查由负载均衡器定时向每个节点发送探测请求,无需等待真实用户触发。例如:

每 5 秒请求 /actuator/health/readiness
连续失败 3 次 -> 摘除
连续成功 2 次 -> 恢复

它能提前发现故障,也能在恢复后先验证再放量。

5.2 Nginx Open Source 与 NGINX Plus#

截至本文版本:

能力Nginx Open SourceNGINX Plus
被动故障判断支持支持
max_fails / fail_timeout支持支持
主动 HTTP 健康检查 health_check不原生提供支持
自定义状态码/Header/Body 匹配不原生提供支持
slow_start不提供支持
实时 Dashboard不原生提供支持
API 动态修改 upstream不提供完整商业 API支持

商业版示例:

upstream backend {
zone backend 64k;
server backend-a:8080;
server backend-b:8080;
}
server {
location / {
proxy_pass http://backend;
health_check uri=/actuator/health/readiness interval=10 fails=3 passes=2;
}
}

这段配置不能被当作 Nginx Open Source 通用配置。

独立插图 07:被动检查、主动检查与编排就绪检查的职责边界
对应文件:07-health-check-capability-boundary.png

健康检查能力边界

5.3 第三方健康检查模块#

开源社区存在第三方主动健康检查模块,但使用前必须评估:

  • 是否支持当前 Nginx 版本;
  • 是否需要源码编译或打补丁;
  • 安全更新是否及时;
  • 与动态模块 ABI 是否兼容;
  • 团队是否能长期维护;
  • 升级 Nginx 是否会被模块阻塞。

不能因为“GitHub 上有模块”就把它当作官方长期支持能力。

5.4 Kubernetes 场景#

在 Kubernetes 中,常见链路是:

readinessProbe
-> Pod 未就绪时从 Service EndpointSlice 中移除
-> Ingress/Gateway Controller 不再转发到该 Pod

这不等同于 Nginx Open Source 自己主动执行 health_check。健康状态由 Kubernetes 控制面和 Controller 协作管理。

需要同时理解:

  • Liveness:进程是否应被重启;
  • Readiness:实例是否应接收流量;
  • Startup:慢启动期间是否暂缓其他探针;
  • Nginx 被动检查:真实请求通信是否失败。

四者可以共存,解决不同层次的问题。

5.5 健康接口设计#

健康检查接口应避免两个极端:

太浅#

进程能返回 200 就算健康

应用线程池耗尽、数据库不可用时仍然返回健康。

太深#

每次探针都同步访问数据库、Redis、MQ、第三方支付和多个下游,会制造额外负载,并因一个非关键依赖抖动把所有实例摘除。

更合理的设计:

  • Liveness 只判断进程基本存活;
  • Readiness 判断处理核心请求所必需的资源;
  • 非关键依赖进入降级状态而非直接判死;
  • 探针超时必须短;
  • 返回内容不泄露敏感配置;
  • 恢复需要连续成功,避免抖动。

六、请求重试:故障转移最危险的边界#

6.1 proxy_next_upstream 做了什么#

proxy_next_upstream 指定在什么情况下把请求交给下一个 upstream server。

默认值:

proxy_next_upstream error timeout;

常见生产配置:

proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 3s;

三个指令分别限制:

  • 哪些失败允许换节点;
  • 一共最多尝试多少次;
  • 所有尝试累计最多持续多久。

如果不限制重试次数和总时间,一次请求可能在多个故障节点之间消耗过长时间,放大尾延迟。

6.2 只有响应尚未发给客户端时才能切换#

一旦 Nginx 已经向客户端发送响应头或部分响应体,中途 upstream 出错也无法“撤回响应并换节点重来”。

因此:

  • 建立连接失败可以换节点;
  • 读取响应头前超时可能换节点;
  • 已经开始流式响应后断开,通常只能终止连接;
  • WebSocket Upgrade 成功后不能透明迁移到另一节点;
  • SSE 已发送部分事件后重试会造成语义重复或断流。

6.3 幂等请求与非幂等请求#

幂等表示同一个操作执行一次或多次,最终业务结果一致。

常见近似分类:

方法通常语义是否天然安全重试
GET查询通常是,但要防止错误设计成有副作用
HEAD查询元信息通常是
PUT用完整资源替换设计正确时幂等
DELETE删除资源设计正确时幂等
POST创建、下单、支付通常非幂等
PATCH局部变更通常不能假设幂等

Nginx 默认不会在请求已经发送到 upstream 后,继续为 POSTLOCKPATCH 等非幂等方法切换节点。只有显式加入 non_idempotent 才允许这样做:

proxy_next_upstream error timeout non_idempotent;

生产中通常不应随意启用。

独立插图 08:请求重试与幂等性判断流程
对应文件:08-retry-idempotency-boundary.png

重试与幂等性边界

6.4 “客户端没收到响应”不代表后端没执行#

假设下单流程:

1. Nginx 把 POST /orders 发给 backend-a
2. backend-a 已写入数据库
3. backend-a 返回响应前连接断开
4. Nginx 误以为失败并把请求发给 backend-b
5. backend-b 再次创建订单

客户端只看到一次请求,却生成两个订单。

因此,代理层重试不能替代业务幂等。高风险写操作应具备:

  • Idempotency-Key;
  • 业务唯一键;
  • 数据库唯一约束;
  • 请求状态机;
  • 去重记录;
  • 可查询的最终结果;
  • 明确的超时后补偿机制。

6.5 推荐的分层策略#

# 查询接口:允许有限重试
location /api/query/ {
proxy_pass http://backend_cluster;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 3s;
}
# 写接口:禁止代理层自动换节点
location /api/orders/ {
proxy_pass http://backend_cluster;
proxy_next_upstream off;
}

是否关闭写请求重试要结合业务协议,但核心原则是:先证明幂等,再允许重试,而不是先重试再期待业务不出事。


七、连接复用与负载均衡:请求均衡不等于连接均衡#

7.1 upstream Keep-Alive 的当前默认行为#

从 Nginx 1.29.7 起,upstream Keep-Alive 默认启用,每个 Worker 默认缓存 32 条空闲连接,并使用 local,即默认不在不同 location 之间共享同地址连接缓存:

keepalive 32 local;

本文稳定版 1.30.3 已包含该行为。

为了让配置意图更明确,实验仍会显式写出:

upstream backend_cluster {
server backend-a:8080;
server backend-b:8080;
keepalive 16;
}

同时为兼容旧版本和提高可读性,在代理位置显式配置:

proxy_http_version 1.1;
proxy_set_header Connection "";

7.2 keepalive 32 不是最大连接数#

该值表示:每个 Worker 为该 upstream 保留的最大空闲连接数

它不是:

  • upstream 总连接数上限;
  • Nginx 全局连接池大小;
  • 每个 server 的最大并发;
  • 后端连接保护阈值。

当请求量上升时,Worker 可以创建超过 32 条活动连接;请求结束后,最多保留 32 条空闲连接,其余关闭。

如果有 8 个 Worker,每个 Worker 最多缓存 32 条空闲连接,理论上可能保留约 256 条空闲连接,而且它们还会分散到多个后端节点。

7.3 连接复用如何影响分流#

Round Robin 的决策通常发生在“需要把新请求交给某个 upstream peer”时。连接复用不会自动把所有请求固定在某个节点,但以下场景会让实际分布偏离简单请求计数:

  • WebSocket:一个连接可能占用数小时;
  • SSE:一个请求长时间不结束;
  • 大文件下载:活动连接长期存在;
  • HTTP/2 upstream:一个连接可能承载多个并发流;
  • 慢节点:连接更久,Least Connections 会减少新请求;
  • 重试:一个客户端请求可能计入多个节点尝试。

独立插图 09:Worker 级 upstream 连接缓存与多后端连接关系
对应文件:09-upstream-keepalive-per-worker.png

upstream keepalive 与 worker 连接缓存

7.4 max_conns 与后端保护#

可以在 upstream server 上配置最大活动连接数:

server backend-a:8080 max_conns=200;

但官方文档提醒:当启用多个 Worker、空闲 Keep-Alive 和共享内存时,活动与空闲连接总量可能超过 max_conns。它不应被理解成绝对的后端 TCP 连接硬上限。

后端容量保护还需要:

  • Spring Boot/Tomcat 最大线程或连接数;
  • 数据库连接池;
  • Nginx 限流与连接限制;
  • 应用队列;
  • 超时;
  • 熔断和降级;
  • 系统级文件描述符与端口范围。

7.5 长连接场景的选择#

场景关注点建议
普通短 API请求分布Round Robin/权重
慢查询活动连接差异Least Connections
WebSocket长连接数量、重连风暴Least Connections + 应用级会话恢复
SSE长时间占用、断线重连Least Connections,独立 upstream
下载带宽与连接时长独立节点池或 Least Connections
推理任务每请求成本差异极大应用调度、队列和指标驱动,不能只依赖 Nginx

八、Nginx 自身高可用:后端三实例不代表入口无单点#

8.1 最常见的误区#

Client -> 单个 Nginx -> 三个 Spring Boot

后端虽然有三个实例,但 Nginx 宕机、主机断电或入口 IP 不可达时,所有流量仍然中断。

高可用至少要覆盖:

  • Nginx 进程;
  • Nginx 所在主机;
  • 入口 IP;
  • 上游网络;
  • 配置同步;
  • TLS 证书;
  • 日志与监控;
  • 发布回滚。

8.2 双 Nginx + Keepalived#

典型 Active-Passive:

VIP
-> Nginx A(MASTER)
-> Nginx B(BACKUP)

Keepalived 基于 VRRP 管理虚拟 IP。当主节点失效时,备用节点接管 VIP。

优点:

  • 架构直观;
  • 适合自建机房或同二层网络;
  • 不依赖云负载均衡器。

风险:

  • VRRP 和网络环境有要求;
  • 健康脚本设计错误会频繁漂移;
  • 配置和证书必须同步;
  • 可能出现脑裂;
  • 备用节点长期空闲;
  • 跨可用区能力有限。

8.3 云负载均衡器 + 多 Nginx#

Client
-> Cloud Load Balancer
-> Nginx A
-> Nginx B
-> Backend Cluster

云 LB 提供稳定入口、健康检查和跨可用区能力,多个 Nginx 实例同时工作。

优点:

  • Active-Active;
  • 云平台负责入口 IP 和基础故障转移;
  • 扩容更容易;
  • 可以跨可用区。

成本与边界:

  • 增加一层费用和网络跳数;
  • 真实 IP 传递需要 PROXY Protocol 或可信 Header;
  • TLS 在云 LB 还是 Nginx 终止需要统一设计;
  • 云 LB 和 Nginx 可能出现重复健康检查、重试或会话保持。

8.4 DNS 轮询#

把域名解析到两个 Nginx IP 不等于完善高可用:

  • 客户端和递归 DNS 有缓存;
  • 故障 IP 可能仍在缓存中;
  • TTL 不是强制实时失效;
  • 不同客户端的重试行为不同;
  • DNS 不直接理解应用层健康状态。

DNS 可以是全局流量调度的一部分,但不应被当作毫秒级故障切换机制。

8.5 Kubernetes Deployment#

在 Kubernetes 中,Nginx 可作为 Deployment 多副本运行,通过 Service 或云 LB 暴露。控制器可以在 Pod 异常时重建实例,readinessProbe 控制流量接入。

但仍要关注:

  • 配置更新是否导致 reload 风暴;
  • Pod 是否跨节点和可用区;
  • PodDisruptionBudget;
  • Service 和外部 LB 的健康检查;
  • 配置与证书挂载;
  • 滚动升级期间连接排空;
  • Ingress/Gateway Controller 是否已经承担同类职责。

独立插图 10:Nginx 入口高可用的三种典型形态
对应文件:10-nginx-high-availability-patterns.png

Nginx 高可用架构模式

8.6 配置一致性与状态问题#

多 Nginx 节点必须同步:

  • upstream 列表;
  • TLS 证书;
  • 路由规则;
  • 安全 Header;
  • 限流 Key 设计;
  • 日志格式;
  • 自定义错误页。

但是某些状态默认是节点本地的:

  • proxy_cache 磁盘缓存;
  • 部分限流共享内存;
  • upstream 失败状态;
  • Worker Keep-Alive 缓存;
  • 本地访问日志。

所以 Active-Active Nginx 并不意味着这些状态天然全局一致。


九、最小可运行实验#

9.1 实验目标#

本实验启动:

  • 一个 Nginx 1.30.3;
  • 三个相同代码、不同 INSTANCE_ID 的 Spring Boot 实例;
  • 多个 upstream 组;
  • Round Robin、权重、Least Connections、IP Hash、Consistent Hash;
  • 主节点 + Backup;
  • 有限 GET 重试;
  • 禁止订单 POST 自动重试;
  • 结构化 upstream 日志。

9.2 目录结构#

nginx-load-balancing-lab/
├── docker-compose.yml
├── backend/
│ ├── Dockerfile
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/example/lab/
│ │ ├── LabApplication.java
│ │ └── InstanceController.java
│ └── resources/application.yml
├── nginx/
│ ├── nginx.conf
│ └── conf.d/load-balancing.conf
└── scripts/
├── test-distribution.sh
└── test-failover.sh

9.3 docker-compose.yml#

services:
nginx:
image: nginx:1.30.3-alpine
container_name: nginx-lb
ports:
- "8088:8088"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- backend-a
- backend-b
- backend-c
networks:
- nginx-lab
backend-a:
build: ./backend
container_name: backend-a
environment:
INSTANCE_ID: backend-a
INSTANCE_WEIGHT: "3"
networks:
- nginx-lab
backend-b:
build: ./backend
container_name: backend-b
environment:
INSTANCE_ID: backend-b
INSTANCE_WEIGHT: "1"
networks:
- nginx-lab
backend-c:
build: ./backend
container_name: backend-c
environment:
INSTANCE_ID: backend-c
INSTANCE_WEIGHT: "1"
networks:
- nginx-lab
networks:
nginx-lab:
driver: bridge

这里不对外暴露后端端口,客户端只能通过 Nginx 访问。这样更接近“统一入口”的架构边界。

9.4 后端 pom.xml#

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>nginx-load-balancing-lab</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

9.5 后端 Dockerfile#

FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /workspace/target/nginx-load-balancing-lab-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

9.6 application.yml#

server:
port: 8080
shutdown: graceful
spring:
application:
name: nginx-load-balancing-lab
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true

9.7 启动类#

package com.example.lab;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LabApplication {
public static void main(String[] args) {
SpringApplication.run(LabApplication.class, args);
}
}

9.8 InstanceController#

package com.example.lab;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api")
public class InstanceController {
@Value("${INSTANCE_ID:unknown}")
private String instanceId;
@GetMapping("/instance")
public Map<String, Object> instance(HttpServletRequest request) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", instanceId);
result.put("timestamp", Instant.now().toString());
result.put("remoteAddr", request.getRemoteAddr());
result.put("requestId", request.getHeader("X-Request-ID"));
return result;
}
@GetMapping("/slow")
public Map<String, Object> slow(@RequestParam(defaultValue = "1000") long ms)
throws InterruptedException {
long safeMs = Math.min(Math.max(ms, 0), 15_000);
Thread.sleep(safeMs);
return Map.of(
"instanceId", instanceId,
"sleptMs", safeMs,
"timestamp", Instant.now().toString()
);
}
@GetMapping("/status/{code}")
public ResponseEntity<Map<String, Object>> status(@PathVariable int code) {
return ResponseEntity.status(code).body(Map.of(
"instanceId", instanceId,
"status", code
));
}
@PostMapping("/orders")
public ResponseEntity<Map<String, Object>> createOrder(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
return ResponseEntity.ok(Map.of(
"orderId", UUID.randomUUID().toString(),
"instanceId", instanceId,
"idempotencyKey", idempotencyKey == null ? "missing" : idempotencyKey
));
}
}

实验接口不会真正存储订单,所以它只用于展示“响应来自哪个实例”,不能被当作完整幂等实现。

9.9 nginx.conf#

user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format upstream_json escape=json
'{'
'"time":"$time_iso8601",'
'"request_id":"$request_id",'
'"request":"$request",'
'"status":$status,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time"'
'}';
access_log /var/log/nginx/access.log upstream_json;
error_log /var/log/nginx/error.log notice;
include /etc/nginx/conf.d/*.conf;
}

9.10 load-balancing.conf#

upstream backend_rr {
zone backend_rr 64k;
resolver 127.0.0.11 valid=10s ipv6=off;
server backend-a:8080 resolve max_fails=2 fail_timeout=10s;
server backend-b:8080 resolve max_fails=2 fail_timeout=10s;
server backend-c:8080 resolve max_fails=2 fail_timeout=10s;
keepalive 16;
}
upstream backend_weighted {
zone backend_weighted 64k;
server backend-a:8080 weight=3;
server backend-b:8080 weight=1;
server backend-c:8080 weight=1;
keepalive 16;
}
upstream backend_least_conn {
zone backend_least_conn 64k;
least_conn;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
keepalive 16;
}
upstream backend_ip_hash {
zone backend_ip_hash 64k;
ip_hash;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
keepalive 16;
}
upstream backend_consistent_hash {
zone backend_consistent_hash 64k;
hash $arg_user consistent;
server backend-a:8080;
server backend-b:8080;
server backend-c:8080;
keepalive 16;
}
upstream backend_failover {
zone backend_failover 64k;
server backend-a:8080 max_fails=1 fail_timeout=10s;
server backend-b:8080 max_fails=1 fail_timeout=10s;
server backend-c:8080 backup;
keepalive 16;
}
server {
listen 8088;
server_name _;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location = /healthz {
access_log off;
return 200 "nginx-ok\n";
}
location /lb/rr/ {
proxy_pass http://backend_rr/api/;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 3s;
}
location /lb/weighted/ {
proxy_pass http://backend_weighted/api/;
}
location /lb/least-conn/ {
proxy_pass http://backend_least_conn/api/;
}
location /lb/ip-hash/ {
proxy_pass http://backend_ip_hash/api/;
}
location /lb/hash/ {
proxy_pass http://backend_consistent_hash/api/;
}
location /lb/failover/ {
proxy_pass http://backend_failover/api/;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_next_upstream_timeout 5s;
}
location = /orders {
proxy_pass http://backend_rr/api/orders;
proxy_next_upstream off;
}
}

为什么不同 location 使用不同 upstream 组#

同一个 upstream 组只能采用一种负载均衡算法。为了在一个实验中并行验证多个算法,需要建立多个组。

为什么显式配置 zone#

共享内存让 Worker 共享 upstream 运行状态,并且是 resolve 动态解析 server 的前提。

为什么订单接口关闭重试#

实验无法证明创建操作是幂等的,因此禁止 Nginx 自动切换节点。

9.11 启动与语法检查#

Terminal window
docker compose up -d --build
docker compose exec nginx nginx -t
docker compose ps

预期 nginx -t 输出:

syntax is ok
test is successful

9.12 验证 Round Robin#

Terminal window
for i in $(seq 1 9); do
curl -s http://localhost:8088/lb/rr/instance
echo
done

在节点健康、请求耗时相近的情况下,三个实例应大致轮流出现。

9.13 验证权重#

Terminal window
for i in $(seq 1 50); do
curl -s http://localhost:8088/lb/weighted/instance \
| grep -o 'backend-[abc]'
done | sort | uniq -c

结果应大致接近 3:1:1,但不要要求恰好等于 30、10、10。

9.14 验证 Consistent Hash#

Terminal window
for i in $(seq 1 5); do
curl -s 'http://localhost:8088/lb/hash/instance?user=user-1001'
echo
done

同一个 user 应稳定进入同一实例。换成其他用户可能映射到其他实例:

Terminal window
curl -s 'http://localhost:8088/lb/hash/instance?user=user-2002'

9.15 验证 Least Connections#

先制造一个较慢请求:

Terminal window
curl -s 'http://localhost:8088/lb/least-conn/slow?ms=10000' &

然后快速发起多个普通请求:

Terminal window
for i in $(seq 1 12); do
curl -s http://localhost:8088/lb/least-conn/instance
echo
done

观察活动连接较少的节点是否获得更多新请求。由于节点只有三个、请求执行非常快,结果不会像数学模拟一样完全固定。

9.16 验证故障转移#

停止主要节点:

Terminal window
docker compose stop backend-a backend-b

连续请求:

Terminal window
for i in $(seq 1 6); do
curl -sS http://localhost:8088/lb/failover/instance
echo
done

Nginx 在确认主要节点不可用后,应使用 backend-c 备用节点。

恢复:

Terminal window
docker compose start backend-a backend-b

由于本文使用被动检查,恢复后的节点会在后续真实请求中重新被尝试。

9.17 验证写请求不自动重试#

Terminal window
curl -sS -X POST http://localhost:8088/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: demo-order-001' \
-d '{}'

Nginx 不会因为 proxy_next_upstream 自动把这次写请求切换到其他节点。

9.18 查看 upstream 重试日志#

Terminal window
docker compose logs nginx --tail=100

重点字段:

upstream_addr
upstream_status
upstream_connect_time
upstream_header_time
upstream_response_time
request_time

如果一次请求先访问故障节点,再切到健康节点,可能出现:

{
"upstream_addr":"172.20.0.3:8080, 172.20.0.4:8080",
"upstream_status":"502, 200",
"upstream_response_time":"0.001, 0.015"
}

这说明客户端只发起一次请求,但 Nginx 尝试了两个 upstream。

9.19 停止与清理#

Terminal window
docker compose down -v

十、核心指令逐项解释#

10.1 upstream#

项目说明
上下文http
作用定义可以被代理模块引用的服务器组
默认算法加权 Round Robin
常见关联serverzonekeepalive、负载算法
常见错误把 upstream 名称当作 DNS 名称;在错误上下文声明
验证$upstream_addr、重复请求分布

10.2 upstream server#

参数作用关键边界
weight相对权重非精确百分比
max_fails失败阈值单节点组会被忽略
fail_timeout失败窗口与暂时不可用时间不是主动探测周期
backup主节点都不可用时接流量部分算法不可组合
down人工永久摘除配置变更后生效
max_conns限制活动连接不是绝对总 TCP 连接上限
resolve动态解析域名变化需要 zoneresolver

10.3 zone#

zone backend 64k;

用于在 Worker 之间共享 upstream 配置和运行状态。它不是响应缓存,也不是数据库。大小需要能容纳服务器组信息;过小会导致配置加载失败。

10.4 least_conn#

选择活动连接数最少的节点并考虑权重。适合连接时长差异较大的业务,但不能感知后端 CPU、JVM GC、线程池或数据库等待。

10.5 hash ... consistent#

根据 Key 稳定映射节点。一致性哈希减少节点增删时的重映射,但不自动解决热点 Key。

10.6 keepalive#

缓存每个 Worker 到 upstream 的空闲连接。当前版本默认启用,但显式配置有利于表达容量意图和跨版本可读性。

10.7 proxy_next_upstream#

定义允许切换节点的错误类型。不要把所有 4xx/5xx 都写进去;404 通常是业务资源不存在,不应该换节点重试。

10.8 proxy_next_upstream_tries#

限制总尝试次数。值包含第一次尝试,不是“额外重试次数”。例如 2 表示最多尝试两个 upstream。

10.9 proxy_next_upstream_timeout#

限制全部尝试累计时间。它与单次 proxy_connect_timeoutproxy_read_timeout 同时生效,实际超时取决于哪个限制先到达。


十一、从开发配置升级到生产配置#

11.1 第一阶段:能够分流#

upstream backend {
server backend-a:8080;
server backend-b:8080;
}
location /api/ {
proxy_pass http://backend;
}

只适合验证基本路径。

11.2 第二阶段:加入连接复用和日志#

upstream backend {
server backend-a:8080;
server backend-b:8080;
keepalive 32;
}

日志至少加入:

$upstream_addr
$upstream_status
$upstream_connect_time
$upstream_header_time
$upstream_response_time

11.3 第三阶段:被动故障判断#

server backend-a:8080 max_fails=3 fail_timeout=30s;
server backend-b:8080 max_fails=3 fail_timeout=30s;

阈值必须结合流量规模与故障成本压测。

11.4 第四阶段:按接口划分重试策略#

  • 查询接口允许有限重试;
  • 写接口默认关闭;
  • 流式接口不期待中途透明切换;
  • 明确总尝试次数和总时间。

11.5 第五阶段:动态服务发现#

在支持版本中使用:

zone backend 64k;
resolver 10.0.0.2 valid=10s;
server backend.service.internal:8080 resolve;

并评估 DNS TTL、解析失败、旧 IP 连接、负缓存与 DNS 本身高可用。

11.6 第六阶段:入口高可用#

选择:

  • Keepalived Active-Passive;
  • 云 LB + 多 Nginx;
  • Kubernetes Service + 多副本 Controller;
  • 全球 DNS/GSLB + 多地域入口。

11.7 第七阶段:发布、排空和回滚#

生产发布步骤:

生成配置
-> nginx -t
-> 预发布请求验证
-> reload
-> 观察错误率/延迟/节点分布
-> 灰度扩大
-> 异常则回滚旧配置并 reload

摘除节点前应先停止新流量,再等待已有连接结束,尤其是 WebSocket、SSE 和大文件传输。


十二、常见错误与反例#

错误 1:认为配置三个 server 就已经高可用#

现象#

Nginx 所在主机故障后,所有后端都无法访问。

根因#

只解决后端实例冗余,没有解决入口单点。

修复#

部署多 Nginx,并使用 Keepalived、云 LB 或 Kubernetes Service 提供入口故障转移。

验证#

主动停止一个 Nginx 实例,确认入口地址仍然可用。

错误 2:把 max_fails 当主动健康检查#

现象#

低流量时节点宕机很久仍未被摘除。

根因#

开源版被动检查依赖真实请求触发。

修复#

根据环境引入 NGINX Plus 主动检查、云 LB 健康检查、Kubernetes readinessProbe 或外部监控。

错误 3:对订单 POST 启用 non_idempotent#

现象#

网络抖动后生成重复订单或重复扣款。

根因#

第一次请求可能已经在后端提交,代理又在其他节点重放。

修复#

默认关闭非幂等重试;在业务层实现 Idempotency-Key、唯一约束和状态查询。

错误 4:把 keepalive 100 当后端最大连接数#

现象#

后端实际连接数远高于 100。

根因#

keepalive 仅限制每个 Worker 缓存的空闲连接,不限制活动连接总量。

修复#

结合 Worker 数量、max_conns、应用线程池、数据库池和限流整体设计。

错误 5:使用 ip_hash,但 Nginx 看到的都是云 LB 地址#

现象#

大量请求集中到同一个后端。

根因#

Hash Key 是上一层代理 IP,而不是真实客户端 IP。

修复#

先正确配置可信 Real IP 链路,或使用业务用户 ID 进行 Generic Hash。

错误 6:权重写成 9:1,就期待每十个请求严格九比一#

现象#

短时间统计与目标比例不一致。

根因#

权重影响长期选择概率,还会受到连接时长、失败、重试和并发影响。

修复#

扩大统计窗口,观察请求数、并发、延迟和资源使用,而不是只看几十个请求。

错误 7:把所有 500 都自动切换节点#

现象#

业务参数错误在多个节点重复执行,增加整体负载。

根因#

HTTP 500 可能是确定性业务缺陷,不是节点故障。

修复#

只对明确的基础设施失败配置有限重试,并在应用层区分可重试与不可重试错误。

错误 8:单节点 upstream 配置 max_fails=1 后以为会自动摘除#

现象#

唯一节点持续被请求。

根因#

单 server 组会忽略 max_failsfail_timeout

修复#

增加真实冗余节点,或由外部入口进行故障切换。

错误 9:Consistent Hash Key 可由公网客户端任意指定#

现象#

攻击者构造相同 Key,把流量集中到某个节点。

根因#

Hash Key 不可信,没有认证或规范化。

修复#

使用服务端认证后的用户/租户标识,并对热点租户设置独立容量策略。

错误 10:恢复节点立即承受满权重流量#

现象#

JVM 刚启动、缓存尚未预热就再次过载。

根因#

Nginx Open Source 不提供 slow_start,恢复后可能直接按正常权重接流量。

修复#

通过编排 readiness 延迟放量、外部发布系统灰度、先低权重再 reload,或使用支持 slow start 的商业能力。


十三、故障排查流程#

独立插图 11:Nginx 负载均衡与故障转移排查流程
对应文件:11-load-balancing-troubleshooting-flow.png

负载均衡故障排查流程

13.1 第一层:确认配置真的生效#

Terminal window
nginx -t
nginx -T
ps -ef | grep nginx

检查:

  • 修改的是不是实际加载文件;
  • reload 是否成功;
  • upstream 名称是否与 proxy_pass 一致;
  • 算法是否位于正确 upstream;
  • 是否有旧容器或旧 Pod 接收流量。

13.2 第二层:检查组内节点#

在 Nginx 容器或主机内:

Terminal window
getent hosts backend-a
curl -v http://backend-a:8080/api/instance
curl -v http://backend-b:8080/api/instance
ss -nt

区分:

  • DNS 解析失败;
  • 端口拒绝;
  • 网络超时;
  • 后端返回 5xx;
  • 接口慢;
  • TLS upstream 校验失败。

13.3 第三层:看 upstream 日志序列#

关注:

$upstream_addr
$upstream_status
$upstream_connect_time
$upstream_header_time
$upstream_response_time

典型判断:

upstream_addr = A, B
upstream_status = 502, 200

说明发生了故障转移。

upstream_connect_time 很高

可能是网络、端口积压或连接建立问题。

upstream_header_time 很高

后端收到请求后迟迟没有返回响应头。

13.4 第四层:检查分布是否真的异常#

不要只发 6 个请求就下结论。应至少观察:

  • 每个节点请求数;
  • 活动连接数;
  • P50/P95/P99 延迟;
  • 错误率;
  • CPU、内存、GC;
  • 线程池和数据库连接池;
  • 长连接数量;
  • 重试次数。

13.5 第五层:判断是负载算法问题还是容量问题#

一个节点慢,不一定是算法错误,也可能是:

  • 节点规格不同但权重相同;
  • JVM 正在 Full GC;
  • 本地缓存未预热;
  • 数据库连接池耗尽;
  • 某个租户形成热点;
  • 宿主机 CPU 被争抢;
  • 长连接集中;
  • 下游依赖局部故障。

13.6 第六层:验证 Nginx 自身高可用#

检查:

  • 云 LB 后是否真的有多个健康 Nginx;
  • Keepalived VIP 是否能漂移;
  • 两个 Nginx 配置与证书是否一致;
  • 防火墙和安全组是否允许新主节点;
  • DNS 是否仍指向旧入口;
  • reload 是否同时失败;
  • 日志是否集中采集。

十四、生产实践与能力边界#

14.1 什么规模下 Nginx upstream 足够#

适合:

  • 数个到数十个后端实例;
  • 路由和负载算法相对稳定;
  • 以 HTTP 反向代理为主;
  • 团队能够管理配置发布;
  • 不需要复杂控制面和全局服务治理。

14.2 什么时候需要云负载均衡器#

  • 需要公网固定入口;
  • 跨可用区;
  • 自动伸缩;
  • DDoS 基础防护;
  • 托管健康检查;
  • 不希望自行维护 VRRP。

14.3 什么时候需要 API Gateway#

  • 认证鉴权;
  • API Key 和配额;
  • 消费者管理;
  • 精细化路由;
  • 协议转换;
  • API 生命周期;
  • 开发者门户;
  • 业务级插件。

Nginx 能实现部分能力,但不代表应该把所有网关治理都堆在配置文件中。

14.4 什么时候需要 Service Mesh#

  • 大规模东西向服务调用;
  • mTLS;
  • 服务级重试、熔断;
  • 细粒度流量拆分;
  • 统一遥测;
  • 多语言服务治理。

Service Mesh 也会增加控制面、数据面和排障复杂度,不能只因为“微服务”就默认引入。

14.5 Nginx 不应承担的职责#

  • 业务订单去重;
  • 分布式事务;
  • 用户权限的最终判断;
  • 应用内部线程调度;
  • 数据库读写一致性;
  • 任务队列;
  • 模型推理调度的完整容量预测。

入口层应该保护和分配流量,但业务正确性仍由应用保证。


十五、面试与复习问题#

15.1 十道核心问题#

1. upstream 的作用是什么?#

它定义一组可被 proxy_pass 等指令引用的上游服务器,并负责组内节点选择、连接复用和部分运行状态管理。

2. Nginx 默认使用什么负载均衡算法?#

加权 Round Robin。未配置权重时所有 server 默认权重为 1。

3. Least Connections 为什么不一定代表真实负载最低?#

它只观察活动连接数,不直接感知 CPU、内存、JVM GC、线程池、数据库等待和每个请求的计算成本。

4. ip_hash 的主要问题是什么?#

NAT、CDN 和上层代理会让大量用户共享同一来源 IP,产生倾斜;同时它会引入会话粘性,削弱无状态架构。

5. max_failsfail_timeout 是主动健康检查吗?#

不是。它们基于真实请求失败进行被动判断。

6. 单 server upstream 会发生什么?#

max_failsfail_timeout 被忽略,唯一节点不会被标记不可用。

7. proxy_next_upstream_tries 2 表示什么?#

包含第一次尝试在内,最多尝试两个 upstream server,不是额外重试两次。

8. 为什么 POST 不应随意配置 non_idempotent#

第一次请求可能已经产生业务副作用,重放会造成重复订单、重复扣款等。

9. keepalive 32 是否限制 upstream 总连接数为 32?#

不是。它限制每个 Worker 缓存的空闲连接数,活动连接可以更多。

10. 后端多实例为什么仍可能有单点?#

如果只有一个 Nginx 或一个入口 IP,入口故障仍会中断全部服务。

15.2 五道场景分析题#

场景 1:三个实例中一个经常处理 20 秒任务,其他请求约 50ms,如何选算法?#

优先考虑 Least Connections,并从应用层把长任务异步化。仅靠 Round Robin 会让请求数量均匀但连接和资源不均匀。

场景 2:需要同一租户尽量访问相同缓存节点,但会频繁扩缩容。#

使用可信租户 ID 作为 hash Key,并启用 consistent。同时识别热点租户,不能只依赖一致性哈希。

场景 3:订单接口偶尔 504,能否配置 Nginx 自动重试三次?#

不能直接这样做。先确认订单创建幂等性、Idempotency-Key 和唯一约束;否则重试可能造成重复订单。

场景 4:Nginx 后面有两个实例,配置 max_fails=1 fail_timeout=30s 后频繁来回摘除。#

阈值过于敏感或网络存在抖动。检查失败类型、流量规模和后端延迟,适当提高阈值,并用 readiness/主动检查避免抖动。

场景 5:云 LB 后部署两个 Nginx,但 ip_hash 仍严重倾斜。#

Nginx 可能只看到云 LB 地址。需要正确恢复真实客户端 IP,或改用经过认证的用户/租户标识做 Generic Hash。

15.3 三道配置排错题#

排错题 1#

upstream backend {
server backend-a:8080 max_fails=1 fail_timeout=10s;
}

问题:为什么节点失败后仍持续被访问?

答案:组中只有一个 server,失败参数会被忽略。

排错题 2#

upstream backend {
ip_hash;
server backend-a:8080;
server backend-b:8080 backup;
}

问题:为什么配置检查失败?

答案:backup 不能与 ip_hash 组合。

排错题 3#

location /orders {
proxy_pass http://backend;
proxy_next_upstream error timeout non_idempotent;
}

问题:最大风险是什么?

答案:订单请求已经在一个节点成功执行但响应丢失时,Nginx 可能在其他节点重放,产生重复副作用。


十六、本文总结#

本文把单实例反向代理扩展成了完整的多实例入口模型:

  1. upstream 定义候选服务器组;
  2. 负载均衡算法决定在健康候选中选择哪个节点;
  3. weight 表达相对能力,不能被理解为短窗口精确比例;
  4. Round Robin 适合无状态短请求,Least Connections 更关注活动连接;
  5. ip_hash 和 Generic Hash 提供稳定映射,但会带来粘性、热点与 Key 信任问题;
  6. Nginx Open Source 使用真实请求进行被动故障判断,不原生提供主动 HTTP health_check
  7. max_failsfail_timeout 共同描述失败窗口和暂时不可用期,单节点组会忽略它们;
  8. proxy_next_upstream 必须限制条件、次数和总时间;
  9. 非幂等写请求不能依赖代理层盲目重试,业务必须实现幂等;
  10. upstream Keep-Alive 只管理每个 Worker 的空闲连接缓存,不是总连接上限;
  11. 后端多实例并未消除 Nginx 入口单点,需要 Keepalived、云 LB 或 Kubernetes 等高可用方案;
  12. 真正的生产排障必须同时观察节点选择、失败序列、连接、延迟、应用资源和入口状态。

下一篇将进入 HTTPS、证书与安全入口,继续回答 TLS 在哪一层终止、证书如何续期、HTTP 如何安全跳转 HTTPS,以及哪些安全 Header 应该由 Nginx 添加。


参考资料#

以下资料均于 2026-07-06 访问:

  1. Nginx 官方下载页:Mainline 1.31.2、Stable 1.30.3
    https://nginx.org/en/download.html
  2. Nginx 官方文档:Module ngx_http_upstream_module
    https://nginx.org/en/docs/http/ngx_http_upstream_module.html
  3. Nginx 官方文档:Module ngx_http_proxy_module
    https://nginx.org/en/docs/http/ngx_http_proxy_module.html
  4. Nginx 官方文档:Using nginx as HTTP load balancer
    https://nginx.org/en/docs/http/load_balancing.html
  5. F5 NGINX 官方文档:HTTP Health Checks
    https://docs.nginx.com/nginx/admin-guide/load-balancer/http-health-check/
  6. F5 NGINX 官方文档:HTTP Load Balancing
    https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/
  7. Spring Boot 官方项目页
    https://spring.io/projects/spring-boot

文章验收清单#

内容完整性#

  • 说明了从单实例到多实例的架构问题;
  • 讲解 upstream 的请求选择流程;
  • 覆盖权重、备用节点、失败参数;
  • 对比主要负载均衡算法;
  • 区分被动检查、主动检查和 Kubernetes readiness;
  • 讲清非幂等重试风险;
  • 讲清 upstream Keep-Alive;
  • 讨论 Nginx 自身高可用;
  • 提供完整 Docker Compose + Spring Boot 实验;
  • 提供故障排查、面试题和反例。

技术准确性#

  • 区分 Nginx Open Source 与 NGINX Plus;
  • 标明 least_time 的版本边界;
  • 标明 resolve 的开源版本变化;
  • 标明 1.29.7 upstream Keep-Alive 默认变化;
  • 说明单 server upstream 的特殊行为;
  • 未把 max_fails 描述成主动检查;
  • 未对 POST 无条件重试;
  • 未把 keepalive 描述为总连接上限。

配置可运行性#

  • Docker 镜像固定版本;
  • 提供完整目录结构;
  • 提供 Nginx 主配置和 upstream 配置;
  • 提供后端代码与 Dockerfile;
  • 提供启动、验证、故障模拟和清理命令;
  • 提供 nginx -t 检查;
  • 所有插图以独立 PNG 文件交付,不嵌入 Markdown。
第 4 篇:Nginx 负载均衡与高可用
https://jupiter-ws.cn/posts/backend/nginx/04_nginx_load_balancing_high_availability/
作者
Jupiter
发布于
2026-07-07
许可协议
CC BY-NC-SA 4.0