11.1 客户端负载均衡

响应式 HTTP 客户端负载均衡:Reactor Netty 与 Spring Cloud LoadBalancer 集成、自定义 ReactiveLoadBalancer 实现轮询/随机/加权策略、服务实例健康检查与动态刷新,以及连接池在负载均衡中的隔离配置。

为什么客户端需要负载均衡

服务端负载均衡(Nginx、HAProxy)是常见方案,但响应式系统更倾向于客户端负载均衡:

  • 减少网络跳数(直连后端,不经负载均衡器)
  • 更细粒度的实例感知(知道哪些实例健康、延迟如何)
  • 选择过程本身是异步的,不阻塞调用线程;背压只能约束「单个请求的响应流」,不会自动改变负载均衡的选择

Spring Cloud LoadBalancer 基础

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

配置服务列表(开发环境):

spring:
  cloud:
    loadbalancer:
      configurations: default
    discovery:
      client:
        simple:
          instances:
            order-service:
              - uri: http://order-1:8081
                metadata:
                  weight: "70"
              - uri: http://order-2:8081
                metadata:
                  weight: "30"

ReactiveLoadBalancer 实现

自定义轮询负载均衡器:

import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.DefaultRequest;
import org.springframework.cloud.client.loadbalancer.DefaultResponse;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.Response;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import reactor.core.publisher.Mono;

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

public class RoundRobinLoadBalancer implements ReactiveLoadBalancer<ServiceInstance> {
    private final List<ServiceInstance> instances;
    private final AtomicInteger position = new AtomicInteger(0);

    public RoundRobinLoadBalancer(List<ServiceInstance> instances) {
        this.instances = instances;
    }

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        if (instances.isEmpty()) {
            return Mono.error(new IllegalStateException("No instances available"));
        }
        // 计数器溢出后会变成负数,floorMod 保证下标仍落在 [0, size)
        int pos = Math.floorMod(position.getAndIncrement(), instances.size());
        return Mono.just(new DefaultResponse(instances.get(pos)));
    }
}

加权随机策略

public class WeightedRandomLoadBalancer implements ReactiveLoadBalancer<ServiceInstance> {
    private final List<ServiceInstance> instances;

    public WeightedRandomLoadBalancer(List<ServiceInstance> instances) {
        this.instances = instances;
    }

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        if (instances.isEmpty()) {
            return Mono.error(new IllegalStateException("No instances available"));
        }
        int totalWeight = instances.stream()
            .mapToInt(i -> Integer.parseInt(i.getMetadata().getOrDefault("weight", "1")))
            .sum();
        if (totalWeight <= 0) {
            return Mono.error(new IllegalStateException("All weights are zero"));
        }

        // ThreadLocalRandom 是线程本地对象:每次在调用线程上取,不要存成字段跨线程复用
        int randomWeight = ThreadLocalRandom.current().nextInt(totalWeight);
        int currentWeight = 0;
        for (ServiceInstance instance : instances) {
            currentWeight += Integer.parseInt(
                instance.getMetadata().getOrDefault("weight", "1"));
            if (randomWeight < currentWeight) {
                return Mono.just(new DefaultResponse(instance));
            }
        }
        return Mono.just(new DefaultResponse(instances.get(instances.size() - 1)));
    }
}

与 WebClient 集成

@Service
public class OrderServiceClient {
    private final WebClient.Builder webClientBuilder;
    private final ReactiveLoadBalancer<ServiceInstance> loadBalancer;

    public Mono<String> getOrders() {
        return loadBalancer.choose(new DefaultRequest<>())
            .flatMap(response -> {
                ServiceInstance instance = response.getServer();
                String url = instance.getUri().toString() + "/api/orders";
                return webClientBuilder.build()
                    .get().uri(url)
                    .retrieve()
                    .bodyToMono(String.class);
            });
    }
}

健康检查与实例剔除

@Component
public class HealthCheckedLoadBalancer implements ReactiveLoadBalancer<ServiceInstance> {
    private final List<ServiceInstance> instances;
    private final WebClient healthClient;
    private final Map<String, Boolean> healthStatus = new ConcurrentHashMap<>();

    public HealthCheckedLoadBalancer(List<ServiceInstance> instances, WebClient healthClient) {
        this.instances = instances;
        this.healthClient = healthClient;
    }

    // instanceId 在 SimpleDiscoveryClient 里默认是 null,不能直接当 Map key
    private static String keyOf(ServiceInstance instance) {
        return instance.getInstanceId() != null
            ? instance.getInstanceId()
            : instance.getUri().toString();
    }

    // 定时健康检查;需要在配置类上 @EnableScheduling
    @Scheduled(fixedRate = 10000)
    public void checkHealth() {
        instances.forEach(instance -> {
            String healthUrl = instance.getUri() + "/health";
            healthClient.head().uri(healthUrl)
                .retrieve()
                .toBodilessEntity()   // 消费并释放响应体,避免连接悬挂
                .doOnNext(entity -> healthStatus.put(
                    keyOf(instance), entity.getStatusCode().is2xxSuccessful()))
                .onErrorResume(err -> {
                    healthStatus.put(keyOf(instance), false);
                    return Mono.empty();
                })
                .subscribe();
        });
    }

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        List<ServiceInstance> healthy = instances.stream()
            .filter(i -> healthStatus.getOrDefault(keyOf(i), true))
            .toList();

        if (healthy.isEmpty()) {
            return Mono.error(new IllegalStateException("No healthy instances"));
        }
        int pos = ThreadLocalRandom.current().nextInt(healthy.size());
        return Mono.just(new DefaultResponse(healthy.get(pos)));
    }
}

连接池隔离(按需)

默认的全局 ConnectionProvider 已经按远端地址分池,同一实例的连接会复用;只有需要按服务或实例做独立配额、故障隔离、监控标签时,才值得自建池。池的数量会随实例数增长,实例下线时必须 dispose(),否则连接和事件循环资源会泄漏:

public class PooledServiceClient {
    private final Map<String, ConnectionProvider> pools = new ConcurrentHashMap<>();

    public HttpClient getClient(ServiceInstance instance) {
        // instanceId 可能为 null,用 URI 兜底,避免 ConcurrentHashMap 拒绝 null key
        String key = instance.getInstanceId() != null
            ? instance.getInstanceId()
            : instance.getUri().toString();
        ConnectionProvider pool = pools.computeIfAbsent(key, k ->
            ConnectionProvider.builder(k)
                .maxConnections(20)
                .build());

        return HttpClient.create(pool)
            .baseUrl(instance.getUri().toString());
    }
}

实例 A 的连接池满不会影响实例 B,避免单点故障引发全局问题。注意上面每次 getClient 都会新建 HttpClient,生产实现要把 HttpClient 也缓存起来,否则连接池复用了,客户端对象和事件循环资源却在反复创建。

eventd v2 的内部分发

eventd v2 的内部服务调用使用客户端负载均衡:

gateway → [order-service-1 (weight 70%)]
       → [order-service-2 (weight 30%)]
       → [health: OK / latency < 100ms]

配合第 2.3 节的超时重试,形成一个完整的弹性请求管线。

小结

策略特点适用
轮询均匀分发实例性能对等
随机简单实例性能对等
加权按权重分发实例性能不等
健康检查剔除故障实例所有场景
最少连接选当前连接最少长连接场景

下一节 11.2 熔断器 :Resilience4j CircuitBreakerOperator、状态机与半开恢复。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南