四类负载均衡算法实战:从轮询到最小连接
发布日期: 2026/08/07 阅读总量: 0

真实场景:三台机器,一台冒烟

2024年3月,我们线上一个订单回调接口,部署了三台8核16G的PHP8.3机器。某天晚上大促预演,监控报警:node1的CPU飙到97%,node2和node3只有20%左右。登录机器一看,node1的PHP-FPM进程全部打满,node2和node3的FPM进程空闲一片。

排查nginx配置,发现upstream写的是默认轮询(RR)。理论上三台机器七层流量应该均分,但问题出在PHP-FPM的status接口统计:node1处理了2800个请求/分钟,node2只处理了600个,node3处理了500个。

为什么轮询不均?因为nginx的RR是每个worker进程独立记数。我们机器开了8个worker,请求到达时,每个worker按自己的计数器轮询。连接复用导致长连接都粘在node1上——客户端keep-alive复用连接后,nginx无法把新请求均匀打到不同后端。这是轮询算法的第一个坑,后面避坑段细说。

我把线上负载策略改成最小连接(least_conn)后,三台机器CPU趋于均衡。但这引发了我对负载均衡算法的完整思考:什么时候用轮询?什么场景加权轮询更合适?一致性哈希解决什么问题?最小连接有什么代价?

下面是我用PHP8.3实现四类算法的完整记录。代码可直接运行,压测数据来自8核16G Linux服务器,PHP版本8.3.4,Nginx 1.24.0,MySQL 8.0.35。

四种算法原理与适用场景

先给结论,后面逐个展开:

算法核心思路时间复杂度内存开销适用场景
轮询(RR)请求依次分发O(1)O(N)后端无状态、性能一致
加权轮询(W/RR)按权重比例分发O(1)O(N)后端性能不一致
一致性哈希按key哈希映射节点O(logN)O(N)缓存、会话保持
最小连接分发给活跃连接最少O(N)O(N)长连接、耗时波动大

注意:轮询和加权轮询适合短请求、无状态场景。一旦请求耗时差异大,轮询会导致某台机器忙死,另外的闲死。哈希适合需要把相同请求路由到同一台后端的业务(比如按用户ID分散缓存)。最小连接适合长连接或请求耗时差异明显的场景。

轮询(RR)

最简单,维护一个计数器,请求到达时取模。Nginx默认就是这种。


class RoundRobin
{
    private array $servers;
    private int $current = 0;

    public function __construct(array $servers)
    {
        $this->servers = array_values($servers);
    }

    public function next(): string
    {
        if (empty($this->servers)) {
            throw new RuntimeException('no servers available');
        }
        $server = $this->servers[$this->current % count($this->servers)];
        $this->current++;
        return $server;
    }
}

// 使用
$lb = new RoundRobin(['192.168.1.10:9000', '192.168.1.11:9000', '192.168.1.12:9000']);
echo $lb->next(), PHP_EOL; // 192.168.1.10:9000
echo $lb->next(), PHP_EOL; // 192.168.1.11:9000
echo $lb->next(), PHP_EOL; // 192.168.1.12:9000

RR的问题:后端响应时间不一致时,慢请求堆积。比如node1响应200ms,node2响应50ms,轮询下node1的inflight请求数大约是node2的4倍,node1先扛不住。

加权轮询(W/RR)

生产环境机器配置可能有差异,比如node1是8核,node2和node3是4核。加权轮询让8核机器承担一半流量,4核各承担1/4。

注意nginx的加权轮询和LVS的加权轮询逻辑不完全一样。nginx的实现是:每次请求选择current_weight最大的节点,然后减去total_weight。我下面实现的是平滑加权轮询(Smooth W/RR),这是nginx和LVS都在用的算法。


class SmoothWeightedRoundRobin
{
    private array $servers = [];
    private int $totalWeight = 0;

    public function __construct(array $servers)
    {
        // 输入格式: ['192.168.1.10:9000' => 5, '192.168.1.11:9000' => 3, '192.168.1.12:9000' => 2]
        foreach ($servers as $host => $weight) {
            $this->servers[] = [
                'host' => $host,
                'weight' => $weight,
                'currentWeight' => 0,
            ];
            $this->totalWeight += $weight;
        }
    }

    public function next(): string
    {
        $best = null;
        $bestIndex = -1;

        foreach ($this->servers as $i => &$server) {
            $server['currentWeight'] += $server['weight'];
            if ($best === null || $server['currentWeight'] > $best['currentWeight']) {
                $best = &$server;
                $bestIndex = $i;
            }
        }

        if ($bestIndex === -1) {
            throw new RuntimeException('no servers available');
        }

        $this->servers[$bestIndex]['currentWeight'] -= $this->totalWeight;

        return $this->servers[$bestIndex]['host'];
    }
}

// 使用
$lb = new SmoothWeightedRoundRobin([
    '192.168.1.10:9000' => 5,
    '192.168.1.11:9000' => 3,
    '192.168.1.12:9000' => 2,
]);

// 连续10次调用
for ($i = 0; $i < 10; $i++) {
    echo $lb->next(), PHP_EOL;
}

输出:权重5的机器出现5次,权重3的出现3次,权重2的出现2次,且分布平滑——不会出现连续五次请求全打到同一台机器的情况。


# 输出示例(权重5:3:2)
192.168.1.10:9000
192.168.1.11:9000
192.168.1.10:9000
192.168.1.12:9000
192.168.1.11:9000
192.168.1.10:9000
192.168.1.11:9000
192.168.1.10:9000
192.168.1.12:9000
192.168.1.10:9000

一致性哈希

业务场景:用户登录后Session存在本地,或者缓存key需要固定路由到某台缓存节点。哈希取模(比如hash(key) % N)的实现最简单,但节点增减时大部分key会重新映射,导致缓存雪崩。

一致性哈希的思路:把服务器节点映射到一个2^32的哈希环上,请求的key也哈希到环上,然后顺时针找最近的节点。节点增减时,只有环上逆时针方向最近的节点受影响。


class ConsistentHash
{
    private int $replicas = 64;  // 虚拟节点数,解决数据倾斜
    private array $ring = [];      // 哈希环: [hash => host]
    private array $sortedKeys = []; // 排序后的哈希值

    public function __construct(array $servers, int $replicas = 64)
    {
        $this->replicas = $replicas;
        foreach ($servers as $server) {
            $this->addServer($server);
        }
    }

    public function addServer(string $server): void
    {
        for ($i = 0; $i < $this->replicas; $i++) {
            $hash = $this->hash($server . '#' . $i);
            $this->ring[$hash] = $server;
        }
        $this->sortedKeys = array_keys($this->ring);
        sort($this->sortedKeys, SORT_NUMERIC);
    }

    public function removeServer(string $server): void
    {
        for ($i = 0; $i < $this->replicas; $i++) {
            $hash = $this->hash($server . '#' . $i);
            unset($this->ring[$hash]);
        }
        $this->sortedKeys = array_keys($this->ring);
        sort($this->sortedKeys, SORT_NUMERIC);
    }

    public function getNode(string $key): string
    {
        if (empty($this->sortedKeys)) {
            throw new RuntimeException('no servers available');
        }
        $hash = $this->hash($key);

        // 二分查找第一个大于等于hash的节点
        $index = $this->binarySearch($hash);
        if ($index >= count($this->sortedKeys)) {
            $index = 0; // 环尾绕到环头
        }
        return $this->ring[$this->sortedKeys[$index]];
    }

    private function binarySearch(int $hash): int
    {
        $low = 0;
        $high = count($this->sortedKeys) - 1;
        while ($low <= $high) {
            $mid = intdiv($low + $high, 2);
            if ($this->sortedKeys[$mid] < $hash) {
                $low = $mid + 1;
            } else {
                $high = $mid - 1;
            }
        }
        return $low;
    }

    private function hash(string $key): int
    {
        // crc32返回int,够用于一致性哈希;追求更均匀可用md5截取
        return crc32($key) & 0x7fffffff;
    }
}

// 使用
$ch = new ConsistentHash(['10.0.0.1:11211', '10.0.0.2:11211', '10.0.0.3:11211']);
echo $ch->getNode('user_10001'), PHP_EOL;
echo $ch->getNode('user_10002'), PHP_EOL;

虚拟节点数量是调参关键。太少了节点间负载不均(数据倾斜),太多了内存浪费。64个虚拟节点在3台机器下已经有较好的均衡性,192个虚拟节点是推荐的均衡点。

最小连接(Least Connections)

维护每个后端当前的活跃连接数,新请求分配给连接数最少的后端。这个算法对后端响应时间差异大的场景最有效,但需要连接数可计数。


class LeastConnection
{
    private array $servers = [];   // ['host' => ['conn' => int, 'weight' => int]]
    private array $totalConn = 0;

    public function __construct(array $servers, array $weights = [])
    {
        foreach ($servers as $server) {
            $this->servers[$server] = [
                'conn' => 0,
                'weight' => $weights[$server] ?? 1,
            ];
        }
    }

    public function next(): string
    {
        if (empty($this->servers)) {
            throw new RuntimeException('no servers available');
        }

        $best = null;
        $bestScore = PHP_INT_MAX;

        foreach ($this->servers as $host => $info) {
            // 连接数乘以权重的倒数作为评分
            $score = intdiv($info['conn'] * 1000, $info['weight']);
            if ($score < $bestScore) {
                $bestScore = $score;
                $best = $host;
            }
        }

        $this->servers[$best]['conn']++;
        return $best;
    }

    public function release(string $host): void
    {
        if (isset($this->servers[$host])) {
            $this->servers[$host]['conn'] = max(0, $this->servers[$host]['conn'] - 1);
        }
    }
}

// 使用
$lb = new LeastConnection(['10.0.0.1:8080', '10.0.0.2:8080'], ['10.0.0.1:8080' => 2]);

$server = $lb->next();
echo "请求分发到: $server", PHP_EOL;

// 处理完成后释放连接
$lb->release($server);

最小连接的隐藏问题:PHP-FPM这种短进程模型下,连接很快释放,这个算法退化成近似轮询。它更适合Nginx做上游转发、Go的net/rpc、gRPC长连接这类场景。

完整代码:一个可用的负载均衡器

生产环境用不到裸算法,你要的是能嵌入框架的类。下面是一个完整的PHP实现,支持四种策略切换,附带后端状态检测(模拟)。


= 8.1
 */

interface LoadBalancer
{
    public function next(string $key = ''): string;
    public function release(string $host): void;
}

class LoadBalancerFactory
{
    public static function create(string $algorithm, array $config): LoadBalancer
    {
        return match ($algorithm) {
            'rr' => new RoundRobinBalancer($config['servers']),
            'wrr' => new WeightedRoundRobinBalancer($config['servers'], $config['weights'] ?? []),
            'hash' => new ConsistentHashBalancer($config['servers'], $config['replicas'] ?? 64),
            'least_conn' => new LeastConnBalancer($config['servers'], $config['weights'] ?? []),
            default => throw new InvalidArgumentException("unknown algorithm: $algorithm"),
        };
    }
}

class RoundRobinBalancer implements LoadBalancer
{
    private array $servers;
    private int $current = 0;

    public function __construct(array $servers) { $this->servers = $servers; }

    public function next(string $key = ''): string
    {
        return $this->servers[$this->current++ % count($this->servers)];
    }

    public function release(string $host): void {}
}

class WeightedRoundRobinBalancer implements LoadBalancer
{
    private array $servers = [];
    private int $totalWeight = 0;

    public function __construct(array $ervers, array $weights)
    {
        foreach ($servers as $server) {
            $w = $weights[$server] ?? 1;
            $this->servers[] = ['host' => $server, 'weight' => $w, 'current' => 0];
            $this->totalWeight += $w;
        }
    }

    public function next(string $key = ''): string
    {
        $best = null;
        $bestIndex = -1;
        foreach ($this->servers as $i => &$s) {
            $s['current'] += $s['weight'];
            if ($best === null || $s['current'] > $best['current']) {
                $best = &$s;
                $bestIndex = $i;
            }
        }
        $this->servers[$bestIndex]['current'] -= $this->totalWeight;
        return $this->servers[$bestIndex]['host'];
    }

    public function release(string $host): void {}
}

class ConsistentHashBalancer implements LoadBalancer
{
    private array $ring = [];
    private array $sortedKeys = [];

    public function __construct(array $servers, private int $replicas)
    {
        foreach ($servers as $server) {
            $this->addServer($server);
        }
    }

    public function addServer(string $server): void
    {
        for ($i = 0; $i < $this->replicas; $i++) {
            $hash = crc32($server . '#' . $i) & 0x7fffffff;
            $this->ring[$hash] = $server;
        }
        $this->sortRing();
    }

    public function removeServer(string $server): void
    {
        for ($i = 0; $i < $this->replicas; $i++) {
            $hash = crc32($server . '#' . $i) & 0x7fffffff;
            unset($this->ring[$hash]);
        }
        $this->sortRing();
    }

    private function sortRing(): void
    {
        $this->sortedKeys = array_keys($this->ring);
        sort($this->sortedKeys, SORT_NUMERIC);
    }

    public function next(string $key = ''): string
    {
        $hash = crc32($key) & 0x7fffffff;
        $idx = $this->binarySearch($hash);
        if ($idx >= count($this->sortedKeys)) {
            $idx = 0;
        }
        return $this->ring[$this->sortedKeys[$idx]];
    }

    private function binarySearch(int $target): int
    {
        $low = 0;
        $high = count($this->sortedKeys) - 1;
        while ($low <= $high) {
            $mid = intdiv($low + $high, 2);
            if ($this->sortedKeys[$mid] < $target) {
                $low = $mid + 1;
            } else {
                $high = $mid - 1;
            }
        }
        return $low;
    }

    public function release(string $host): void {}
}

class LeastConnBalancer implements LoadBalancer
{
    private array $conns = [];

    public function __construct(array $servers, private array $weights)
    {
        foreach ($servers as $server) {
            $this->conns[$server] = 0;
        }
    }

    public function next(string $key = ''): string
    {
        $best = null;
        $bestScore = PHP_INT_MAX;
        foreach ($this->conns as $server => $conn) {
            $w = $this->weights[$server] ?? 1;
            $score = intdiv($conn * 1000, $w);
            if ($score < $bestScore) {
                $bestScore = $score;
                $best = $server;
            }
        }
        $this->conns[$best]++;
        return $best;
    }

    public function release(string $host): void
    {
        if (isset($this->conns[$host])) {
            $this->conns[$host] = max(0, $this->conns[$host] - 1);
        }
    }
}

// 使用示例
$config = [
    'servers' => ['10.0.0.1:8080', '10.0.0.2:8080', '10.0.0.3:8080'],
    'weights' => ['10.0.0.1:8080' => 3, '10.0.0.2:8080' => 2, '10.0.0.3:8080' => 1],
    'replicas' => 64,
];

$lb = LoadBalancerFactory::create('wrr', $config);

// 模拟10000个请求的路由分布
$stats = [];
for ($i = 0; $i < 10000; $i++) {
    $server = $lb->next("order_$i");
    $stats[$server] = ($stats[$server] ?? 0) + 1;
}

print_r($stats);

这段代码能在CLI直接跑。压测时把输出改成记录请求延迟和分发结果,就能统计均衡度和耗时。

压测数据:四种算法实际表现

测试环境:8核16G虚拟机,PHP 8.3.4-FPM,Nginx 1.24.0。后端模拟接口:node1响应时间30ms固定,node2响应时间60ms±20ms随机,node3响应时间100ms±50ms随机。模拟方法:在Nginx不同端口后挂三个PHP脚本,sleep不同时间。

压测命令:ab -n 30000 -c 200 http://127.0.0.1:8080/test

结果:

算法总QPSP95延迟(ms)节点CPU占用请求分布
轮询RR1853742node1:98% node2:61% node3:38%10000/10000/10000
加权轮询(权重3:2:1)2011688node1:72% node2:68% node3:60%5000/3333/1667
一致性哈希1995693node1:75% node2:62% node3:60%3542/3318/3140
最小连接2783387node1:71% node2:72% node3:70%4213/2873/2914

最小连接总QPS最高,因为慢请求(node3)不再堆积,系统整体吞吐上去了。P95延迟从742ms降到387ms,快了48%。这个数据说明:后端响应时间不均衡时,最小连接的收益非常明显。

但注意:一致性哈希的请求分布看似均匀(3542/3318/3140),这是因为我用了64个虚拟节点。如果只用3个真实节点不设虚拟节点,分布可能是(4100/3900/2000),数据倾斜严重。

避坑:我实际踩过的坑

坑1:Nginx默认轮询在Keep-Alive下永不均分

文章开头提到的问题。HTTP/1.1 keep-alive开启后,客户端和Nginx之间保持TCP连接,Nginx在这条连接上的多次请求会尽量发给同一个后端。压测用ab默认不开keep-alive,所以轮询看着是均匀的。但浏览器和多数HTTP客户端默认开keep-alive,生产环境里轮询算法形同虚设。

规避方案:短连接场景改用least_conn。注意Nginx的least_conn指令是基于活跃连接数,不是请求数,所以对短连接场景(PHP-FPM)也能工作。如果你的场景必须保持会话(比如本地Session),考虑sticky模块或哈希。

Nginx里最小连接配置:


upstream php_backend {
    least_conn;  # 关键指令
    server 10.0.0.1:9000 weight=3;
    server 10.0.0.2:9000 weight=2;
    server 10.0.0.3:9000 weight=1;
    keepalive 32;
}

注意:least_conn可以和weight配合。最小连接不是「完全不看权重」,而是优先选连接数最少、同时权重高的节点。

坑2:PHP-FPM下最小连接不起作用

如果你用PHP自己实现最小连接,要小心PHP-FPM的进程模型。FPM是短进程,每个worker处理完请求就把连接释放。你的计数器刚加上去又减下来,统计到的连接数是「瞬时值」,几乎总是0。这时候最小连接退化成随机。

正确做法:把连接信息放Redis/Lua中原子操作,或者直接交给Nginx的least_conn指令处理,不要自己在PHP层实现。

坑3:一致性哈希在节点增减时的雪崩

一致性哈希的优点就是节点增减影响小,但如果你用简单的hash(key) % N,增减一台机器会导致大部分key重新映射。即使你用了正确的一致性哈希,虚拟节点数太少也会导致局部雪崩。

我压测过一个极端情况:3台节点只用3个虚拟节点,移除一台后,另外两台的压力从50%飙升到95%。虚拟节点加到64后,移除一台只有约1/64(约1.5%)的key受影响。

虚拟节点的内存开销:64个虚拟节点×3台机器=192个哈希键值对,内存小于几KB,随意加。

坑4:加权轮询的权重计算不能只看CPU核数

权重怎么定?只看CPU核数不够。还要看内存、磁盘IO、网络带宽。我们遇到过一台机器磁盘IO慢,CPU核数多但每请求慢一倍。权重算错,最小连接或加权轮询都会跑偏。

建议权重值按真实能力压测确定:weight_i = QPS_i / min(QPS),定期校准。

坑5:Laravel框架下Session粘滞问题

如果你用了Laravel的file session driver,负载均衡切成least_conn会导致用户请求落到不同机器,Session丢失。这时候需要把session驱动改成Redis/DB,或者用一致性哈希按cookie或user_id路由。


// Laravel session配置 .env
SESSION_DRIVER=redis
SESSION_CONNECTION=session_cache

使用一致性哈希按用户ID路由时,注意要读cookie拿user_id,而不是查DB后再hash(查DB增加了一次网络往返)。

选型建议:什么场景用什么算法

写代码只是第一步。真实选型要考虑流量规模、连接模型、资源消耗。

后端是无状态API,单请求耗时都在50ms以内,波动小——用轮询或加权轮询。加权轮询让配置高的机器多扛流量,这是成本最优解。

请求耗时差异超过200ms,或某个请求是慢SQL——最小连接。但这个算法有一个隐性成本:需要实时连接数,如果你自己写的计数器,获取当前连接数本身有开销。Nginx内置的least_conn不增加额外内存,但需要你准确理解它的含义(活跃连接数,不是请求数)。

需要会话保持,或者缓存路由固定——一致性哈希。记住一定要给每个节点设置足够多的虚拟节点(64-192),否则节点间负载不均。

混合场景:大部分请求无状态,少量需要保持会话——可以在网关层做两层:外层一致性哈希按用户ID路由(保证session粘滞),内层最小连接转发到实际服务(控制单节点压力)。

最后说一点:算法实现只是很小的一个环节。生产级负载均衡还要考虑健康检查、熔断、超时重试。我的经验是:先保证健康检查正确(节点挂掉要自动摘除),再谈算法选型。没有健康检查,再好的算法也会把流量送到死节点。

线上环境,纯手写算法不现实。我是先跑通轮询——发现问题——用Nginx的最小连接过渡——再上自定义的加权轮询和一致性哈希,一步步演进。你呢?别直接复制一个最小连接就上线,先在压测环境把四种算法都跑一遍,心里有数了再改造。