图 1:本文封面,流量进门分给哪台。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:Nginx 的 upstream 模块有哪几种负载均衡策略,各自适合什么场景?
先把结论立住:**upstream 默认就是加权轮询,weight 参数调节配比;ip_hash 换会话亲和,least_conn 追平活跃连接数。选型是一条决策链:先要回答要不要会话保持,再看机器是否同规格,最后看请求耗时不不均。**另外记住一条边界:负载均衡策略只解决「分给谁」,后端健不健康、失败怎么摘除是另一层职责(max_fails 与 fail_timeout 管这事)。
**逐个看机制。**轮询是默认策略:请求依次循环发给每台,零状态、最简单,但它无视机器强弱与当前忙闲。加权轮询按 weight 静态配比,官方文档的例子很直白:weight=5 的机器在每 7 个请求里分走 5 个——能者多劳,适合机器规格不均的集群,但权重是写死的,不随实时负载调整。ip_hash 按客户端地址哈希固定到某一台,官方文档注明:IPv4 取前三段做哈希键,同一客户端的请求总是落到同一台,除非那台不可用;要临时摘一台机器必须标 down 而不是删配置,否则整批客户端的哈希映射会被打乱。least_conn 把新请求发给当前活跃连接最少的一台(计数时还考虑权重),适合请求耗时长短不一的场景——长请求会把轮询撒来的连接在某台上堆成山,least_conn 会绕开正在堆的那台。
图 2:四种策略机制与决策链。
四种策略的行为差异,与其背定义不如跑一遍。下面用 Node.js 22.15.0 模拟三万条请求(八成短请求、两成长请求)打到三台权重 5:3:2 的后端上,再制造「2 号机变慢」的事故场景(node upstream.js):
'use strict';
// 可复现随机数与稳定字符串哈希(FNV-1a)
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function fnv1a(s) {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
return h >>> 0;
}
const rand = mulberry32(20261027);
const weights = [5, 3, 2];
const TOTAL = 30000;
// 请求流:3000 个客户端,80% 短请求(1 片)、20% 长请求(30 片)
const reqs = Array.from({ length: TOTAL }, () => ({
client: `client-${Math.floor(rand() * 3000)}`,
cost: rand() < 0.8 ? 1 : 30,
}));
// 无状态策略:只数分配数。seq 为按权重展开的分发序列
function staticSplit(seq, byIp) {
const load = [0, 0, 0];
const homes = new Set();
let cursor = 0;
for (const r of reqs) {
const pick = byIp ? fnv1a(r.client) % 3 : seq[cursor++ % seq.length];
load[pick]++;
homes.add(`${r.client}->${pick}`); // 同一客户端应只有一条映射
}
return { load, homes: homes.size };
}
// 动态策略:追踪活跃连接,支持「某台变慢」场景(slowIndex 上的请求挂 60 片)
function dynamicSplit(leastConn, slowIndex) {
const active = [0, 0, 0], load = [0, 0, 0], peak = [0, 0, 0];
const inflight = [];
const seq = weights.flatMap((w, i) => Array(w).fill(i));
let now = 0, cursor = 0;
for (const r of reqs) {
now++;
while (inflight.length && inflight[0][0] <= now) active[inflight.shift()[1]]--;
let pick = 0;
if (leastConn) { for (let i = 1; i < 3; i++) if (active[i] < active[pick]) pick = i; }
else pick = seq[cursor++ % seq.length];
load[pick]++;
active[pick]++;
peak[pick] = Math.max(peak[pick], active[pick]);
inflight.push([now + (pick === slowIndex ? 60 : r.cost), pick]);
inflight.sort((a, b) => a[0] - b[0]);
}
return { load, peak };
}
const rr = staticSplit([0, 1, 2], false);
const wrr = staticSplit(weights.flatMap((w, i) => Array(w).fill(i)), false);
const ih = staticSplit(null, true);
const lc = dynamicSplit(true, -1);
console.log(`模拟 ${TOTAL} 个请求(80% 短 / 20% 长),三台后端权重 5:3:2`);
console.log(`轮询: ${rr.load.join(' / ')}(不看权重,三台均分)`);
console.log(`加权轮询: ${wrr.load.join(' / ')}(严格按 5:3:2)`);
console.log(`ip_hash: ${ih.load.join(' / ')}(3000 个客户端只有 ${ih.homes} 条映射,全部固定无漂移)`);
console.log(`least_conn:${lc.load.join(' / ')}(追平活跃连接数)`);
const wrrSlow = dynamicSplit(false, 2);
const lcSlow = dynamicSplit(true, 2);
console.log(`2 号机变慢:加权轮询仍按 5:3:2 分,慢机峰值并发 ${wrrSlow.peak[2]} 个;least_conn 自动绕开,分配 ${lcSlow.load.join(' / ')},慢机峰值并发仅 ${lcSlow.peak[2]} 个`);
实测输出:
模拟 30000 个请求(80% 短 / 20% 长),三台后端权重 5:3:2
轮询: 10000 / 10000 / 10000(不看权重,三台均分)
加权轮询: 15000 / 9000 / 6000(严格按 5:3:2)
ip_hash: 10014 / 9927 / 10059(3000 个客户端只有 3000 条映射,全部固定无漂移)
least_conn:11486 / 9987 / 8527(追平活跃连接数)
2 号机变慢:加权轮询仍按 5:3:2 分,慢机峰值并发 12 个;least_conn 自动绕开,分配 15349 / 13099 / 1552,慢机峰值并发仅 6 个
最后一行是「动态」与「静态」的本质差别:加权轮询在慢机面前无动于衷,继续按 5:3:2 往里灌,慢机峰值并发堆到 12;least_conn 看到它活跃连接多就自动绕开,慢机只承接到 1552 个请求。这个行为正是「请求耗时不均选 least_conn」的理由。
**常见误区。**一是以为配了 upstream 就有了健康检查——开源版的失败摘除靠 server 行的 max_fails 与 fail_timeout 参数,主动健康检查是商业订阅的能力;二是摘机器时直接删 server 行,ip_hash 映射全乱,正确做法是标 down;三是把 ip_hash 当银弹——IPv4 只哈希前三段,办公网出口同段的流量会挤进同一台,热点场景会倾斜;四是以为权重能救慢机,实测已经说明它是静态的。
AI 工程里多模型实例之间的流量分配、按用户做会话亲和,都落在 upstream 策略选择上;选错策略会出现单实例热点与会话丢失。答题节奏建议:先讲默认的加权轮询,再按「会话保持、机器规格、耗时不均」三个分叉讲 ip_hash、权重、least_conn,最后补一句策略与健康检查的职责边界。你们的网关现在用的是哪种策略?踩过会话保持的坑吗?欢迎分享。