feat: expand the list by weights to implement weighted load balancing (#1140)
This commit is contained in:
@@ -50,6 +50,7 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||
* @author Olga Maciaszek-Sharma
|
||||
* @author Zhiguo Chen
|
||||
* @author Sabyasachi Bhattacharya
|
||||
* @author Zhuozhi Ji
|
||||
*/
|
||||
public final class ServiceInstanceListSupplierBuilder {
|
||||
|
||||
@@ -111,6 +112,31 @@ public final class ServiceInstanceListSupplierBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a {@link WeightedServiceInstanceListSupplier} to the
|
||||
* {@link ServiceInstanceListSupplier} hierarchy.
|
||||
* @return the {@link ServiceInstanceListSupplierBuilder} object
|
||||
*/
|
||||
public ServiceInstanceListSupplierBuilder withWeighted() {
|
||||
DelegateCreator creator = (context, delegate) -> new WeightedServiceInstanceListSupplier(delegate);
|
||||
this.creators.add(creator);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a {@link WeightedServiceInstanceListSupplier} that uses user-provided
|
||||
* {@link WeightFunction} instance to the {@link ServiceInstanceListSupplier}
|
||||
* hierarchy.
|
||||
* @param weightFunction a user-provided {@link WeightFunction} instance
|
||||
* @return the {@link ServiceInstanceListSupplierBuilder} object
|
||||
*/
|
||||
public ServiceInstanceListSupplierBuilder withWeighted(WeightFunction weightFunction) {
|
||||
DelegateCreator creator = (context, delegate) -> new WeightedServiceInstanceListSupplier(delegate,
|
||||
weightFunction);
|
||||
this.creators.add(creator);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a {@link HealthCheckServiceInstanceListSupplier} to the
|
||||
* {@link ServiceInstanceListSupplier} hierarchy.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2012-2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.loadbalancer.core;
|
||||
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
|
||||
/**
|
||||
* Represents a function that calculate the weight of the given service instance.
|
||||
*
|
||||
* <p>
|
||||
* This is a functional interface whose functional method is
|
||||
* {@link #apply(ServiceInstance)}.
|
||||
*
|
||||
* @author Zhuozhi Ji
|
||||
* @see java.util.function.ToIntFunction
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface WeightFunction {
|
||||
|
||||
/**
|
||||
* Applies this function to the given service instance.
|
||||
* @param instance the service instance
|
||||
* @return the weight of service instance
|
||||
*/
|
||||
int apply(ServiceInstance instance);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2012-2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.loadbalancer.core;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
|
||||
/**
|
||||
* A {@link ServiceInstanceListSupplier} implementation that uses weights to expand the
|
||||
* instances provided by delegate.
|
||||
*
|
||||
* @author Zhuozhi Ji
|
||||
*/
|
||||
public class WeightedServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier {
|
||||
|
||||
private static final Log LOG = LogFactory.getLog(WeightedServiceInstanceListSupplier.class);
|
||||
|
||||
static final String METADATA_WEIGHT_KEY = "weight";
|
||||
|
||||
static final int DEFAULT_WEIGHT = 1;
|
||||
|
||||
private final WeightFunction weightFunction;
|
||||
|
||||
public WeightedServiceInstanceListSupplier(ServiceInstanceListSupplier delegate) {
|
||||
super(delegate);
|
||||
this.weightFunction = WeightedServiceInstanceListSupplier::metadataWeightFunction;
|
||||
}
|
||||
|
||||
public WeightedServiceInstanceListSupplier(ServiceInstanceListSupplier delegate, WeightFunction weightFunction) {
|
||||
super(delegate);
|
||||
this.weightFunction = weightFunction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<List<ServiceInstance>> get() {
|
||||
return delegate.get().map(this::expandByWeight);
|
||||
}
|
||||
|
||||
private List<ServiceInstance> expandByWeight(List<ServiceInstance> instances) {
|
||||
if (instances.size() == 0) {
|
||||
return instances;
|
||||
}
|
||||
|
||||
int[] weights = instances.stream().mapToInt(instance -> {
|
||||
try {
|
||||
int weight = weightFunction.apply(instance);
|
||||
if (weight <= 0) {
|
||||
if (LOG.isDebugEnabled()) {
|
||||
LOG.debug(String.format(
|
||||
"The weight of the instance %s should be a positive integer, but it got %d, using %d as default",
|
||||
instance.getInstanceId(), weight, DEFAULT_WEIGHT));
|
||||
}
|
||||
return DEFAULT_WEIGHT;
|
||||
}
|
||||
return weight;
|
||||
}
|
||||
catch (Exception e) {
|
||||
if (LOG.isDebugEnabled()) {
|
||||
LOG.debug(String.format(
|
||||
"Exception occurred during apply weight function to instance %s, using %d as default",
|
||||
instance.getInstanceId(), DEFAULT_WEIGHT), e);
|
||||
}
|
||||
return DEFAULT_WEIGHT;
|
||||
}
|
||||
}).toArray();
|
||||
|
||||
// Calculate the greatest common divisor (GCD) of weights and the total number of
|
||||
// elements after expansion.
|
||||
int gcd = 0;
|
||||
int total = 0;
|
||||
for (int weight : weights) {
|
||||
gcd = greatestCommonDivisor(gcd, weight);
|
||||
total += weight;
|
||||
}
|
||||
|
||||
// Because scaling by the gcd does not affect the distribution,
|
||||
// we can reduce memory usage by this way.
|
||||
List<ServiceInstance> newInstances = new ArrayList<>(total / gcd);
|
||||
|
||||
// use iterator for some implementation of the List that not supports
|
||||
// RandomAccess, but `weights` is supported, so use a local variable `i`
|
||||
// to get the current position.
|
||||
int i = 0;
|
||||
for (ServiceInstance instance : instances) {
|
||||
int weight = weights[i] / gcd;
|
||||
for (int j = 0; j < weight; j++) {
|
||||
newInstances.add(instance);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
Collections.shuffle(newInstances);
|
||||
return newInstances;
|
||||
}
|
||||
|
||||
static int metadataWeightFunction(ServiceInstance serviceInstance) {
|
||||
Map<String, String> metadata = serviceInstance.getMetadata();
|
||||
if (metadata != null) {
|
||||
String weightValue = metadata.get(METADATA_WEIGHT_KEY);
|
||||
if (weightValue != null) {
|
||||
return Integer.parseInt(weightValue);
|
||||
}
|
||||
}
|
||||
// using default weight when metadata is missing or
|
||||
// weight is not specified
|
||||
return DEFAULT_WEIGHT;
|
||||
}
|
||||
|
||||
static int greatestCommonDivisor(int a, int b) {
|
||||
int r;
|
||||
while (b != 0) {
|
||||
r = a % b;
|
||||
a = b;
|
||||
b = r;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,9 +37,11 @@ public class ServiceInstanceListSupplierBuilderTests {
|
||||
public void testBuilder() {
|
||||
new ApplicationContextRunner().withUserConfiguration(CacheTestConfig.class).run(context -> {
|
||||
ServiceInstanceListSupplier supplier = ServiceInstanceListSupplier.builder().withDiscoveryClient()
|
||||
.withHealthChecks().withCaching().build(context);
|
||||
.withHealthChecks().withWeighted().withCaching().build(context);
|
||||
assertThat(supplier).isInstanceOf(CachingServiceInstanceListSupplier.class);
|
||||
DelegatingServiceInstanceListSupplier delegating = (DelegatingServiceInstanceListSupplier) supplier;
|
||||
assertThat(delegating.getDelegate()).isInstanceOf(WeightedServiceInstanceListSupplier.class);
|
||||
delegating = (DelegatingServiceInstanceListSupplier) delegating.getDelegate();
|
||||
assertThat(delegating.getDelegate()).isInstanceOf(HealthCheckServiceInstanceListSupplier.class);
|
||||
delegating = (DelegatingServiceInstanceListSupplier) delegating.getDelegate();
|
||||
assertThat(delegating.getDelegate()).isInstanceOf(DiscoveryClientServiceInstanceListSupplier.class);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright 2012-2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.loadbalancer.core;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import org.springframework.cloud.client.DefaultServiceInstance;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
|
||||
import static java.util.stream.Collectors.summingInt;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.cloud.loadbalancer.core.WeightedServiceInstanceListSupplier.DEFAULT_WEIGHT;
|
||||
|
||||
/**
|
||||
* Tests for {@link WeightedServiceInstanceListSupplier}.
|
||||
*
|
||||
* @author Zhuozhi Ji
|
||||
*/
|
||||
class WeightedServiceInstanceListSupplierTests {
|
||||
|
||||
private final DiscoveryClientServiceInstanceListSupplier delegate = mock(
|
||||
DiscoveryClientServiceInstanceListSupplier.class);
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyWhenDelegateReturnedEmpty() {
|
||||
when(delegate.get()).thenReturn(Flux.just(Collections.emptyList()));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
assertThat(serviceInstances).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBeSameAsWeightsRatioWhenGcdOfWeightsIs1() {
|
||||
ServiceInstance one = serviceInstance("test-1", buildWeightMetadata(1));
|
||||
ServiceInstance two = serviceInstance("test-2", buildWeightMetadata(2));
|
||||
ServiceInstance three = serviceInstance("test-3", buildWeightMetadata(3));
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", 1);
|
||||
assertThat(counter).containsEntry("test-2", 2);
|
||||
assertThat(counter).containsEntry("test-3", 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBeSameAsWeightsRatioWhenGcdOfWeightsIs10() {
|
||||
ServiceInstance one = serviceInstance("test-1", buildWeightMetadata(10));
|
||||
ServiceInstance two = serviceInstance("test-2", buildWeightMetadata(20));
|
||||
ServiceInstance three = serviceInstance("test-3", buildWeightMetadata(30));
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", 1);
|
||||
assertThat(counter).containsEntry("test-2", 2);
|
||||
assertThat(counter).containsEntry("test-3", 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultWeightWhenWeightNotSpecified() {
|
||||
ServiceInstance one = serviceInstance("test-1", Collections.emptyMap());
|
||||
ServiceInstance two = serviceInstance("test-2", Collections.emptyMap());
|
||||
ServiceInstance three = serviceInstance("test-3", buildWeightMetadata(3));
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-2", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-3", 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultWeightWhenWeightIsNotNumber() {
|
||||
ServiceInstance one = serviceInstance("test-1", buildWeightMetadata("Foo"));
|
||||
ServiceInstance two = serviceInstance("test-2", buildWeightMetadata("Bar"));
|
||||
ServiceInstance three = serviceInstance("test-3", buildWeightMetadata("Baz"));
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-2", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-3", DEFAULT_WEIGHT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultWeightWhenWeightedFunctionReturnedZero() {
|
||||
ServiceInstance one = serviceInstance("test-1", Collections.emptyMap());
|
||||
ServiceInstance two = serviceInstance("test-2", Collections.emptyMap());
|
||||
ServiceInstance three = serviceInstance("test-3", Collections.emptyMap());
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate, instance -> 0);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-2", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-3", DEFAULT_WEIGHT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultWeightWhenWeightedFunctionReturnedNegative() {
|
||||
ServiceInstance one = serviceInstance("test-1", Collections.emptyMap());
|
||||
ServiceInstance two = serviceInstance("test-2", Collections.emptyMap());
|
||||
ServiceInstance three = serviceInstance("test-3", Collections.emptyMap());
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate,
|
||||
instance -> -1);
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-2", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-3", DEFAULT_WEIGHT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultWeightWhenWeightedFunctionThrownException() {
|
||||
ServiceInstance one = serviceInstance("test-1", Collections.emptyMap());
|
||||
ServiceInstance two = serviceInstance("test-2", Collections.emptyMap());
|
||||
ServiceInstance three = serviceInstance("test-3", Collections.emptyMap());
|
||||
|
||||
when(delegate.get()).thenReturn(Flux.just(Arrays.asList(one, two, three)));
|
||||
WeightedServiceInstanceListSupplier supplier = new WeightedServiceInstanceListSupplier(delegate, instance -> {
|
||||
throw new RuntimeException();
|
||||
});
|
||||
|
||||
List<ServiceInstance> serviceInstances = Objects.requireNonNull(supplier.get().blockFirst());
|
||||
Map<String, Integer> counter = serviceInstances.stream()
|
||||
.collect(Collectors.groupingBy(ServiceInstance::getInstanceId, summingInt(e -> 1)));
|
||||
assertThat(counter).containsEntry("test-1", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-2", DEFAULT_WEIGHT);
|
||||
assertThat(counter).containsEntry("test-3", DEFAULT_WEIGHT);
|
||||
}
|
||||
|
||||
private ServiceInstance serviceInstance(String instanceId, Map<String, String> metadata) {
|
||||
return new DefaultServiceInstance(instanceId, "test", "localhost", 8080, false, metadata);
|
||||
}
|
||||
|
||||
private Map<String, String> buildWeightMetadata(Object weight) {
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
metadata.put("weight", weight.toString());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user