深蓝黑背景封面:左侧大标题「流量进门,分给哪台」,副标题「轮询、权重、ip_hash 与 least_conn 的适用场景」;右侧原创示意图——一个写有 upstream 的分发器方块向下连着三台服务器,三根管道粗细不一表示权重不同,分发器顶部一枚旋转箭头表示轮询;左上标注「面试经典 · 系统设计」,右上标注「Day 09 · 10-27」 图 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 会绕开正在堆的那台。

四象限对照加决策图:左上「轮询」——请求依次循环发给每台,零状态最简单,但无视机器强弱与当前忙闲;右上「权重」——按配置比例分配,能者多劳,适合机器规格不均的集群,权重是静态的、不随实时负载调整;左下「ip_hash」——按客户端 IP 哈希固定到某一台,换来会话亲和,代价是某台下线会打乱整批映射、且热点大促时可能倾斜;右下「least_conn」——把新请求发给当前活跃连接最少的那台,适合请求耗时长短不一的场景,实现动态均衡。下方决策路径:先问要不要会话保持——要则用 ip_hash 或更通用的会话外置;不要再看机器是否同规格——不同配权重;同规格再看请求耗时是否均匀——不均选 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,最后补一句策略与健康检查的职责边界。你们的网关现在用的是哪种策略?踩过会话保持的坑吗?欢迎分享。