Appearance
Client-Side Load Balancing
A load balancer spreads traffic across the instances of a service so no single instance is overloaded. Chapter 4 showed that Feign already does this: four calls alternated between two product-service instances. This chapter looks at how, and how to change it.
Server-side and client-side
Server-side load balancing: a separate, central component sits in front of the instances. The caller sends every request to it and it forwards each one. The caller has no balancing logic. Examples: Nginx, AWS Elastic Load Balancer.
Client-side load balancing: the caller does it, usually through a library. It gets the list of instances from service discovery, picks one, and calls it directly. Examples: Spring Cloud LoadBalancer, Netflix Ribbon (now retired), a service mesh sidecar such as Istio's.
This chapter covers the client side. A central entry point in front of services returns with the API gateway in chapter 10.
From manual choice to @LoadBalanced
Chapter 4 chose the instance by hand:
java
List<ServiceInstance> instances = discoveryClient.getInstances("product-service");
URI uri = instances.get(0).getUri(); // always the first: no balancing
String response = restTemplate.getForObject(uri + "/products/" + id, String.class);Hand it to Spring Cloud LoadBalancer instead.
Step 1: the dependency
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>(The Eureka client starter already includes it.)
Step 2: mark the RestTemplate
java
@Configuration
public class Config {
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}@LoadBalanced adds a LoadBalancerInterceptor to this RestTemplate. Every request it sends is intercepted; the interceptor looks up the instances, applies the load-balancing algorithm and rewrites the URL to the chosen host and port.
Step 3: use the service ID as the host
properties
server.port=8081
spring.application.name=order-service
eureka.client.service-url.defaultZone=http://localhost:8761/eureka
product.service.baseurl=http://product-servicejava
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
RestTemplate restTemplate;
@Value("${product.service.baseurl}")
String productBaseURL;
@GetMapping("/{id}")
public void callProductAPI(@PathVariable String id) {
String response = restTemplate.getForObject(
productBaseURL + "/products/" + id, String.class);
System.out.println("Response from Product api call is: " + response);
}
}http://product-service/products/1 is not a real address. product-service is the service ID, and the interceptor replaces it with a real instance. No DiscoveryClient, no choosing code.
With three product-service instances, eight calls go round in order:
text
[8083, 8084, 8082, 8083, 8084, 8082, 8083, 8084]A RestClient works the same way: annotate a RestClient.Builder bean with @LoadBalanced and build clients from it.
What the interceptor does
Recall from chapter 1 that RestTemplate runs its interceptors just before executing the request. LoadBalancerInterceptor does two things there:
loadBalancerClientFactory.getInstance(serviceId): get the load-balancing algorithm for this service ID.loadBalancer.choose(request): ask service discovery for the instances, then let that algorithm pick one.
Every algorithm belongs to a service ID
The interceptor's first step hides the key idea: a load-balancing algorithm is always attached to one service ID. An order-service calling product-service, sales-service and payment-service has one load balancer per service, and each can use a different algorithm: random for product-service, round robin for sales-service, something custom for payment-service.
Spring Cloud keeps them apart by creating a small Spring context for each service ID, holding that service's load balancer and instance supplier. Each context is created lazily, on the first call to that service, not at startup.
The default algorithm is RoundRobinLoadBalancer, for every service. Spring Cloud LoadBalancer ships two algorithm classes:
Other strategies come from filtering the instance list rather than from new algorithm classes. Set spring.cloud.loadbalancer.configurations to weighted, zone-preference, health-check, same-instance-preference or request-based-sticky-session, and round robin then runs over the filtered list. For anything else (least connections, response time), write your own algorithm (below) or use a service mesh such as Istio.
Changing the algorithm for one service
Use random for product-service. Two pieces.
1. Name the service and its configuration on the application class:
java
@SpringBootApplication
@LoadBalancerClient(name = "product-service",
configuration = LoadBalancerProductClientConfig.class)
public class OrderServiceApplication { ... }2. Define the algorithm in that configuration:
java
public class LoadBalancerProductClientConfig { // no @Configuration
@Bean
public ReactorLoadBalancer<ServiceInstance> productClientLoadBalancer(
LoadBalancerClientFactory factory) {
return new RandomLoadBalancer(
factory.getLazyProvider("product-service", ServiceInstanceListSupplier.class),
"product-service");
}
}The RandomLoadBalancer constructor takes two arguments:
- a
ServiceInstanceListSupplier(wrapped in a lazyObjectProvider): the object that asks service discovery for the instances ofproduct-service; - the service ID this algorithm is attached to.
This configuration class is not part of the main application context: Spring Cloud loads it into product-service's own context, the first time product-service is called. Calls are now random:
text
[8084, 8083, 8084, 8084, 8084, 8082, 8082, 8084]Leave @Configuration off
It is tempting to annotate LoadBalancerProductClientConfig with @Configuration. If you do, and it sits inside your component-scan package, it is also loaded into the main context, and every service's context inherits its bean from there. Then every service uses this random balancer, and since that balancer is tied to product-service, calls to sales-service end up on product-service instances:
text
sales-service -> [8083, 8084, 8083, 8082, ...] <- product-service ports!Classes passed to @LoadBalancerClient must either have no @Configuration annotation or live outside component scanning.
One algorithm for one service, another for the rest
To set a default for all services plus exceptions, use @LoadBalancerClients:
java
@SpringBootApplication
@LoadBalancerClients(
defaultConfiguration = LoadBalancerGlobalConfig.class,
value = {
@LoadBalancerClient(name = "product-service",
configuration = LoadBalancerProductClientConfig.class)
})
public class OrderServiceApplication { ... }The global configuration cannot hardcode a service ID: it serves every service. The service's context knows its own ID, under the property LoadBalancerClientFactory.PROPERTY_NAME:
java
public class LoadBalancerGlobalConfig {
@Bean
@ConditionalOnMissingBean
public ReactorLoadBalancer<ServiceInstance> globalLoadBalancer(
LoadBalancerClientFactory factory, Environment environment) {
String serviceId = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new RoundRobinLoadBalancer(
factory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class),
serviceId);
}
}This is why the per-service contexts matter: the method runs once in each service's context, with that service's ID.
@ConditionalOnMissingBean is required. product-service's context loads its own configuration first, then the default one. Without the condition, it ends up with two load balancers (random and round robin), cannot choose between them, and every call to product-service fails with an IllegalStateException. With the condition, the global bean is skipped wherever a service already has one.
The result, logged on first use:
text
>>> product config: creating RandomLoadBalancer
>>> global config: creating RoundRobinLoadBalancer for sales-service
product-service -> random
sales-service -> [8092, 8093, 8092, 8093, ...]Each bean is created once per service, on the first call, and reused for every call after.
A custom algorithm
Implement ReactorServiceInstanceLoadBalancer and its choose method, taking the same two constructor arguments as the built-in ones:
java
public class MyCustomLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceSuppliers;
private final String serviceId;
public MyCustomLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> serviceInstanceSuppliers,
String serviceId) {
this.serviceInstanceSuppliers = serviceInstanceSuppliers;
this.serviceId = serviceId;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
return serviceInstanceSuppliers
.getIfAvailable()
.get() // ask service discovery for the instances
.next()
.map(instances -> {
if (instances == null || instances.isEmpty()) {
return new EmptyResponse();
}
// your algorithm here (demo: always the first instance)
return new DefaultResponse(instances.get(0));
});
}
}RoundRobinLoadBalancer and RandomLoadBalancer have exactly this shape; only the line that picks an instance differs. Plug it in like the random one:
java
public class LoadBalancerProductClientConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> productClientLoadBalancer(
LoadBalancerClientFactory factory) {
return new MyCustomLoadBalancer(
factory.getLazyProvider("product-service", ServiceInstanceListSupplier.class),
"product-service");
}
}Every call to product-service now goes to the first instance: [8082, 8082, 8082, ...].
With Feign
Nothing changes. Feign uses the same load balancer for @FeignClient(name = "product-service"), so the same @LoadBalancerClient / @LoadBalancerClients configuration applies:
java
@SpringBootApplication
@EnableFeignClients
@LoadBalancerClient(name = "product-service",
configuration = LoadBalancerProductClientConfig.class)
public class OrderServiceApplication { ... }Next: Load balancing spreads healthy traffic. The next chapters protect a service when traffic or failures get out of hand, starting in chapter 6 with the rate limiter.
Check yourself
- What is the difference between server-side and client-side load balancing? Give an example of each.
- What does
@LoadBalancedadd to aRestTemplate? - In
http://product-service/products/1, what isproduct-service? - What two steps does
LoadBalancerInterceptorperform? - Why is every load-balancing algorithm tied to a service ID?
- When is a service's load-balancer configuration loaded?
- What is the default algorithm, and which two algorithm classes ship with Spring Cloud LoadBalancer?
- What two arguments does
RandomLoadBalancertake, and what does each do? - What goes wrong if the class passed to
@LoadBalancerClientis a scanned@Configuration? - How does a global configuration find out which service it is configuring?
- Why does the global load balancer bean need
@ConditionalOnMissingBean? - What do you implement for a custom algorithm?
Answers
- Server-side: a central component in front of the instances forwards each request (Nginx, ELB). Client-side: the caller gets the instance list and picks one itself (Spring Cloud LoadBalancer, Istio sidecar).
- A
LoadBalancerInterceptorthat, for every request, resolves the service ID in the URL to a chosen instance. - The service ID (the target's
spring.application.name), which the load balancer replaces with a real host and port. - It gets the algorithm for the service ID (
loadBalancerClientFactory.getInstance(serviceId)) and asks it to choose an instance (choose), which fetches the instances from service discovery. - Each service can need a different algorithm, so Spring Cloud keeps a separate load balancer (in a separate small context) for each service ID.
- Lazily, the first time that service is called; then it is reused.
- Round robin;
RoundRobinLoadBalancerandRandomLoadBalancer. - A lazy provider of
ServiceInstanceListSupplier, which fetches the instances from service discovery, and the service ID the algorithm is attached to. - It is also loaded into the main context, so every service inherits its balancer, which is tied to product-service: calls to other services go to product-service instances.
- From the environment property
LoadBalancerClientFactory.PROPERTY_NAMEin the service's own context. - A service with its own configuration would otherwise get two load balancer beans and fail; the condition skips the global one there.
ReactorServiceInstanceLoadBalancer, with a constructor taking the supplier provider and service ID, and achoosemethod that picks from the instances.