前几天看到一个博文,介绍 SWRR 负载均衡算法,配合权重之后,可以精确实现轮询功能。其中算法实现逻辑不算复杂,但是背后的数学证明却极其复杂。而 SWRR 算法是由 Nginx 首次引入实现,因此作者又专门研究了一个 Nginx 的负载均衡。
Nginx 里真正内置的开源负载均衡算法,只有 6 种。加第三方模块凑到 7 种,商业版再多一种。
但问题是,大部分人只会用 proxy_pass 转发一下,连默认算法背后长什么样都不知道。出了问题就换 least_conn,再不行换 ip_hash,像拆盲盒。
这篇文章我翻了一遍 Nginx 的 upstream 源码,把每种算法的核心逻辑拆出来。不写"建议多关注性能"这种废话,直接上代码。
先看全景:这些算法到底谁是谁
在聊源码之前,先把这些算法的位置搞清楚。我按功能分了三组:
无状态分发组——每次请求独立选后端,不记状态。Round Robin、Random、Least Connections、Least Time 都在这一组。适合后端无状态、横向扩展的场景。
会话保持组——同一个客户端/请求尽量落到同一台后端。IP Hash、Generic Hash、Consistent Hash。适合有缓存亲和性需求的场景。
第三方补充组——Fair 模块。开源的 Least Time 平替,按响应时间选后端。维护状态不稳定,这几年用得少了。
好,分组完毕。下面逐个拆算法,从默认的 Round Robin 开始。
Round Robin:不是"轮着来"那么简单
你以为是逐个轮着来——A、B、C、A、B、C。实际上 Nginx 用的叫平滑加权轮询(Smooth Weighted Round Robin),英文简称 SWRR。
核心代码在 src/http/ngx_http_upstream_round_robin.c。每个后端 peer 维护三个关键变量:
struct ngx_http_upstream_rr_peer_s {
ngx_int_t current_weight; // 当前权重
ngx_int_t effective_weight; // 有效权重
ngx_int_t weight; // 配置权重
};选后端的逻辑,精简下来就四步:
1. 遍历所有 peer
current_weight += effective_weight
total += effective_weight
2. 选 current_weight 最大的 peer
3. 选中 peer 的 current_weight -= total
4. 如果 peer 挂了,effective_weight--,等冷却恢复举个例子。假设后端 A 权重 5,B 权重 1,C 权重 1。初始化时 effective_weight = weight,current_weight = 0。
第一轮:A 的 current 变成 5,B 变 1,C 变 1。选 A。然后 A 的 current 减去 total(7),变成 -2。
第二轮:A: -2+5=3,B: 1+1=2,C: 1+1=2。还是选 A。A 减 7 剩 -4。
第三轮:A: -4+5=1,B: 2+1=3,C: 2+1=3。选 B。
你看,7 次请求下来,A 被选中 5 次,B 和 C 各 1 次。而且分布均匀,不会出现连续 5 次全打 A 的情况——这就是"平滑"的含义。
**容错怎么做的?**很有意思。effective_weight 不是固定的——每次 max_fails 次失败后,effective_weight 往下掉 weight / max_fails,冷却期过了再逐次 +1 慢慢抬回来。相当于服务器越挂越不被信任,信任恢复需要时间。
IP Hash:留下 1/4 的 IP 信息,故意的
IP Hash 的代码在 src/http/modules/ngx_http_upstream_ip_hash_module.c。哈希函数本身简单粗暴:
hash = 89; // 初始种子
for (i = 0; i < addrlen; i++) {
hash = (hash * 113 + addr[i]) % 6271;
}但有一个细节很多人不知道——IPv4 地址只取了前 3 个字节做哈希:
case AF_INET:
sin = (struct sockaddr_in *) r->connection->sockaddr;
iphp->addr = (u_char *) &sin->sin_addr.s_addr;
iphp->addrlen = 3; // ← 不是 4!是 3!
break;这意味着 192.168.1.10 和 192.168.1.200 算出来的 hash 是一模一样的,因为最后一位 .10 和 .200 根本没参与计算。
这不是 bug。Nginx 的设计意图是让同一个 C 类子网内的客户端落到同一台后端,减少因为 DHCP 换 IP 导致的会话丢失。但说实话,在今天这个移动互联网时代,这逻辑已经有点过时了——大部分客户端 IP 根本不是同一个子网的。
建议:如果你真的要按 IP 做会话保持,用 hash $remote_addr 替代 ip_hash。它用的是完整 IP,还支持 consistent 参数做一致性哈希,扩容影响小很多。
另外,IP Hash 还有一个保底机制:连续 20 次选出来的后端都不可用(挂了/被摘了),直接退化到 Round Robin。这个 20 次限制在几乎所有算法里都有,防止死循环。
Consistent Hash:160 个虚拟节点是怎么来的
代码在 src/http/modules/ngx_http_upstream_hash_module.c。普通 hash 指令和 hash consistent 是同一个模块,区别只在初始化函数。
一致性哈希最关键的数据结构是哈希环上的点:
typedefstruct {
uint32_t hash; // CRC32 哈希值
ngx_str_t *server; // 指向 server 名称
} ngx_http_upstream_chash_point_t;构造哈希环时,每个 weight 产生 160 个虚拟节点。这是硬编码在源码里的,不能改:
npoints = peer->weight * 160;为什么是 160?太少,环上节点分布不均匀,容易数据倾斜;太多,二分查找的开销变大。160 是 Nginx 团队权衡出来的经验值,实测在大部分场景下负载偏差控制在 5% 以内。
构造过程值得一说。最初版本的实现是逐个插入 + 排序,复杂度 O(n²)。当 upstream 里有几十台机器、总 weight 很大时,Nginx 启动可能要卡 5 秒。2015 年 Roman Arutyunyan 提交了一个优化——改成全部插入后一次性 qsort + 去重,复杂度降到 O(n log n)。同样的配置,启动从 5 秒降到 40 毫秒。
查找一个请求应该打到哪台机器也很直接:
1. CRC32 算请求 key 的 hash 值
2. 二分查找环上第一个 >= hash 值的点
3. 取该点对应的 server
4. 如果 server 不可用 → 顺环找下一个
5. 超过 20 次还不行 → 退化 Round Robin这就是一致性哈希的"一致性":增删服务器时,只有相邻的 hash 区间受影响,其他区间保持不变。缓存集群扩容时特别有用——不用全部 cache 失效重新预热。
Least Connections:用乘法代替除法的优雅
代码在 src/http/modules/ngx_http_upstream_least_conn_module.c。核心比较逻辑就一行:
if (best == NULL
|| peer->conns * best->weight < best->conns * peer->weight)
{
best = peer;
}这行代码在比较什么?转化一下就清楚了:
peer->conns * best->weight < best->conns * peer->weight
等价于
peer->conns / peer->weight < best->conns / best->weight
等价于
比较"加权连接数":conns ÷ weight之所以用乘法不用除法,是怕浮点运算在 C 里开销大,而且整数乘法保证精确。把除法两边交叉相乘,结果等价但全是 int 运算——这就是写 C 的人的肌肉记忆。
但问题来了:如果两台机器的加权连接数一模一样怎么办?Least Connections 的处理是退回到 Smooth Weighted Round Robin。也就是说,least_conn 在"连接数差不多"的情况下,行为等价于加权轮询。
还有一个容易被忽略的配置项:max_conns。配合 least_conn 使用可以给每台后端设连接上限,防止某台机器被长连接撑爆。但要生效必须加上 zone 指令用共享内存跨 worker 同步,否则每个 worker 各算各的,连接数根本不准确。
Random:被严重低估的随机算法
Nginx 的 random 算法从 1.15.1 版本开始可用。实现上构建了一个权重累加的有序数组,用二分查找定位随机命中的后端:
1. 生成随机数 r ∈ [0, total_weight)
2. 二分查找累加权重数组中第一个 > r 的位置
3. 返回对应 server时间复杂度 O(log n),比 Round Robin 的 O(n) 更快。
但真正的亮点是 random two 模式:
upstream backend {
random two least_conn;
server 10.0.0.1:8080 weight=3;
server 10.0.0.2:8080 weight=1;
server 10.0.0.3:8080 weight=1;
}逻辑是:随机挑 2 台,然后用 least_conn 选其中连接数更少的那台。这比纯 least_conn 扫描全部服务器快得多,同时保留了连接数感知能力。Google 的 Maglev 负载均衡器用的也是类似思路——两个随机选择 + 比较。
一张表收尾
least_conn | |||
random | |||
random two least_conn | |||
ip_hash | |||
hash $var | |||
hash $var consistent | |||
least_time | 仅商业版 | ||
fair |
复杂度:Round Robin / Least Conn / IP Hash / Least Time 为 O(n);Random / Consistent Hash 为 O(log n)。会话保持仅 Hash 类算法支持。所有算法失效时统一退化到 Round Robin。
所有算法都有一个共同底线:失效时退化到 Round Robin。这不是 Round Robin 有多好,而是它足够简单、容错机制最完备,当备胎再合适不过。
选型上没有银弹。你那个"全公司共用一套配置"的 nginx upstream,大概率只用了默认的 round robin。如果把长连接的服务、需要缓存亲和性的服务、纯无状态 API 全混在一个 upstream 里,选什么算法都不好使。
拆开、分场景、针对性配。这才是负载均衡的正确打开方式。
夜雨聆风