spring cloud 2021 实现自定义的负载均衡

问题的产生

公司内有个基于spring cloud的微服务项目在生产实践中经常出现部分服务实例重启的问题,通过grafana的日志发现同一个服务的不同实例内存使用差异大,某个实例的内存使用远高于另一个实例,最终导致内存不足容器被杀死。

spring cloud 2021 实现自定义的负载均衡-我的技术分享

在模块日志里 可以看到打印了 Killed 字样,一般情况下都是触发了OOM导致容器被杀死,然后又被ReplicaSet重新拉起,产生了服务重启的现象。

spring cloud 2021 实现自定义的负载均衡-我的技术分享

问题分析

通过内存使用的趋势分析,明显就能知道服务实例之间负载不均衡的问题,图中绿色实例可能处理了占用更多内存的请求,或者处理的请求数大于黄色的实例。

已知当前微服务使用的cloud版本是2021.0.8,通过spring cloud loadbalancer 3.1.8实现负载均衡,服务间通过 open feign 3.1.9实现http通信。通过查阅文献资料,可知locadbalancer默认使用轮询策略实现负载均衡,实现类是RoundRobinLoadBalancer

负载均衡实现类型的顶层接口为 ReactorServiceInstanceLoadBalancer
spring cloud 2021 实现自定义的负载均衡-我的技术分享

默认存在三个负载均衡实现:
spring cloud 2021 实现自定义的负载均衡-我的技术分享

NacosBalancer : nacos提供的一个实现,基于实例的权重实现负载均衡。可以在nacos的管理控制台修改实例的权重。

spring cloud 2021 实现自定义的负载均衡-我的技术分享

RandomLoadBalancer: 随机策略,每个请求到达的时候,会执行 ThreadLocalRandom.current().nextInt(instances.size())随机取一个。

RoundRobinLoadBalancer : 实现了轮询的负载均衡策略,核心代码只有2行,实现了尽可能的将请求均分到每个实例的功能。

// 防止值超过int的最大值
int pos = this.position.incrementAndGet() & Integer.MAX_VALUE;
// 通过取模获取当前轮到的实例索引
ServiceInstance instance = instances.get(pos % instances.size());

此策略为spring cloud默认的策略,可以在LoadBalancerClientConfiguration 中看到相应Bean的注册。

    @Bean
    @ConditionalOnMissingBean
    public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(Environment environment,
            LoadBalancerClientFactory loadBalancerClientFactory) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new RoundRobinLoadBalancer(
                loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class), name);
    }

通过对spring cloud 默认负载均衡机制的学习,我们可以知道所有的请求都是均匀的发送到每一个服务实例,那么出现这种情况的最大可能是短时间内某些负载重的请求都巧合的被随机到了某个实例上。因为平台的特殊性,某些负载重的单个请求,可能会消耗几百MB的内存,导致了默认的负载均衡策略有概率会搞垮单个实例。

查询了平台的日志,这种情况也是无规律的出现,有时候一周内也不出现一次,有时候,一天内会出现几次。一般都是用户操作频繁的时候,出现概率高。

解决方案

我们现在知道,问题场景是不同请求对实例的内存升降影响不一致,负载重的请求无法均分到不同的实例,导致潜在的单独实例压力过大的问题。
现在,我们需要实现一个自定义的负载均衡策略解决这个问题。我们要做的是,先写代码实现一个负载均衡策略,然后实现一个符合场景需求的策略。

在策略上,首先我们要定义那些接口是负载重的,每个服务实例记录当前正在处理这些重负载的请求的数量,当一个重负载请求到达的时候,对比每个实例正在处理的重负载请求数量,优先分配给数量低的。

然后针对本文第一张截图OOM的问题,将剩余内存百分比加入权重系数的因子中: 请求数的权重为1,而剩余内存百分比量化成2个请求数,如果内存剩余100%则内存权重值为0,如果剩余内存为0则权重值为2,也就是内存使用率最大按2个请求计算。

最终的计算公式为 (重负载请求数+ 2内存使用百分比)100,去掉小数位得到一个整数值。
通过openfeign调用服务的选择实例的时候,选择权重值最低的实例发请求。或者按不同实例的权重值随机取一个实例,权重值越低就越容易选到。

实现自定义的负载均衡

首先,我们需要先实现服务实例的权重值计算。先定义一个注解,用于标记那些重负载请求的接口。

/**
 * 标记哪些接口需要额外的负载均衡策略
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoadBalancerAnnotation {
}

然后在控制器上使用这个注解。

    @LoadBalancerAnnotation
    @PostMapping("/xxxx")
    public Result deployModule() {
    }

接下来我们需要使用AOP拦截所有使用了这个注解的控制器方法,并使用一个变量记录当前正在处理的请求数。

 /**
 * 通过AOP 实现 需要负载均衡策略的接口的请求统计
 */
@Aspect
@Component
@Slf4j
public class RequestLoadBalancerInterceptor {

    private final Registration registration;
    /**
     * 注入一个redis操作类,主要是把当前实例的权重值写入redis,这样的话,每个服务的实例都能读取到。
    /**
    private final RedisTemplate redisTemplate;

    /**
     * 当前正在处理请求数 使用原子类解决并发冲突
     */
    private final AtomicInteger processCount = new AtomicInteger(0);

    public RequestLoadBalancerInterceptor(Registration registration, RedisTemplate redisTemplate) {
        this.registration = registration;
        this.redisTemplate = redisTemplate;
    }

    // 拦截注解类
    @Pointcut("@annotation(cn.bobmao.pro.util.loadbalancer.LoadBalancerAnnotation)")
    private void memoryConsumptionRequest() {}

    // 使用Around实现正在处理请求数的统计,处理前+1,处理后-1
    @Around("memoryConsumptionRequest()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // 构建redis key
        String instanceKey = buildKey(registration.getHost(), registration.getServiceId(), registration.getPort());
        // signature一般是个网址,如果是方法调用被拦截对象,则打印的是调用方法的签名。
        log.info("请求: {}  进入实例 {}", joinPoint.getSignature().toString(), instanceKey);
        // 请求数+1
        processCount.incrementAndGet();
        // 计算权重,写入redis
        redisTemplate.opsForValue().set(instanceKey, percentageOfFreeMemory());
        try {
            // 执行方法
            return joinPoint.proceed();
        } finally {
            log.info("请求处理完毕");
            // -1 操作
            processCount.decrementAndGet();
            // 写入redis
            redisTemplate.opsForValue().set(instanceKey, percentageOfFreeMemory());
        }
    }
    // 构建redis key, 采用ip+端口+服务名的方式作为唯一key
    private String buildKey(String host, String serviceId, int port) {
        return "loadbalancer:" + host + ":" + port + ":" + serviceId;
    }

    /**
     * 计算权重 (当前正在处理请求量+ 2*剩余内存百分比)*100
     * @return
     */
    private Long percentageOfFreeMemory() {
        Runtime runtime = Runtime.getRuntime();

        // 获取当前 JVM 的最大可用内存
        long maxMemory = runtime.maxMemory();

        // 获取当前 JVM 的总内存
        long totalMemory = runtime.totalMemory();

        // 获取当前 JVM 的空闲内存
        long freeMemory = runtime.freeMemory();

        // 已用内存 = 总内存 - 空闲内存
        long usedMemory = totalMemory - freeMemory;

        // 计算最大内存使用百分比
        double memoryUsagePercentage = (double) usedMemory / maxMemory ;

        // 剩余内存百分比
        double a = 1 - memoryUsagePercentage;

        // 内存权重按2个请求衡量
        double factor = 2 * a;

        // 累加后x100
        double v = (processCount.intValue() + factor) * 100;

        // 然后去掉小数位
        String s = String.valueOf(v);
        if (s.contains(".")) {
            s = s.split("\\.")[0];
        }

        return Long.parseLong(s);

    }
}

接下来,我们复制一份RoundRobinLoadBalancer类,创建名为CustomLoadBalancer的类,用于定义我们的负载均衡类,需要重写getInstanceResponse方法
然后创建一个类CustomLoadBalancerClientConfiguration,把原本RoundRobinLoadBalancer注册bean的方法塞进去。

@Configuration
@LoadBalancerClients(defaultConfiguration = CustomLoadBalancerClientConfiguration.class)
public class CustomLoadBalancerClientConfiguration {
    @Bean
    public ReactorLoadBalancer reactorServiceInstanceLoadBalancer(Environment environment,
            LoadBalancerClientFactory loadBalancerClientFactory) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new CustomLoadBalancer(
                loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class), name);
    }

现在我们重写CustomLoadBalancer类的getInstanceResponse方法,该方法传入了所有可用的实例信息,返回一个Response对象。

private Response getInstanceResponse(List instances)

Response接口默认有两个实现类,EmptyResponse和DefaultResponse,我们需要使用DefaultResponse作为返回类型。EmptyResponse是没有可用实例的时候,才会返回。

具体的实现代码如下

  private Response getInstanceResponse(List instances, boolean needLog) {
        // 没有可用实例则直接返回
        if (instances.isEmpty()) {
            if (log.isWarnEnabled()) {
                log.warn("服务没有可用的实例: " + this.serviceId);
            }
            return new EmptyResponse();
        }
        // 如果是单实例,则直接返回
        if (instances.size() == 1) {
            //log.info("单实例 选择实例 " + instances.get(0).getHost() + ":" + instances.get(0).getPort());
            return new DefaultResponse(instances.get(0));
        }
        ServiceInstance instance = instances.stream()
                .min(Comparator.comparingLong(it -> {
                    // redis读取每个实例的权重 取权重值最小的
                    Long value = redisTemplate.opsForValue().get(buildKey(it));
                    log.info("负载量化 " + buildKey(it) + " -> " + value);
                    return value != null ? value : -1L;
                })).orElseThrow(null);
        return new DefaultResponse(instance);
    }

测试

通过上面的负载均衡代码,我们能够在spring cloud 中实现自定义的负载均衡策略了。但是,当你异步线程内使用openFeign调用别的服务的时候,你会得到一个奇怪的异常!

 java.lang.ClassNotFoundException: org.springframework.boot.autoconfigure.condition.OnPropertyCondition
      at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(Unknown Source) ~[na:na]
      at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(Unknown Source) ~[na:na]
      at java.base/java.lang.ClassLoader.loadClass(Unknown Source) ~[na:na]
      at java.base/java.lang.Class.forName0(Native Method) ~[na:na]
      at java.base/java.lang.Class.forName(Unknown Source) ~[na:na]

像这种spring类找不到的情况,一般都是classloader的问题,或者classpath里少jar了(外置依赖的情况)
然后想起来之前被删除的一串和负载均衡相关的代码,因为这个配置会导致自定义的负载均衡不生效,然后短时间内也没想起来这个代码干啥用的,依稀记得是为了解决一个BUG,代码重新加进来就不会提示找不到类了,但是均衡策略会不生效。被删的代码如下:

@Configuration
public class LoadBalanceConfig {
    @Bean
    @ConditionalOnMissingBean
    public LoadBalancerClientFactory loadBalancerClientFactory(LoadBalancerClientsProperties properties) {
        return new LoadBalancerClientFactory(properties) {
            @Override
            protected AnnotationConfigApplicationContext createContext(String name) {
                // FIXME: temporary switch classloader to use the correct one when creating the context
                ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
                Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader());
                AnnotationConfigApplicationContext context = super.createContext(name);
                Thread.currentThread().setContextClassLoader(originalClassLoader);
                return context;
            }
        };
    }
}

经过一番查找,在CSDN上可以看到相同的问题,CSDN Could not find class,通过文章内的链接在GitHub找到相同问题的issue并在回复中找到相应的临时解决办法。这代码似乎和被我删除的代码长的差不多。

我们继续往下看,发现这个issue关联了另一个issue,且2个issues都是closed状态的,此问题应该是被官方修复了的。

spring cloud 2021 实现自定义的负载均衡-我的技术分享

进入关联问题,可知此问题出现于2021年,于2023年解决修复,这时间跨度可真长。产生此问题的解释如下,应该是jdk本身的改动导致的兼容性问题,jdk9及以上的都存在这个问题。

We got similar issue in one of our applications. After investigation, we found this is caused by https://bugs.openjdk.org/browse/JDK-8172726.
Start with Java 9, ForkJoinPool creates thread using system class loader as context class loader, refer to https://github.com/openjdk/jdk11u/blob/master/src/java.base/share/classes/java/util/concurrent/ForkJoinPool.java#L721.
If we run applications in IDE, the system class loader has been used to load Spring and application classes, so it's not easy to reproduce this issue. If we run applications from command line using fat JAR, org.springframework.boot.loader.LaunchedURLClassLoader will be used to load those classes instead. If threads switch to use system class loader, it will not be able to load Spring and other classes inside the fat JAR.

页面的下面,有人在2023年给出了一个使用自定义负载均衡场景下修复问题的方法
spring cloud 2021 实现自定义的负载均衡-我的技术分享

按照kisick的方案,当我们创建LoadBalancerClientFactory的实例的时候,自定义的负载均衡实现没有配置进去,导致失效。所以需要修改修复代码,注入ObjectProvider<List<LoadBalancerClientSpecification>> configurations,并重新塞到创建出来的LoadBalancerClientFactory内。而项目内的代码是缺少相关操作的。

为了更好的了解这段代码,我进一步分析。我们查看一下用于声明负载均衡实现的注解@LoadBalancerClients


@Configuration(proxyBeanMethods = false)
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE })
@Documented
@Import(LoadBalancerClientConfigurationRegistrar.class)
public @interface LoadBalancerClients {

LoadBalancerClient[] value() default {};

/**
 * {@link LoadBalancerClientConfigurationRegistrar} creates a
 * {@link LoadBalancerClientSpecification} with this as an argument. These in turn are
 * added as default contexts in {@link LoadBalancerClientFactory}. Configuration
 * defined in these classes are used as defaults if values aren't defined via
 * {@link LoadBalancerClient#configuration()}
 * @return classes for default configurations
 */
Class[] defaultConfiguration() default {};

}

其中对于defaultConfiguration属性的注释说defaultConfiguration的值会被封装成LoadBalancerClientSpecification对象,并添加到LoadBalancerClientFactory中。这直接说明了为什么我们使用自定义的LoadBalancerClientFactory后负载均衡失效的根本原因。只要重新加进去就好了。

但现在又有个问题,注入的为什么是 ObjectProvider<List>而不是 List,外面包裹的ObjectProvider有什么用?

查阅官方资料可知,ObjectProvider是实现依赖注入的辅助类,当使用方法入参注入LoadBalancerClientSpecification的时候,正常情况下如果项目内不存在自定义负载均衡的时候就会直接报错,提示找不到bean。而使用ObjectProvider包裹后,则不会抛出异常。ObjectProvider提供一个方法getIfAvailable,如果存在此类型bean则返回这个bean,不存在则返回用户定义的默认值。这样的话,用户不管是否定义了负载均衡,代码都不会报错,提升了兼容性。和java8的Optional类似。