今天不整那些虚头巴脑的概念,直接聊一个生产环境里很常用的负载均衡策略。后端服务通常不止一台,用户请求进来后,得有个“门卫”决定把请求派给哪台机器。最简单的办法是轮流来,但这有个问题:有的请求很快,有的请求很慢,轮流派会导致慢请求堆积在某一台机器上。于是就有了这么个思路:谁手上正在处理的请求少,就把新请求派给谁。这个思路听着特别合理,但真要把“谁手上少”这件事统计准,尤其是在高并发下,坑比想象中多得多。

一、先从生活场景理解最少连接

1.1 饭馆里的“最闲服务员”

假设你开了一家小饭馆,请了三个服务员。客人来了,你自然会优先把客人安排给正在忙活最少的那个人。这就需要你随时掌握每个人手上还有几桌客人。这个“正在忙活的数量”就是连接数;安排客人就是分配请求;客人吃完结账走人,这个人的忙活数就减一。负载均衡器要做的,就是不断回答一个问题:此刻哪个服务员最闲?

1.2 为什么轮询不如它聪明

轮询是“一号、二号、三号、一号、二号、三号”这么转圈,完全不看每个人手上的工作量。如果一号服务员的客人特别能吃,点了十个菜,二号服务员那边几乎没客人,轮询还是会往一号派,导致一号忙死。最少连接不一样,它每次都会挑最闲的那个,做到“能者多劳”。这在后端接口耗时差异很大的场景下,优势非常明显。

二、连接统计逻辑的最初形态

2.1 最直白的思路:Map加数字

用程序实现的话,最直白的方法就是用一个Map。key是服务器地址,value是当前连接数。每次请求来,遍历这个Map,找出value最小的key,把请求发给它,然后给这个key的value加一。请求处理完,再减一。逻辑清晰,代码也好写,但它真的能扛住线上流量吗?我们来看看第一版代码。

2.2 第一版代码:能跑但不够强壮

// 技术栈:Java 8+

import java.util.HashMap;
import java.util.Map;

/**
 * 第一版负载均衡器:用HashMap存储连接数
 *
 * 设计思路:
 *  - 用Map保存每台服务器的“当前连接数”
 *  - 每次请求时,遍历Map找出连接数最少的服务器
 *  - 选中后把连接数加一
 *  - 请求结束时把连接数减一
 */
public class SimpleLoadBalancer {

    // key:服务器IP,value:当前连接数
    private final Map<String, Integer> connectionCount = new HashMap<>();

    // 初始化三台后端服务器
    public SimpleLoadBalancer() {
        connectionCount.put("192.168.1.1", 0);
        connectionCount.put("192.168.1.2", 0);
        connectionCount.put("192.168.1.3", 0);
    }

    /**
     * 返回当前连接数最少的服务器IP
     */
    public String getServer() {
        String bestIp = null;
        int minCount = Integer.MAX_VALUE;

        // 1. 遍历所有服务器,找出连接数最少的
        for (Map.Entry<String, Integer> entry : connectionCount.entrySet()) {
            if (entry.getValue() < minCount) {
                minCount = entry.getValue();
                bestIp = entry.getKey();
            }
        }

        // 2. 把该服务器的连接数加一,表示多了一个请求
        connectionCount.put(bestIp, minCount + 1);
        return bestIp;
    }

    /**
     * 请求处理完毕后,将对应服务器的连接数减一
     */
    public void release(String ip) {
        // 先取出来,减一,再放回去
        Integer current = connectionCount.get(ip);
        connectionCount.put(ip, current - 1);
    }
}

2.3 这版代码在并发下会出什么问题

这代码在单线程下完全没问题,但线上服务一定是多线程同时处理请求。两个人同时操作同一个数字,你加我也加,最后结果就少加了。更麻烦的是,HashMap本身不是线程安全的,多个线程同时触发扩容,可能导致CPU 100%甚至死循环。还有,读和改中间隔着好几步,容易出现好几个请求同时挑中同一台服务器,导致它瞬间被压垮。这些坑,我们在下一章展开说。

三、高并发下的三个典型大坑

3.1 坑一:数字更新不是原子操作

在Java里,connectionCount.get(ip) + 1 实际上是“读取、计算、写入”三步。多线程时,线程A读到5,线程B也读到5,A加一写成6,B加一也写成6,可实际上总共来了两个请求,应该变成7。这叫丢失更新。丢失的更新多了,连接数统计就不准,负载均衡就变成了盲人摸象。

3.2 坑二:选择和更新之间存在空档

在getServer方法里,我们先是遍历拿到最小值,然后再做加一。这两步之间可能隔了几微秒,但高并发下这几微秒足以让成百上千的请求同时看到同一个“最闲”服务器。于是它们全挤过去,那台服务器瞬间被压垮,其他服务器却闲着。这就是经典的“惊群”现象。

3.3 坑三:一加锁就全卡住

有的同学说,那给getServer方法加上synchronized不就行了?问题是,加锁后,所有请求分派都得排队,负载均衡器的处理能力就变成了瓶颈。本来你是想解决后端服务器的负载不均,结果负载均衡器自己先累趴下了。而且锁还会引入线程阻塞、上下文切换,忙碌时系统吞吐量掉得厉害。

四、让统计在并发下依然准确

4.1 用AtomicInteger代替Integer

Java的java.util.concurrent.atomic包里提供了AtomicInteger,它的incrementAndGet方法在硬件层面保证“读、加、写”是一个不可分割的操作。我们用它来代替Integer,就不会再丢更新。简单来说,这是一个自带“原子性”的计数器,多线程去加它,结果一定等于实际请求数。

4.2 用ConcurrentHashMap代替HashMap

ConcurrentHashMap这个类,内部把数据分成一段一段,不同线程操作不同的key基本不用互相等待,比给整个HashMap加锁要快得多。而且它不会出现扩容死循环的问题。对于负载均衡器这种“遍历多、修改多”的场景,它是非常合适的容器。

4.3 第二版代码:并发安全的负载均衡器

// 技术栈:Java 8+

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 第二版负载均衡器:使用ConcurrentHashMap + AtomicInteger
 *
 * 相比第一版的变化:
 *  1. Map使用并发容器,避免结构上的线程安全问题
 *  2. 连接数使用AtomicInteger,保障增减过程的原子性
 */
public class ConcurrentLoadBalancer {

    // Node代表一台后端服务器
    static class Node {
        final String ip;
        final AtomicInteger connections = new AtomicInteger(0);

        Node(String ip) {
            this.ip = ip;
        }
    }

    // 使用ConcurrentHashMap存储节点
    private final ConcurrentHashMap<String, Node> nodes = new ConcurrentHashMap<>();

    public ConcurrentLoadBalancer(String[] ips) {
        for (String ip : ips) {
            nodes.put(ip, new Node(ip));
        }
    }

    /**
     * 分配请求:返回连接数最少的服务器IP
     * 注意:这里仍然存在多个线程同时选中同一个节点的可能,
     * 但连接数不会丢,因此长期来看是均衡的。
     */
    public String getServer() {
        Node best = null;
        int min = Integer.MAX_VALUE;

        // 遍历节点,找出连接数最少的
        for (Node node : nodes.values()) {
            int count = node.connections.get(); // 原子读
            if (count < min) {
                min = count;
                best = node;
            }
        }

        // 原子加一,并返回IP
        if (best != null) {
            best.connections.incrementAndGet();
            return best.ip;
        }
        return null;
    }

    /**
     * 请求处理完毕,连接数减一
     */
    public void release(String ip) {
        Node node = nodes.get(ip);
        if (node != null) {
            node.connections.decrementAndGet(); // 原子减一
        }
    }

    // 一个简单的演示
    public static void main(String[] args) throws Exception {
        ConcurrentLoadBalancer lb = new ConcurrentLoadBalancer(new String[]{
            "192.168.1.1", "192.168.1.2", "192.168.1.3"
        });

        // 模拟10个并发请求
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                String ip = lb.getServer();
                System.out.println(Thread.currentThread().getName() + " 被分配到 " + ip);
            });
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }
}

这个版本已经能解决“连接数统计不准”的问题。但正如注释里说的,瞬时高并发时依然可能多个请求同时选中同一个节点。为了缓解这个问题,可以在选择时加一点随机抖动。

4.4 加一点随机抖动,减少瞬时倾斜

抖动的意思是:比较的时候,不只看真实连接数,而是给每个节点加上一个非常小的随机数再比较。这样,当多台机器的连接数相同时,大家不会整齐划一地扑向同一个节点。这个技巧实现起来特别简单,代价也很小。

// 技术栈:Java 8+

// 假设在某个方法内部,遍历节点时使用加抖动后的分值来比较
private Node chooseWithJitter() {
    Node best = null;
    double bestScore = Double.MAX_VALUE;

    for (Node node : nodes.values()) {
        // 真实连接数 + 0到1之间的随机抖动
        double score = node.connections.get() + Math.random();
        if (score < bestScore) {
            bestScore = score;
            best = node;
        }
    }
    return best;
}

注意,抖动值不能太大,否则会掩盖真实的忙闲程度,影响长期均衡。一般控制在0到1之间就够了。

4.5 别忘了配合健康检查和超时清理

连接数只是“数量”,不是“质量”。有的连接虽然建立了,但对方已经卡死,占着茅坑不拉屎。所以最少连接算法还需要配合健康检查:定期探活,把挂掉的服务器从列表里摘出去。另外,连接空闲时间过长的也要及时清理,否则统计表里堆着一堆僵尸连接,均衡器就会把请求派给那些实际上并不空闲的机器。

五、关联技术:看看常用的Nginx怎么玩

5.1 Nginx的least_conn

Nginx的负载均衡模块里,有个指令叫least_conn,用的就是最小连接策略。它的实现思路跟我们上面写的大同小异,但它是在事件驱动模型里做的,通过共享内存和原子操作维护每个上游的连接数,不会因为多进程而统计错乱。它也没有用全局锁,而是让每个工作进程在各自的事件循环里完成“读计数、选最小、加计数”。由于Nginx本身是单线程处理请求,天然避开了很多并发修改问题。

5.2 对你的自研网关有什么启发

我们自己在Java里做网关时,可以参考Nginx的思路:尽量减少临界区的范围;尽可能用原子变量而不是锁;把“选节点”和“更新计数”压缩到尽量少的指令里。如果后端机器数量不多,遍历成本很低,完全可以用第二版方案。如果机器非常多,可以改用“最小堆”结构来维护,但那又涉及到堆的并发修改,复杂度会上升。大多数场景下,简单遍历加原子变量已经够用了。

六、应用场景、优缺点和注意事项

6.1 适合用在哪些地方

  • 后端接口耗时差异很大的情况。比如有的接口查缓存,1毫秒返回;有的接口查大数据库,1秒返回。轮询会把慢请求分散到不同机器,导致某台机器因为处理慢请求而堆积;最少连接则能把新请求导向当前最闲的机器。
  • 长连接场景。比如WebSocket、消息推送,连接一旦建立就会持续占用,连接数能很直接地反映服务器压力。
  • 需要快速响应的网关层。在API网关里实现最少连接,可以提升整体资源的利用率。

6.2 优点有哪些

  • 实现简单,不依赖复杂的数学公式。
  • 能够应对请求处理时间不均的情况。
  • 配合健康检查,可以自动避开故障节点。
  • 相比轮询,更符合“把活分给闲人”的直觉。

6.3 缺点有哪些

  • 只能衡量“数量”,不能衡量“真实负载”。比如机器A上有10个简单请求,机器B上有5个重量级计算请求,可能A反而更空闲;但只看连接数,会把新请求派给B。
  • 瞬时高并发下,所有线程同时读到的快照可能相同,容易引发惊群。
  • 如果服务器性能不一样,单纯看连接数会失衡。比如一台8核一台2核,连接数一样时,8核的应该承担更多。

6.4 落地时要注意什么

  • 一定要用线程安全的数据结构,不能用裸HashMap。
  • 连接数的加减要使用原子操作,手动加锁要慎重,锁的粒度要小。
  • 要配合超时机制。比如一个连接超过30秒没有结束,就主动断开并减少计数。
  • 如果后端机器性能差别大,可以考虑“加权最少连接”,给高配机器减个系数。
  • 定期做压力测试,观察均衡曲线,不要以为算法没问题就万事大吉。
  • 监控要跟上:除了连接数,还要关注CPU、内存、响应时间等指标,综合判断是否需要调参。

七、总结

最少连接这个策略,听起来像一个简单的“找最小值”,但真正把它落到高并发环境里,需要仔细对待统计的原子性、容器的并发安全、以及瞬时倾斜的问题。第一版代码直观但脆弱,第二版代码用ConcurrentHashMap和AtomicInteger解决了计数丢失的问题,再搭配健康检查和随机抖动,就能在绝大多数场景下做得不错。如果不想自己造轮子,直接用Nginx的least_conn也很香。关键是理解它背后的逻辑,出问题时才知道从哪儿排查。