Nginx upstream调度算法怎么选:轮询、least_conn与哈希

一、先把「负载不均」和「负载不了」分开

很多站长一发现某个后端机器 CPU 高、另一台闲着,第一反应就是换调度算法。但 Nginx upstream 的算法只解决一件事:把新连接派给谁。如果一个后端的慢是因为它自己持有长任务、内存不够、磁盘满了,换成 hash 或者 least_conn 也救不回来,只会把问题均匀地摊到所有机器上,让「一台慢」变成「全站慢」。

所以动手之前先做一次判定。把每台后端在这一分钟内的四个数拉出来看:活跃连接数、请求速率、CPU 使用率、平均响应时间。如果四台机器的活跃连接数差不多,但响应时间差三倍,这属于后端自身问题,算法无关;如果活跃连接数差距明显(比如 A 有 80 个、B 只有 5 个),那才是派发不均,值得动 upstream 配置。

# 在每台后端上执行,连续看 30 秒
for i in $(seq 1 6); do
  ss -tan state established '( sport = :9000 )' | wc -l
  sleep 5
done

# 在反代机上看 upstream 的实时连接分布
nginx -T 2>/dev/null | grep -A6 'upstream php_backend'

一个容易被忽略的前提:Nginx 的默认轮询(round-robin)是按请求数派的,不是按连接数。PHP-FPM 上一个请求处理 2 秒、另一个页面只查一次缓存 20 毫秒,两者的负载根本不在一个量级上。如果你的站点里混着「重页面」和「轻接口」,光看请求数就觉得派发很均匀,实际上重页面的机器早就过载了。这类站点才真正需要 least_conn。

二、加权轮询的权重怎么写才不出事

加权轮询是最常用、也最容易被写成事故的配置。它的语义是「每台机器按权重比例接收新请求」,但权重是相对值,不是绝对能力值。8 核和 4 核的机器写 weight=8 和 weight=4 就够了,写成 800 和 400 效果一样,却容易在后续添加机器时算错比例。

upstream php_backend {
    server 10.0.0.11:9000 weight=4 max_fails=3 fail_timeout=15s;
    server 10.0.0.12:9000 weight=2 max_fails=3 fail_timeout=15s;
    server 10.0.0.13:9000 weight=1 max_fails=3 fail_timeout=15s;
    keepalive 64;
}

三个参数必须一起考虑。max_fails 是连续失败几次判死,fail_timeout 是判死之后多久再放进去试。默认值 1 和 10 秒过于激进:一次网络抖动就能把一台健康的机器摘掉,然后所有流量涌向剩下的机器,触发第二台超时,形成连锁雪崩。生产上比较稳的取值是 max_fails=3 配 fail_timeout=15s,并且把 proxy_next_upstream 限定成只对幂等方法重试:

proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;

这三个值配错会带来一种很难查的故障:用户提交表单,后端其实已经处理成功了,但因为响应超时被 Nginx 重试到另一台,于是数据库里多了一条重复记录。所以 proxy_next_upstream 里不要加 non_idempotent,除非你的接口本身做了幂等键。

三、least_conn 的真实行为与适用边界

least_conn 的规则是「把新连接派给当前活跃连接数最少的机器,连接数相同时按权重和轮询决定」。它对付长连接和长短请求混合的场景非常有效:一台机器上挂着 20 个慢查询请求,它自然就不会再收到新连接。

但它有两个边界必须知道。第一,它看的是连接数,不是请求耗时。现在大部分站点开了 keepalive,一个空闲的 keepalive 连接也计为活跃连接,于是「闲着但连着 64 条」的机器反而被判定为最忙。比较妥的写法是让 upstream keepalive 池和 least_conn 同时存在,但把 keepalive 数量控制在合理范围(每 worker 16~32 条),不要设成几百。

第二,least_conn 在流量极低时表现不稳定。凌晨只有几十个请求时,连接数的差异完全由偶然因素决定,可能连续十几分钟都派给同一台机器。这本身不算故障,但如果你有「按机器统计的监控告警」,就容易产生误报。

四、ip_hash 与 hash $key:会话粘性的代价

会话粘性最常见的诉求是「登录状态不能丢」。如果站点的 session 存在本地文件里,一旦请求换到另一台机器,用户就被登出了。这时候会想到 ip_hash。

upstream app_backend {
    ip_hash;
    server 10.0.0.21:8080;
    server 10.0.0.22:8080;
    server 10.0.0.23:8080 down;
}

ip_hash 依赖客户端 IP 的哈希值。这里有个致命的现实问题:大量用户处在同一出口 IP 下(公司 NAT、运营商大内网、手机基站),他们会被哈希到同一台后端;而反过来,移动网络用户每次切换基站 IP 就变,粘性直接失效。更麻烦的是扩容:从 3 台加到 5 台,绝大多数 IP 的落点都会变,等于全站用户同时掉线。

比较现代的做法是用 hash $cookie_sessionid consistent,按业务会话键做一致性哈希,扩缩容时只有约 1/N 的键会迁移。但真正的结论是:粘性应该是兜底手段,不是设计目标。把 session 放进 Redis,后端就变成无状态的,所有算法都能随便用,扩容也不用挑时间。这一条经验在多个站点上反复验证过:花半天改 session 存储,比花两周调算法划算得多。

五、备份节点与灰度:down、backup 和健康检查

upstream 里一共有三种"让某台机器退出或降级"的写法,用错场景的后果差别很大。

down 是静态标记,写完需要 reload 才生效,适合计划内的维护。它的好处是可回滚、可版本化,配合配置管理工具很安全。backup 是「平时不接流量,所有主节点都挂了才上」,适合那台配置低、只是用来兜底的机器。注意 backup 节点平时不做健康检查的话,等真的切过去才发现它连不上数据库,那就白搭了。

商业版 Nginx 有主动健康检查,开源版没有。开源环境的替代方案有三种:一是用 max_fails 被动探测;二是用 OpenResty 或者 nginx_upstream_check_module 编译进模块;三是干脆用 HAProxy 或者云厂商的负载均衡做前面那层。如果后端数量超过 5 台,或者业务不能容忍「失败三次才摘除」,我更倾向第三种:把健康检查的职责交给专业组件,Nginx 只做反向代理和缓存。

六、灰度发布怎么落到 upstream 里

灰度不需要单独的网关,在 upstream 里就能做,而且比想象中简单。核心是把「新版本机器」和「老版本机器」分成两个 upstream,然后在 server 段里用变量在两者间选择。

upstream app_stable {
    server 10.0.0.21:8080;
    server 10.0.0.22:8080;
}
upstream app_canary {
    server 10.0.0.31:8080;
}

map $cookie_canary $backend_pool {
    default  app_stable;
    "1"      app_canary;
}

server {
    location / {
        proxy_pass http://$backend_pool;
    }
}

map 的写法有一个坑:proxy_pass 里一旦用了变量,Nginx 就不再在启动时解析 upstream 名称,而是每次请求时查。这时候如果 upstream 名字写错,Nginx 不会报错,只会在运行时返回 502。所以改完必须 nginx -t 加一次真实请求验证,别只看语法检查通过。

按 cookie 分流只适合内部测试,外部用户不会有这个 cookie。面向真实用户的灰度一般按比例:用 split_clients 对客户端 IP 做哈希,10% 的流量进 canary。要提醒的是,split_clients 的百分比是请求粒度而不是用户粒度,同一个用户的两次请求可能落到不同版本,对状态敏感的页面会出现「刷新一下界面变了」的诡异现象。这种页面要在灰度期强制走稳定池。

七、后端拿到的客户端 IP 和协议头

负载均衡加了一层,后端拿到的 REMOTE_ADDR 就变成了反代机的内网地址。程序里的限流、防刷、地域统计全部失真。必须显式透传:

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;

这里最容易出事的是 X-Forwarded-For:它是可被客户端伪造的。如果程序直接取第一个值当客户端 IP,就等于把限流和封禁的决定权交给了攻击者。正确的做法是后端按「从右往左数第 N 个」取值,N 等于可信代理层数;或者在入口层就把客户端传来的 XFF 头整个清空重写。Nginx 侧对应的写法见 real_ip_module 的 set_real_ip_from,只信任已知的代理地址段。

八、上线前的三件小事

第一件是固定 worker_connections 与文件描述符的匹配关系。upstream 连接加上客户端连接,每处理一个请求大约占用 2 个文件描述符,worker 进程的 ulimit 如果还是默认的 1024,高并发时会出 too many open files,而这个错误在错误日志里只写成 obscure 的一行,很容易被当成后端问题。

第二件是给 upstream 设一个合理的超时组合。proxy_connect_timeout 3s、proxy_read_timeout 30s、proxy_send_timeout 30s,三个都要写。只写其中一个时,其余保持默认 60 秒,遇到后端卡死会让连接挂满一分钟,前端用户早就走了。

第三件是保留一份「改动前后」的日志对照。至少在改 upstream 前后各跑一次 nginx -T 存档,并且连续观察 5 分钟的 $upstream_addr 分布。用一行 awk 就能出结果:

awk '{print $NF}' /var/log/nginx/upstream_dist.log | sort | uniq -c | sort -rn

九、小结

负载均衡的配置本身不复杂,真正难的是判断「该不该动算法」。先分清是派发问题还是后端问题,再决定用轮询、least_conn 还是哈希;权重写成相对值并配合 max_fails 与超时;会话粘性当兜底而不是设计目标;灰度和健康检查尽量交给更专业的组件;客户端 IP 与协议头必须显式透传且只信可信来源。把这五件事做对,upstream 这一段基本就不会成为故障源。

Last modification:November 2nd, 2026 at 07:24 pm

Leave a Comment