☰
Nginx负载均衡算法与upstream配置实战:轮询、最少连接与一致性哈希
2026/9/29 7:32:07 网站建设 项目流程

1. 负载均衡到底在解决什么问题

1.1 从一台机器扛不住说起

任何一台服务器都有物理上限。CPU 核数、内存带宽、网卡队列、磁盘 IOPS,这几项里总有一项会先到顶。单机纵向扩容(换更强的机器)在成本上是不划算的,性能翻倍往往意味着价格翻三倍,而且扩容当天就得停机。横向扩容(多台机器组集群)才是主流路径,代价是必须回答一个新问题:请求该由谁接、怎么分。负载均衡就是回答这个问题的组件,它站在客户端和真实服务之间,把流量按照某种规则分发到后端多个实例上。

它带来的收益不只是"分摊压力"。一组服务实例放在负载均衡后面,你能做到不停机发布(先摘掉两台、升级、再挂回来)、灰度放量(新版本只接 5% 流量)、故障隔离(一台挂了自动不再分配)。所以负载均衡在架构里的真实定位是流量调度层,性能只是它最容易被看见的那部分职责。

有人觉得负载均衡就是把请求轮流发给后端,配置里随便填几个 IP 就完事了。这种理解在只有两台机器、QPS 几百的场景下确实不会出问题,一旦集群规模上去、请求耗时出现分化、上线了缓存和长连接,分配不均的问题就会以"某台机器 CPU 打满、其他机器闲着"的形式暴露出来。这也是我写这篇东西的起因——大部分负载均衡的坑,都不在"有没有配",而在"算法和参数为什么不匹配"。

1.2 四层与七层的分界线

聊负载均衡绕不开四层和七层的划分。判断标准很简单:组件是否完整解析了应用层协议。只处理 TCP/UDP 包头、按 IP 和端口转发的是四层;能看懂 HTTP 请求行、Header、URL 的是七层。

对比项四层负载均衡七层负载均衡
工作层次传输层(TCP/UDP)应用层(HTTP/HTTPS/gRPC)
转发依据源目 IP、端口、协议号Host、URL、Header、Cookie
拆包开销不拆包,只改包头完整解析请求,开销更大
典型吞吐十万到百万级 QPS万级到十万级 QPS
常见形态内核态转发、硬件设备Nginx、各类网关服务
会话保持依赖源 IP 哈希依赖 Cookie 或 Header 哈希
适合的业务数据库代理、长连接入口微服务 API、灰度路由

选择逻辑很清楚:越靠外层越用四层,越靠内层越用七层。公网入口通常先过四层扛量,再由七层做业务路由。反过来把七层放在最外侧扛全量流量,CPU 会大量消耗在 TLS 握手和 HTTP 解析上,得不偿失。我在一次活动大促里见过把 HTTPS 终止全放在业务网关上的做法,流量上来后网关 CPU 直接跑满,后来把证书卸载前移到四层入口,网关负载立刻降了六成。这个改动几乎没动业务代码,只是把职责放回了正确的位置。

1.3 负载均衡不等于高可用

这是最容易被混淆的一点。负载均衡解决的是分配问题,高可用解决的是存活问题,两者只是常常一起出现。一个只做了轮询转发、没有任何健康检查的负载均衡器,后端挂了一台,它照样会把请求发给那台死机器,用户看到的就是七分之一的请求失败。

完整的可用性保障至少需要三层配合:负载均衡层的健康检查与自动摘除(连续失败 N 次后把节点踢出转发列表)、调用方的超时与重试(单次请求卡住不能无限等)、以及服务自身的限流与熔断(防止被上游的重试洪峰打垮)。健康检查还分主动和被动:主动检查是负载均衡器定期发探测请求,被动检查是根据真实请求的失败结果来判断。开源版 Nginx 的max_fails属于后者,节点只有在真实请求失败了才会被计数,这意味着一个新挂掉的节点在"被判定失败"之前,仍然会吃掉一部分流量。对延迟极度敏感的接口,被动检查的这段窗口期是真实存在的风险,值得通过多探针或者应用层主动上报来弥补。

2. 常见负载均衡算法逐个拆解

2.1 轮询与加权轮询:最朴素的公平

轮询(Round Robin,简称 RR)就是按顺序一个个发。假设后端是 A、B、C 三台,请求序列就是 A→B→C→A→B→C。它假设了所有后端"完全等价":配置一样、当前负载一样、每个请求消耗的资源一样。只要这三个条件成立,轮询就是最简单且绝对均匀的方案,实现上只需要一个自增计数器取模。

加权轮询(Weighted Round Robin,WRR)是给这个假设打了个补丁。机器规格不一致时(比如 8 核机器和 2 核机器并存),给 8 核机器配 weight=4、2 核机器配 weight=1,让强机器多干活。权重怎么定?我一般按 CPU 核数或内存做基准,用最小规格的机器作为 1,其他机器按比例取整。比如集群里有 2C4G、4C8G、8C16G 三种规格,权重就设 1:2:4。这个算法比拍脑袋设"高配设 10、低配设 3"靠谱得多,因为它是可推导的,后续加机器也能延续同一套换算逻辑。

但最朴素的加权轮询有个明显缺陷:它会把同一个节点的请求堆在一起。按权重 5:1:1 的配置,序列会变成 A A A A A B C,前五个请求全部砸向 A,A 会在很短时间内出现一个尖峰,然后再空闲。这种"突发式"分配对连接池、线程池都不友好,很容易触发瞬时限流。

2.2 平滑加权轮询的数学过程

Nginx 默认的加权轮询用的是平滑算法,能保证权重大小关系不变的前提下,把请求序列打散。它的实现只需要两个变量:每个节点的固定权重 weight和运行时变化的当前权重 current_weight。每一轮的过程是固定的三步:

  1. 每个节点的current_weight += weight;
  2. 选出current_weight最大的节点;
  3. 把选中节点的current_weight -= 所有节点的 weight 之和。

用权重 5:1:1 的三个节点 a、b、c 实际走一遍,总权重是 7:

轮次累加后的 current_weight (a,b,c)命中扣减后的 current_weight (a,b,c)
15, 1, 1a-2, 1, 1
23, 2, 2a-4, 2, 2
31, 3, 3b1, -4, 3
46, -3, 4a-1, -3, 4
54, -2, 5c4, -2, -2
69, -1, -1a2, -1, -1
77, 0, 0a0, 0, 0

七轮跑完,序列是 a a b a c a a,a 出现 5 次、b 和 c 各 1 次,权重比例完全正确,而且 b、c 被均匀地插在中间,没有出现连续扎堆。第七轮结束后所有 current_weight 回到 0,说明这是一个长度恰好等于总权重的循环,不会出现长期漂移。这个"归零"性质是平滑算法的关键,如果实现里忘了扣减总权重,误差会累积,几十万次请求后就会出现明显的分配倾斜。

用代码实现只有十来行,这里给一段可以直接跑的最小版本,方便你在自己的调度逻辑里复刻:

class SmoothWRR: def __init__(self, nodes): # nodes: [{"name": "a", "weight": 5}, {"name": "b", "weight": 1}] self.nodes = nodes self.total = sum(n["weight"] for n in nodes) for n in self.nodes: n["current"] = 0 def pick(self): best = None for n in self.nodes: n["current"] += n["weight"] if best is None or n["current"] > best["current"]: best = n best["current"] -= self.total return best["name"]

注意:比较current_weight时要用严格大于(>)而不是大于等于。平局时固定选列表里靠前的那个,才能保证序列可复现。如果是大于等于,选中项会随遍历顺序漂移,压测时你可能会看到两份完全不同的分布结果,白白浪费排查时间。

2.3 最少连接与加权最少连接

权重是静态的,它描述的是机器的"能力上限",而不是"此刻有多忙"。同样权重的两台机器,一台正在处理三个耗时 2 秒的报表接口,另一台在跑一堆 10 毫秒的查询,轮询会把它们当成一样闲,结果就是慢的那台越堆越多。最少连接(Least Connections)用当前活跃连接数作为分配依据,谁手上的连接少就给谁,天然具备自适应能力。

加权最少连接(least_conn配合 weight)在 Nginx 里的实际判据是连接数 / 权重的比值,比值最小的胜出。所以权重大小关系和动态负载是同时生效的。

这个算法有个必须知道的盲点:连接数不等于工作量。如果后端用的是 HTTP 长连接,一个连接上可能已经跑了上千个请求,也可能刚建好还没用,连接数完全反映不出真实压力。另外在使用连接池的场景下(比如后端是数据库代理),连接数是稳定的,最少连接反而退化成近似轮询。判断要不要用least_conn,我的经验是看接口耗时的方差:耗时方差大(P99 是 P50 的十倍以上)就用它,方差小就用轮询,没必要为了"看起来先进"而引入额外的状态统计。

2.4 一致性哈希:缓存场景的救命稻草

前面几种算法都假设后端是无状态的:请求发给谁都一样。一旦涉及本地缓存、本地文件、有状态的会话,这个假设就崩了。轮询加上本地缓存会得到这样的结果:同一个 key 第一次落到 A 被缓存,第二次落到 B 就得回源再查一次,缓存命中率被硬生生砍到 1/N,后端压力反而更大。

一致性哈希把节点和一个哈希空间映射成一个环。常见实现是把 0 到 2 的 32 次方减一这个区间首尾相接成一个环,节点按哈希值落在环上,请求也按 key 的哈希值落环,然后顺时针找到的第一个节点就是目标。这样同一个 key 永远落在同一个节点上,缓存命中率保住了。

它真正聪明的地方在扩容时的表现。假设环上有 A、B、C 三个节点,新增一个 D,用取模哈希的话几乎所有 key 都要重新映射,缓存集体失效,回源洪峰能把数据库打穿。一致性哈希里,只有落在 D 和它逆时针前一个节点之间的那段 key 需要迁移,其余保持不变,代价从"全量"降到"约 1/N"。

但只有三个物理节点的环,哈希分布往往很不均匀。解决办法是虚拟节点:每个物理节点在环上放 100 到 200 个虚拟点,key 落到虚拟点再映射回真实节点。虚拟点越多,分布越接近均匀。这个数量不是越多越好,200 个虚拟点的路由表已经需要二分查找了,继续增加只是徒增内存和查找开销。常见的 Ketama 实现默认每节点 160 个虚拟点,是个久经考验的经验值。

upstream cache_backend { hash $request_uri consistent; server 10.0.0.21:6379; server 10.0.0.22:6379; server 10.0.0.23:6379; }

上面这段里,hash $request_uri consistent表示按 URI 做一致性哈希,consistent关键字就是开启环式哈希并启用虚拟点。如果要按用户维度保持会话,把变量换成$cookie_sessionid或者基于用户 ID 拼出来的变量即可。

2.5 最短响应时间与 P2C

最短响应时间(Least Time)直接用历史响应时间作为选点依据,把请求发给最近表现最好的节点。它比最少连接更贴近用户感受,因为连接数少不代表响应快——一个卡在 GC 上的节点可能连接数很少,但每个请求都要等几百毫秒。

它的代价是需要持续采集指标。开源版 Nginx 不提供这个算法,商业版本里才有least_time指令,可以选择以响应头时间或完整响应时间为依据。自己实现的话,通常会维护一个滑动窗口的平均 RT,窗口大小是个权衡:窗口太大反应迟钝,慢节点要很久才被降权;窗口太小容易抖动,一个偶发的慢请求就会让节点被冷落,之后它的样本更少、判断更不准。

P2C(Power of Two Choices,二选一)是另一个思路:随机挑两个节点,从这两个里选指标更好的那个。它不需要维护全局状态,是个纯本地决策,天然适合分布式的大规模调用。数学上它只比纯随机好一点,但实际效果惊人——它能把最忙节点和平均节点的负载差距压到很小的范围,同时避免了"所有调用方都选同一个最优节点"的羊群效应。gRPC 的负载均衡策略和不少 RPC 框架都提供这个模式。我自己在服务间调用上做过对比,P2C 相比轮询,P99 延迟降低了差不多三成,改动量只有几行配置。

2.6 随机与加权随机

随机算法就是从后端列表里随机取一个。听起来很粗糙,但在节点数量足够多(几十个以上)时,大量请求的统计结果会收敛到均匀分布,而且它不需要任何中心状态,每个调用方可以独立决策,不存在单点。缺点是短时间窗口内会有波动,节点少的时候尤其明显,三台机器一百个请求,很可能出现 45:35:20 这种分布。

加权随机在随机的基础上按权重设置概率区间,实现上就是累加权重后取一个随机数落在哪个区间。它在小规模集群下不如平滑加权轮询均匀,但胜在实现简单、无状态、天然去中心化。我在一些边缘节点的小集群里会用它,因为那套环境里不方便维护中心的调度状态。

3. Nginx 负载均衡配置实战

3.1 upstream 基础结构与权重换算

Nginx 的负载均衡配置集中在upstream块里,写法不复杂,但每个参数的选择都有讲究:

upstream api_backend { least_conn; server 10.0.0.11:8080 weight=5 max_fails=3 fail_timeout=10s; server 10.0.0.12:8080 weight=3 max_fails=3 fail_timeout=10s; server 10.0.0.13:8080 weight=1 backup; keepalive 64; }

逐个参数说。least_conn声明使用最少连接算法,不写就是默认的平滑加权轮询。三行server定义后端,weight是权重,按前面说的核数比例换算:11 号机器 8 核、12 号 4 核多一点、13 号是备用的低配机器,所以是 5:3:1。backup标记表示这台机器平时不接流量,只有当 11 和 12 全部不可用时才会启用,适合放一台降级用的机器或者专门跑兜底逻辑的服务。

max_fails=3 fail_timeout=10s这两个参数要连起来读:在 10 秒的统计窗口内失败 3 次,节点就被摘除;被摘除后同样要等 10 秒,才会重新放一个请求进去探测。如果探测成功,节点恢复;失败则继续摘除。所以一个节点从挂掉到完全退出转发列表,最坏情况下会损失 3 个请求;从恢复到稳定接流量,又要经历一次探测。fail_timeout设得太短会导致频繁摘除恢复,把本来只是偶发超时的节点反复踢出;设得太长会让已经恢复的节点长时间闲置。我一般把这两个值设成接近业务超时时间的两到三倍,比如接口超时是 3 秒,这里就设max_fails=3 fail_timeout=10s。

keepalive 64是 upstream 到后端的长连接池大小。这里很容易踩坑,只写这一行是不生效的,还需要在location里配合两行配置:

location /api/ { proxy_pass http://api_backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

proxy_http_version 1.1是必须的,因为 HTTP/1.0 默认不支持长连接。proxy_set_header Connection ""用来清掉客户端发来的Connection头,否则客户端说close,Nginx 转发到后端也会跟着关连接,连接池就白建了。这两行是长连接生效的必要条件,少了任意一行,你会在后端看到TIME_WAIT数量居高不下,每秒钟都在建新连接。我见过一个团队排查了半个月"为什么 QPS 上不去",最后发现就是漏了这两行,加上之后同一个压测脚本的 QPS 翻了将近一倍。

3.2 会话保持:ip_hash、hash 与无状态化的取舍

会话保持(sticky session)有几种实现方式,各有各的代价。

ip_hash按客户端 IP 做哈希,同一个 IP 固定落到同一台机器。它的问题是:大量用户来自同一个出口 IP 时(公司出口、学校出口、移动网络网关),这些用户会全部压到同一台后端上。我遇到过一次线上告警,某台机器 QQ 相关的流量占比超过 70%,排查下来是某个大客户的内网通过单一出口访问,几万个用户共用一个源 IP,ip_hash把它们全指向了同一个节点。而且ip_hash要求集群大小固定,加了机器之后只能靠权重来"慢慢引流",扩容操作非常别扭。

hash $cookie_xxx consistent用业务侧的 Cookie 做键,粒度比 IP 细,也不受 NAT 影响,是更推荐的做法。但要注意 Cookie 可能为空——未登录用户第一次访问时还没有这个 Cookie,所有空值会落到同一个节点上,形成热点。解决方式是给空值做一层兜底,比如在变量拼接时掺入$remote_addr或者$request_id,让空 Cookie 的用户也能分散开。

最彻底的方案还是无状态化:把会话数据挪到 Redis 之类的共享存储里,后端任何一台机器都能处理任意请求,负载均衡层就可以放心用轮询。这个改动一次投入、长期受益,代价是要重构会话的读写逻辑。我的建议是,新项目一开始就按无状态设计,别等到集群扩容时才回头改。

3.3 健康检查的被动与主动

开源版 Nginx 只支持被动健康检查,也就是靠max_fails这套机制。它的局限在于必须等真实请求失败才会计数,而且失败的定义是"连接建立失败、超时或者响应错误",如果你的后端接口返回 200 但对内报错,Nginx 是不知道的。

要做到主动探测,通常有三条路:一是换用带主动检查能力的商业版本或者支持该能力的网关组件;二是引入第三方的健康检查模块;三是在运维层用一个独立的探针服务,探测失败后调用接口动态地把节点从 upstream 里摘掉(或者直接改配置 reload)。第三条路最土但最可控,我在几个项目里都用过:一个简单的定时任务,每 5 秒 curl 一次每个节点的健康接口,连续两次失败就调运维接口摘节点,恢复后再挂回来。逻辑加起来不超过一百行,可控性和可观测性比任何黑盒方案都好。

注意:不管是哪种健康检查,探测接口一定要设计得足够轻。我见过把健康检查接口写成"查一次数据库、调一次下游"的,节点本身只是轻微卡顿,健康检查也一起超时,结果所有节点被同时判定为不健康,整个集群瞬间空转。健康检查接口的职责只有一个——证明进程还活着、能接受连接。

3.4 超时与重试:最容易被误用的两个参数

proxy_connect_timeout 2s; proxy_send_timeout 5s; proxy_read_timeout 5s; proxy_next_upstream error timeout http_502 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 10s;

四个超时参数的作用点不同,理解清楚才能设对。proxy_connect_timeout是跟后端建立 TCP 连接的超时,一般设 1 到 3 秒,这个时间反映的是网络和 backlog 队列的情况,设太长没意义。proxy_send_timeout是把请求发给后端的超时,proxy_read_timeout是等后端响应的超时,这两个要按业务的实际耗时来设,一般取 P99 的两倍左右。

proxy_next_upstream是重试策略。这里有个非常危险的细节:默认情况下,如果请求已经发出去、后端也已经开始处理,但响应超时了,Nginx 会向下一台后端重发这个请求。对于那些非幂等的接口——比如下单、扣款、发消息——重试意味着用户可能被扣两次钱、收到两条消息。

我的处理原则是:写接口(POST/PUT/DELETE)原则上不开重试,或者只对"连接失败"这种确定没到达后端的错误重试(proxy_next_upstream error),绝不对timeout重试。读接口(GET)可以放心重试。如果业务上确实需要对写接口做重试,就必须在服务端做幂等控制,比如用请求 ID 去重、数据库唯一索引、乐观锁版本号,让重复请求变成无害操作。

另一个容易忽略的点是重试会放大流量。上游失败 20%、重试 2 次,意味着后端要承受 1.2 倍的请求量。如果是因为后端整体过载导致的超时,重试洪峰会把已经奄奄一息的集群彻底压死。这种时候更该做的是熔断和快速失败,而不是重试。

4. 不同业务场景下的算法选型

4.1 无状态 API 服务的选型

普通的 REST API、微服务间的内部调用,属于这一类的典型。特征是所有实例等价、请求耗时接近、无本地状态。这种情况下直接用平滑加权轮询就足够了,配置简单、行为可预测、排查方便。想再进一步可以上P2C,代价是需要部署侧的指标采集能力。

我不建议在这类场景里过早引入最少连接或者最短响应时间。它们的收益在请求耗时方差大的时候才明显,而在方差小的场景里,它们会因为指标波动做出一些看起来"莫名其妙"的决策,反倒增加排查难度。压测时发现分布不匀,如果用的是自适应算法,你得先怀疑是指标采集出了问题还是算法本身在起作用,多了一层不确定性。

4.2 缓存与有状态服务的选型

带本地缓存的场景用一致性哈希,键的选择决定成败。用 URL 做键适合静态资源;用用户 ID 做键适合会话和个性化数据;用业务主键做键适合领域对象缓存。键的基数要足够大,否则热 key 依然会集中在某一个节点上,哈希再均匀也救不了。曾经有个项目的排行榜接口,哈希键用了"榜单类型",总共只有三种取值,三个节点各接一个榜单,结果大榜单的节点被打爆,小榜单的节点在发呆。后来把键改成"榜单类型+分页区间",才真正分散开。

有状态服务还有一个容易忽视的问题:节点摘除时的状态迁移。一致性哈希解决了"哪个 key 归谁",但没解决"节点挂了之后,它的 key 由谁接管"。缓存场景下这只是回源成本上升,有状态场景下可能就是数据丢失。所以真正需要持久化状态的服务,状态应该存在外部存储里,负载均衡层的哈希只是为了减少跨网络的访问次数,而不是状态的唯一载体。

4.3 异构机器与灰度发布

机型不一致的集群,权重换算前面已经说过,按 CPU 核数做基准。但有一种情况权重解决不了:同一台机器上跑了多个实例,其中一个实例做了内存泄漏。这时候应该做的是把异常实例摘掉,而不是调权重。权重的定位是"静态能力的描述",不要把它当成动态调速的旋钮,每天调权重说明你的容量规划出了问题。

灰度发布是另一个高频场景。常用做法是在 upstream 里建两个组,一个稳定组放 95% 的流量,一个灰度组放 5%,用split_clients按请求特征分流:

split_clients "${remote_addr}${http_user_agent}" $group { 5% gray; * stable; } upstream app_stable { server 10.0.0.31:8080; server 10.0.0.32:8080; } upstream app_gray { server 10.0.0.41:8080; } location /api/ { proxy_pass http://app_$group; }

这里的分流依据去掉了纯 IP,掺入了 User-Agent,是为了避免同一个用户在不同设备上被分到不同版本。split_clients的百分比是按请求数算的,流量小的接口样本量不足,可能前几百个请求一个都没进灰度组,放量前最好先用大盘确认一下实际比例。灰度期间务必盯紧新版本的错误率和延迟,别只看流量进来了多少。

4.4 等开销负载均衡(ECMP)在四层的应用

"等开销负载均衡"这个词更多出现在网络设备的语境里,指的是等价多路径(Equal-Cost Multi-Path):在路由层面存在多条代价相同的转发路径时,把流量按某种哈希规则分摊到这些路径上。它工作在四层以下,和前面聊的应用层算法是两个层面的东西,但在现代数据中心里两者是配合使用的。

ECMP 的分流依据通常是五元组(源 IP、源端口、目的 IP、目的端口、协议号)的哈希。同一个 TCP 连接的流量会被散列到同一条路径上,这就是流粘性,它保证了同一连接的报文不会乱序到达。没有流粘性的话,一个连接的数据包走了不同路径,接收端会因为乱序大量重传,吞吐反而下降。

ECMP 有两个典型问题值得记住。一是哈希极化:如果所有流量都来自少数几个源 IP,哈希结果会集中,导致部分链路拥塞而其他链路空闲。二是大象流:一个长期存在的大流量连接(比如备份任务、大文件传输)会死死占住一条路径,其他流无法挤进去,即便整体带宽还有富裕。所以现代网络里会引入动态调整机制,或者用更细粒度的流标识来打散。做应用层的容量规划时,如果发现"每条链路带宽利用率都很高但应用侧 QPS 不高",可以往这个方向排查。

5. 常见故障与排查实录

5.1 流量倾斜的四种典型原因

排在第一位的永远是权重配错。前面提到的那次事件,扩容的新机器权重默认是 1,而老机器还是 5,新机器几乎没接到流量。排查方法很直接:让所有后端节点输出自己的请求计数,对比一下差异。如果差异比例跟权重比例一致,那就是配置问题;如果差异跟权重完全对不上,往下看第二条。

第二条是长连接复用导致的实际分配不均。这是最隐蔽的一类。即使算法是完美的轮询,如果调用方和后端之间是长连接,连接一旦建立就会被反复使用,新连接只有在旧连接关闭后才会建立。结果是:早期建立的连接把大量请求压到了少数节点上,后面新加的节点拿不到连接,只能干等。表现得就像"算法失效了"。验证方法是统计每个节点上的请求数而不是连接数,如果连接数很均匀但请求数差异很大,基本就是这个问题。解决办法是给长连接设置最大请求数(比如每个连接处理 1000 个请求后主动关闭)或者最大存活时间,强制重新分配。

第三条是哈希键分布不均。用一致性哈希时,如果键的取值集中,某些节点天然会拿大头。用hash $remote_addr尤其明显,移动网络下大量用户共享出口 IP。验证方法很简单:把哈希键的分布统计出来,看看 Top 10 的键占了多少请求量。

第四条是慢节点导致的连接堆积。节点本身没挂,但响应慢,连接被长时间占用,连接数越来越高,反倒因为"最少连接"算法被不断"降权",看起来是好事——但它的连接池已经被占满,再接到新请求就是排队,这时候应该直接摘掉它。慢节点比挂节点更难处理,因为挂节点会被健康检查快速剔除,慢节点却可能一直"活着"、"能响应",只是拖累整体。监控上一定要给每个节点的 P99 单独建图,不能只看集群整体平均。

5.2 慢节点引发的雪崩链条

一个节点变慢,可能引发一连串反应,这是负载均衡层面最需要警惕的故障模式。链条通常是这样的:

第一环,节点响应变慢,活跃连接数上升。第二环,上游的请求在负载均衡器上排队,等待时间变长。第三环,超过了上游的超时阈值,上游开始重试,请求量放大。第四环,重试的请求打到其他健康节点上,把其他节点的负载推高,它们也开始变慢。第五环,健康检查因为超时开始误判,把本来健康的节点也摘掉,可用节点进一步减少。

整个过程可能只需要几十秒。要打断这条链,关键动作有两个:一是在负载均衡层做快速失败,而不是无限等待,超时时间要设得比后端正常耗时长一点但绝不设成"等到底";二是在调用方做熔断,当某个节点的失败率超过阈值时直接停止向它发请求一段时间,不要让它继续消耗资源。还有一个经验是给慢节点设置"冷静期"——被判定为慢后强制摘除一段时间,而不是立刻允许它重新接流量。这样做虽然浪费了一点容量,但能避免它在半死不活的状态下反复抖动。

5.3 连接数不均衡与端口耗尽

后端看到的现象是某些节点ESTABLISHED数量特别高,另一些很低。除了上面的长连接问题,还有一个常见原因是负载均衡器自身的连接池配置。如果每个工作进程维护独立的连接池,池子大小和进程数、后端节点数的组合会影响实际分布。比如 4 个 worker 进程、3 个后端节点,某些 worker 的连接池可能偏向了某一个后端。

还有一类问题是端口耗尽。负载均衡器主动向后端建立大量短连接时,会消耗本地的源端口(默认范围通常是 32768 到 60999,大约 28000 个)。如果连接建立速度很快、回收又慢(TIME_WAIT状态默认持续 60 秒),端口会被耗尽,报错通常是 "Cannot assign requested address"。我遇到过的一次,压测脚本以每秒 3000 个请求的频率打过来,没建长连接,不到 20 秒就开始大量报错。解决办法就是启用 upstream 长连接,把连接建立频率降下来,这是最根本的解法。

排查命令我常备这几条:

排查目的常用命令关键观察点
查看端口与连接总量ss -sTIME-WAIT 数量是否异常增长
查看监听队列ss -lntSend-Q 是否长期非零,说明 backlog 堆积
按后端统计连接ss -tan | awk '{print $5}' | sort | uniq -c各节点连接数是否均衡
单节点耗时curl -o /dev/null -s -w '%{time_total}\n' http://节点/健康接口与集群平均值对比
查看生效配置nginx -T确认 upstream 与实际预期一致

注意:nginx -T输出的是合并后的完整配置,包括 include 进来的所有文件。排查"配置改了没生效"的问题时,一定要用它而不是看单个配置文件,被 include 的旧配置覆盖新配置是极常见的原因。

5.4 一份可直接对照的问题速查表

现象最可能的原因优先动作
某节点流量明显偏高权重配置错误核对 upstream 权重与机型比例
连接数均匀但请求数倾斜长连接复用设置连接最大请求数与存活时间
新扩容节点接不到流量长连接未重建或权重过低重启客户端连接池或调整权重
请求集中在少数 key 所在节点哈希键分布不均更换哈希键或加虚拟节点
集群整体 P99 上涨慢节点拖累 + 重试放大摘除慢节点、关闭超时重试
后端出现大量 TIME_WAIT未启用 upstream 长连接配置 keepalive 与 HTTP/1.1
报 Cannot assign requested address源端口耗尽启用长连接或扩大端口范围
某个接口偶发重复数据超时重试作用于非幂等请求关闭 timeout 重试、加幂等控制

6. 把负载均衡用稳的几条个人经验

6.1 参数不要拍脑袋,先算出基准值再说

权重、超时、连接池大小、fail_timeout,这几个参数我见过太多是"参考了网上一篇博客"就填上去的。它们的合理值其实都能推导:权重按核数比例算,超时按 P99 的两倍算,连接池大小按"峰值 QPS × 平均耗时"估算并发数再留 20% 余量,fail_timeout按业务超时的两到三倍设。推导出来的值不一定最优,但至少有依据,出问题时你知道该往哪个方向调。拍脑袋填的参数,出事时连怀疑的对象都没有。

举个具体的估算例子。假设某接口峰值 QPS 是 2000,平均耗时 50 毫秒,那么并发数大约是 2000 × 0.05 = 100。如果后端有 4 台机器,每台的并发约 25,那 upstream 的keepalive设成 32 就够了——它描述的是每个 worker 进程到后端的空闲连接保留数,不需要设得很大,设太大反而会占用后端不必要的连接槽位。这些数字算一遍只要两分钟,比上线后半夜被叫起来改配置划算得多。

6.2 上线前的验证动作

每次调整负载均衡配置,我都会做三件事。第一是用真实流量镜像压一遍,确认各个节点的请求数比例符合预期,误差在 5% 以内算正常。第二是摘节点演练,手动把一台机器的进程停掉,看流量多久完成切换、有没有报错、上游超时和重试配置是否按预期工作。第三是看一组关键指标:每个节点的请求数、平均耗时、错误率、活跃连接数,四个指标放在同一张图上,一眼就能看出倾斜。

第三件事的价值比想象中大。我曾经因为观察到"请求数很均匀,但某节点的活跃连接数一直是其他节点的三倍"而提前发现了一个连接泄漏问题——那个节点上的服务有个接口忘了关数据库连接。如果只看请求数,这个问题要等到半个月后连接池耗尽才会暴露。

6.3 后续可以继续深挖的方向

负载均衡这一层往下走,有几个方向值得投入时间。一是自适应限流,让每个节点根据自身的实时负载(比如 CPU、队列长度)动态上报"我还能接多少",负载均衡器按上报值加权分配,这就是所谓的"最少未完成请求 + 服务端反馈"的组合方案,比纯静态权重精确得多。二是延迟感知的路由,把网络 RTT 也纳入决策,跨机房场景下能避免把请求发到物理距离远的节点。三是全链路的重试预算控制,在调用链的入口处统一分配重试额度,避免每一层各自重试导致的放大。

不过我也想说一句,这些方案的前提是你的基础配置已经没问题了。我见过太多团队在权重还配错、长连接还没开的情况下就去研究自适应调度,最后发现问题出在最初的那一页配置上。把upstream块里的每一个参数都搞清楚它到底在做什么,比引入任何新框架都更有价值。我个人在排查过的所有负载均衡相关问题里,真正需要改算法的不到一成,剩下的九成都出在参数配置和长连接复用上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询