1. 项目背景详细介绍

随着微服务架构和高并发 Web 应用的普及,如何将客户端的请求在多台后端服务器之间均匀分配,成为保证系统性能与可用性的关键一环。最基础、最常见的负载均衡算法就是“轮询”(Round Robin):按顺序将请求依次分发给列表中的每一台服务器,当达到末尾时再从头开始。

  • 简单易懂:轮询算法实现逻辑直观明了,不依赖复杂状态。

  • 零配置:无需为服务器分配权重、无需监控实时负载,即可上线运行。

  • 通用场景:适用于后端实例性能、规格基本一致的场景。

本项目旨在使用 Java 语言,从零实现一个线程安全的简单轮询负载均衡器,帮助大家深入理解基本原理,并为后续优化(如加权轮询、健康检查等)铺垫基础。


2. 项目需求详细介绍

  • 功能需求

    1. 支持动态添加、删除后端服务器实例;

    2. 按“轮询”策略,将请求依次分发给各实例;

    3. 支持多线程并发调用,保证调度结果正确。

  • 非功能需求

    1. 性能:在节点数量≤50、并发线程数≤2000 的情况下,调度延迟应在微秒级;

    2. 线程安全:在高并发场景下,无重复选中或漏选;

    3. 可扩展性:后续可接入权重、健康检查、优先级等功能;

    4. 易用性:API 简洁,上手成本低;

    5. 可测试性:附带单元测试案例,验证轮询顺序。


3. 相关技术详细介绍

  1. Java 基础

    • List 集合:保存服务器列表,支持 add、remove、get 操作;

    • 原子变量AtomicIntegerAtomicLong 记录当前索引,保证并发安全;

    • 锁机制:在复杂场景下可选用 ReentrantLock

    • 并发工具:如 JUnit + ExecutorService 模拟高并发测试。

  2. 轮询算法原理

    • 顺序遍历:维护一个全局索引 currentIndex,每次调度前做 currentIndex = (currentIndex + 1) % size

    • 循环复用:当索引达到末尾,自动回绕到列表头部。

  3. 线程安全策略

    • 无锁方案:使用 AtomicInteger 做自增取模,避免锁竞争;

    • 有锁方案:在列表变更(增删节点)或调度过程中加锁,保证状态一致。

  4. 测试方法

    • 使用 JUnit 编写多线程测试,验证在并发环境下输出的节点序列与预期轮询顺序一致。


4. 实现思路详细介绍

4.1 数据模型

  • 定义 class ServerNode 表示后端实例,包含唯一标识(如 idaddress 等);

  • 维护 List<ServerNode> 作为可用节点列表。

4.2 核心调度逻辑

  1. 初始化

    • currentIndex 初始为 -1

    • 节点列表可为空,调度时应返回 null 或抛出异常。

  2. 轮询选取

    • 每次调用 selectNode()

      • 先获取列表长度 n

      • 通过原子操作:int idx = (atomicIndex.incrementAndGet() & Integer.MAX_VALUE) % n;

      • 返回 nodes.get(idx)

  3. 并发安全

    • 增删节点:在写操作时使用 synchronized 块或写锁,确保列表修改与读取互斥;

    • 取模计算:使用原子变量无锁实现,实现高吞吐;

    • 边界处理:动态增删节点后,atomicIndex 可能大于新列表长度,通过取模自动回绕。

4.3 API 设计

public class RoundRobinLoadBalancer {
    public void addNode(ServerNode node);
    public void removeNode(String nodeId);
    public ServerNode selectNode();
    public List<ServerNode> listNodes();
}
  • addNoderemoveNode:在写锁保护下修改列表;

  • selectNode:无锁读取列表长度并通过原子自增取模选取;

  • listNodes:返回当前活跃节点快照,用于监控或调试。

4.4 扩展思考

  • 自恢复索引:当 atomicIndex 达到 Integer.MAX_VALUE 临界,可在低峰期重置;

  • 快速失败:若列表空,可直接抛出异常或 fallback 到默认节点;

  • 日志监控:在选取过程中记录节点命中日志,便于后期分析流量分布。

5. 完整实现代码

// ==================== 文件:ServerNode.java ====================
package com.example.loadbalancer;

/**
 * 表示一个后端服务器节点
 */
public class ServerNode {
    /** 节点唯一标识 */
    private final String id;
    /** 节点地址或其它信息 */
    private final String address;

    public ServerNode(String id, String address) {
        this.id = id;
        this.address = address;
    }

    public String getId() {
        return id;
    }

    public String getAddress() {
        return address;
    }

    @Override
    public String toString() {
        return "ServerNode{id='" + id + "', address='" + address + "'}";
    }
}

// ==================== 文件:RoundRobinLoadBalancer.java ====================
package com.example.loadbalancer;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

/**
 * 轮询(Round Robin)负载均衡器实现
 */
public class RoundRobinLoadBalancer {

    /** 活跃的服务器列表 */
    private final List<ServerNode> nodes = new ArrayList<>();
    /** 原子自增索引,保证并发安全 */
    private final AtomicLong index = new AtomicLong(0);

    /**
     * 增加一个服务器节点
     * @param node 新增的节点
     */
    public synchronized void addNode(ServerNode node) {
        nodes.add(node);
    }

    /**
     * 删除一个服务器节点
     * @param nodeId 要删除的节点ID
     */
    public synchronized void removeNode(String nodeId) {
        nodes.removeIf(n -> n.getId().equals(nodeId));
    }

    /**
     * 列出当前所有节点的快照
     * @return 节点列表快照
     */
    public synchronized List<ServerNode> listNodes() {
        return new ArrayList<>(nodes);
    }

    /**
     * 执行一次轮询选取,返回被选中的节点
     * @return 本次调度选中的 ServerNode;若无节点则返回 null
     */
    public ServerNode selectNode() {
        List<ServerNode> snapshot;
        synchronized (this) {
            if (nodes.isEmpty()) {
                return null;
            }
            snapshot = new ArrayList<>(nodes);
        }
        // 原子自增并取模
        long current = index.getAndIncrement();
        int idx = (int) (Math.floorMod(current, snapshot.size()));
        return snapshot.get(idx);
    }
}

// ==================== 文件:TestRoundRobin.java ====================
package com.example.loadbalancer;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import java.util.List;

/**
 * JUnit 单元测试,验证轮询顺序
 */
public class TestRoundRobin {

    private RoundRobinLoadBalancer lb;

    @Before
    public void setup() {
        lb = new RoundRobinLoadBalancer();
        lb.addNode(new ServerNode("A", "192.168.0.1"));
        lb.addNode(new ServerNode("B", "192.168.0.2"));
        lb.addNode(new ServerNode("C", "192.168.0.3"));
    }

    @Test
    public void testSequence() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 6; i++) {
            ServerNode node = lb.selectNode();
            sb.append(node.getId());
        }
        // 期望顺序:ABCABC
        Assert.assertEquals("ABCABC", sb.toString());
    }

    @Test
    public void testConcurrent() throws Exception {
        // 模拟多线程并发选取,确保无异常、顺序整体符合轮询模式
        int threads = 50;
        int callsPerThread = 100;
        StringBuilder sb = new StringBuilder();
        java.util.concurrent.ExecutorService exec = java.util.concurrent.Executors.newFixedThreadPool(threads);
        java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(threads);
        for (int t = 0; t < threads; t++) {
            exec.submit(() -> {
                for (int i = 0; i < callsPerThread; i++) {
                    ServerNode node = lb.selectNode();
                    synchronized (sb) {
                        sb.append(node.getId());
                    }
                }
                latch.countDown();
            });
        }
        latch.await();
        exec.shutdown();
        // 这里只验证总长度正确即可
        Assert.assertEquals(threads * callsPerThread, sb.length());
    }
}

6. 代码详细解读

  • ServerNode.java

    • ServerNode(String id, String address):构造方法,初始化节点的唯一 ID 和地址信息。

    • toString():打印节点信息,便于调试时输出。

  • RoundRobinLoadBalancer.java

    • addNode(ServerNode) / removeNode(String) / listNodes():在 synchronized 保护下对 nodes 列表进行增删改查,保证修改的线程安全。

    • selectNode()

      1. synchronized 块中生成列表快照,避免在计算时列表被并发修改;

      2. 使用 AtomicLong index 做无锁自增;

      3. 通过 Math.floorMod(current, size) 取模,避免负值并正确回绕;

      4. 从快照列表中按序取出对应节点并返回。

  • TestRoundRobin.java

    • testSequence():验证在单线程调用下,节点按照 “A→B→C→A→B→C” 的循环顺序分配;

    • testConcurrent():使用线程池模拟高并发场景,批量调用 selectNode(),主要验证在并发下无抛出异常且总调用次数正确。


7. 项目详细总结

本项目通过 Java 语言实现了最基础的轮询(Round Robin)负载均衡算法,核心特点如下:

  1. 简单高效:利用 AtomicLong 无锁自增和取模操作,实现毫秒级甚至微秒级的调度;

  2. 线程安全:列表修改与读取分离,写操作加锁,读操作无锁,仅保留快照,兼顾安全与性能;

  3. 易于扩展:在此基础上可接入加权、健康检查、故障剔除等高级功能;

  4. 可测试性强:附带单元测试,验证顺序和并发场景下的正确性。


8. 项目常见问题及解答

Q1:为何要生成快照再取模?
A:避免在 selectNode() 的计算过程中,另一线程同时执行 addNode()removeNode() 导致 nodes 列表变化,保证索引和列表长度的一致性。

Q2:使用 AtomicLong 而非 AtomicInteger 的考虑?
A:请求量极大时,AtomicInteger 达到上限后会回到负数,需额外处理;AtomicLong 范围更大,且通过 floorMod 可正确取模。

Q3:当没有节点时如何处理?
A:当前实现返回 null,可根据业务需求抛出自定义异常或返回默认节点。

Q4:索引何时重置?
A:本示例未主动重置 index,在长期运行且调用次数极多下可监控 index 值,在低峰期调用 index.set(index % size) 进行重置。


9. 扩展方向与性能优化

  1. 无锁节点列表

    • 使用 CopyOnWriteArrayList 或读写分离的并发集合,减少 synchronized 范围;

  2. 支持加权轮询

    • 在本轮询框架上加权,或直接集成前文提到的“平滑加权轮询”算法;

  3. 健康检查集成

    • 定时探测实例可用性,自动上下线,提高可用性;

  4. 监控与告警

    • 接入 Prometheus/Grafana,实时监控各节点的命中次数、响应延迟,并在异常时告警;

  5. 分布式协同

    • 在多台负载均衡器之间同步节点列表,确保一致性;

  6. 优先级与隔离

    • 支持不同优先级的请求流量,或不同流量类型的隔离调度。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐