轮询算法:从普通轮询到平滑加权轮询

轮询算法,说白了就是「大家轮流来」。

在负载均衡里,它是最朴素的分配策略。请求排成队,服务器排成圈,一个挨一个分下去。我早期做网关选型时,第一个接触的就是它——简单,但坑也不少。

这一章我们拆开揉碎了讲。从普通轮询讲到加权轮询,再讲到平滑加权轮询。你跟着走一遍,不仅能写代码,还能明白为什么 Nginx 选了最后那种。

轮询算法分类与演进 负载均衡轮询算法 普通轮询 加权轮询 平滑加权轮询 核心特点 按顺序循环分配 适合同配置服务器 简单,无状态 核心特点 按权重比例分配 支持异构服务器 可能出现请求倾斜 核心特点 动态调整当前权重 分配平滑无倾斜 Nginx 默认算法 演进路线:简单 → 异构支持 → 平滑分配 每一步解决上一步的痛点

普通轮询:最原始的「转圈圈」

普通轮询的逻辑,你闭上眼睛都能想出来。

服务器列表排好队,请求来了按顺序发。发完一圈回到开头,继续循环。实现就是一个计数器取模:

// 普通轮询 —— 核心就这一行
int index = counter.getAndIncrement() % serverList.size();
return serverList.get(index);

我早期做一个内部管理系统时就用的这个。三台服务器配置一模一样,跑起来挺稳。但后来加了一台新机器,配置是老机器的两倍——问题来了。

普通轮询不管服务器性能差异,每台分到的请求数一样。如果某台机器慢,就会拖慢整体响应。你想想看,一个请求队列里,慢机器处理一个请求的时间,快机器能处理两个。结果就是快机器闲着等慢机器。

所以普通轮询的适用场景很窄:所有服务器规格完全相同,且处理能力稳定。但凡有点差异,它就不太行了。

核心结论:普通轮询适合同构集群,简单但「看不见」服务器差异。

加权轮询:给服务器贴上「能力标签」

加权轮询就是为了解决上面那个问题。给每台服务器配一个权重,性能好的权重高,分到的请求就多。

实现思路也不复杂。把权重展开成虚拟节点列表,再轮询:

// 假设服务器A权重3,服务器B权重1,服务器C权重1
// 虚拟列表 = [A, A, A, B, C]
// 然后在这个列表上做普通轮询

这样A拿到的请求是B和C的三倍。听起来很合理对吧?

但有个隐藏问题。你注意看上面的虚拟列表——A连续出现了三次。这意味着在真实流量中,A服务器会连续收到三个请求,然后才轮到B和C。

这就是请求倾斜。短时间内大量请求涌向同一台机器,虽然总量是对的,但流量不平滑。我在一个电商项目中就踩过这个坑——权重配了5:1,结果高权重机器在高峰时段的CPU直接飙到90%,低权重机器却只有30%。

为什么?因为连续的请求突发,把高权重机器的连接池打满了。

注意:加权轮询虽然支持了异构服务器,但「连续分配」的特性可能导致局部流量倾斜。权重差距越大,倾斜越明显。

平滑加权轮询:Nginx 的优雅解法

这个算法是 Nginx upstream 模块默认使用的轮询方式。我第一次看到它的实现时,觉得真是巧妙——用很小的计算代价,换来了非常平滑的分配效果。

算法核心就三个变量:

  • weight:固定权重,配好后不变
  • current_weight:当前权重,动态变化
  • total_weight:所有服务器 weight 之和

每次选一台服务器的过程分三步:

  1. 每台服务器的 current_weight 加上自己的 weight
  2. 选出 current_weight 最大的那台
  3. 被选中的服务器 current_weight 减去 total_weight

光看文字有点绕,我们直接上代码:

class SmoothServer {
    String name;
    int weight;          // 固定权重
    int currentWeight;   // 当前权重

    public SmoothServer(String name, int weight) {
        this.name = name;
        this.weight = weight;
        this.currentWeight = 0;
    }
}

public SmoothServer select(List<SmoothServer> servers) {
    int totalWeight = 0;
    SmoothServer best = null;

    for (SmoothServer s : servers) {
        s.currentWeight += s.weight;
        totalWeight += s.weight;

        if (best == null || s.currentWeight > best.currentWeight) {
            best = s;
        }
    }

    best.currentWeight -= totalWeight;
    return best;
}

这段代码如果你第一次看,可能会觉得「为什么 currentWeight 要减 totalWeight」?

我举个例子你就明白了。假设两台服务器,权重比是 3:1。手动跑几轮:

轮次 A 的 currentWeight B 的 currentWeight 选中结果
初始00—
第1轮+3 → 3+1 → 1A(3-4=-1)
第2轮-1+3 → 21+1 → 2A(2-4=-2)
第3轮-2+3 → 12+1 → 3B(3-4=-1)
第4轮1+3 → 4-1+1 → 0A(4-4=0)

看到没?分配序列是 A, A, B, A。没有连续的三个A,而是穿插着来。整个分配过程非常平滑。

我个人比较推崇这个算法。它没有增加多少计算量,却完美解决了加权轮询的请求倾斜问题。Nginx 选它作为默认算法,不是没有道理的。

小提示:平滑加权轮询的数学本质,是在保证权重比例的前提下,让高权重服务器的请求在时间轴上尽量「分散」开。

三种算法的对比与选型

我把它们的核心差异整理成了一张表。你实际选型时对着看就行:

维度 普通轮询 加权轮询 平滑加权轮询
分配策略 完全平均 按权重比例 按权重比例 + 平滑
适用集群 同构 异构 异构
请求倾斜风险 无 有(权重差距大时明显) 无
实现复杂度 低 中 中
典型代表 早期 Nginx 默认 LVS 加权调度 Nginx upstream

选型其实很简单:

  • 服务器都一样 → 普通轮询,够用
  • 服务器有差异,流量不大 → 加权轮询,简单
  • 服务器有差异,流量大/要求稳定 → 平滑加权轮询,首选

避坑指南

讲几个我实际踩过的坑,你遇到了能少走弯路。

坑一:权重配比与流量的非线性关系

我曾经把一台机器的权重配成10,另一台配成1。心想10倍流量没问题。结果高权重机器先扛不住了。为什么?因为权重10意味着它经常被连续选中,连接池瞬间被打满。

后来我换成了平滑加权轮询,同样10:1的权重,分配变得均匀,高权重机器的CPU峰值降了40%。

坑二:动态权重调整时的「抖动」

有些场景需要根据服务器负载动态调整权重。如果你在平滑加权轮询中直接改 weight 值,会导致 current_weight 和新的 weight 不匹配,出现短暂的分配抖动。

我建议的做法是:先停止当前节点的流量,等 current_weight 归零后再调整权重,然后重新上线。

坑三:服务器宕机后的权重残留

如果一台服务器宕机了,但权重信息没清理,current_weight 还保留着。等它恢复上线时,可能因为累积的 current_weight 过高,刚上线就被「猛灌」一波请求。

解决办法:服务器重新上线时,把它的 current_weight 重置为0。

一句话总结:普通轮询看均等,加权轮询看比例,平滑加权轮询看节奏。理解了这三个层次的演进,你就能在项目中选对算法、用好算法。