今天不整那些虚头巴脑的概念,直接聊一个生产环境里很常用的负载均衡策略。后端服务通常不止一台,用户请求进来后,得有个“门卫”决定把请求派给哪台机器。最简单的办法是轮流来,但这有个问题:有的请求很快,有的请求很慢,轮流派会导致慢请求堆积在某一台机器上。于是就有了这么个思路:谁手上正在处理的请求少,就把新请求派给谁。这个思路听着特别合理,但真要把“谁手上少”这件事统计准,尤其是在高并发下,坑比想象中多得多。
一、先从生活场景理解最少连接
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也很香。关键是理解它背后的逻辑,出问题时才知道从哪儿排查。
评论
围绕“最少连接负载均衡的连接统计逻辑及高并发下的问题解决”参与讨论