本系列示例與膠水代碼地址: https://github.com/HashZhang/spring-cloud-scaffold
負載均衡Ribbon替換成Spring Cloud Load Balancer
Spring Cloud Load Balancer
並不是一個獨立的項目,而是spring-cloud-commons
其中的一個模塊。 項目中用了Eureka
以及相關的 starter,想完全剔除Ribbon
的相關依賴基本是不可能的,Spring 社區的人也是看到了這一點,通過配置去關閉Ribbon
啓用Spring-Cloud-LoadBalancer
。
spring.cloud.loadbalancer.ribbon.enabled=false
關閉ribbon之後,Spring Cloud LoadBalancer就會加載成爲默認的負載均衡器。
Spring Cloud LoadBalancer 結構如下所示:
其中:
- 全局只有一個
BlockingLoadBalancerClient
,負責執行所有的負載均衡請求。 BlockingLoadBalancerClient
從LoadBalancerClientFactory
裏面加載對應微服務的負載均衡配置。- 每個微服務下有獨自的
LoadBalancer
,LoadBalancer
裏面包含負載均衡的算法,例如RoundRobin
.根據算法,從ServiceInstanceListSupplier
返回的實例列表中選擇一個實例返回。
1. 實現zone
隔離
要想實現zone
隔離,應該從ServiceInstanceListSupplier
裏面做手腳。默認的實現裏面有關於zone
隔離的ServiceInstanceListSupplier
-> org.springframework.cloud.loadbalancer.core.ZonePreferenceServiceInstanceListSupplier
:
private List<ServiceInstance> filteredByZone(List<ServiceInstance> serviceInstances) {
if (zone == null) {
zone = zoneConfig.getZone();
}
//如果zone不爲null,並且該zone下有存活實例,則返回這個實例列表
//否則,返回所有的實例
if (zone != null) {
List<ServiceInstance> filteredInstances = new ArrayList<>();
for (ServiceInstance serviceInstance : serviceInstances) {
String instanceZone = getZone(serviceInstance);
if (zone.equalsIgnoreCase(instanceZone)) {
filteredInstances.add(serviceInstance);
}
}
if (filteredInstances.size() > 0) {
return filteredInstances;
}
}
// If the zone is not set or there are no zone-specific instances available,
// we return all instances retrieved for given service id.
return serviceInstances;
}
這裏對於沒指定zone
或者該zone
下沒有存活實例的情況下,會返回所有查到的實例,不區分zone
。這個不符合我們的要求,所以我們修改並實現下我們自己的com.github.hashjang.hoxton.service.consumer.config.SameZoneOnlyServiceInstanceListSupplier:
private List<ServiceInstance> filteredByZone(List<ServiceInstance> serviceInstances) {
if (zone == null) {
zone = zoneConfig.getZone();
}
if (zone != null) {
List<ServiceInstance> filteredInstances = new ArrayList<>();
for (ServiceInstance serviceInstance : serviceInstances) {
String instanceZone = getZone(serviceInstance);
if (zone.equalsIgnoreCase(instanceZone)) {
filteredInstances.add(serviceInstance);
}
}
if (filteredInstances.size() > 0) {
return filteredInstances;
}
}
//如果沒找到就返回空列表,絕不返回其他集羣的實例
return List.of();
}
然後我們來看一下默認的 Spring Cloud LoadBalancer
提供的 LoadBalancer
,它是帶緩存的:
org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientConfiguration
@Bean
@ConditionalOnBean(ReactiveDiscoveryClient.class)
@ConditionalOnMissingBean
public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
ReactiveDiscoveryClient discoveryClient, Environment env,
ApplicationContext context) {
DiscoveryClientServiceInstanceListSupplier delegate = new DiscoveryClientServiceInstanceListSupplier(
discoveryClient, env);
ObjectProvider<LoadBalancerCacheManager> cacheManagerProvider = context
.getBeanProvider(LoadBalancerCacheManager.class);
if (cacheManagerProvider.getIfAvailable() != null) {
return new CachingServiceInstanceListSupplier(delegate,
cacheManagerProvider.getIfAvailable());
}
return delegate;
}
DiscoveryClientServiceInstanceListSupplier
每次從Eureka
上面拉取實例列表,CachingServiceInstanceListSupplier
提供了緩存,這樣不必每次從Eureka
上面拉取。可以看出CachingServiceInstanceListSupplier
是一種代理模式的實現,和SameZoneOnlyServiceInstanceListSupplier
的模式是一樣的。
我們來組裝我們的ServiceInstanceListSupplier
,由於我們是同步的環境,只用實現同步的ServiceInstanceListSupplier
就行了。
public class CommonLoadBalancerConfig {
/**
* 同步環境下的ServiceInstanceListSupplier
* SameZoneOnlyServiceInstanceListSupplier限制僅返回同一個zone下的實例(注意)
* CachingServiceInstanceListSupplier啓用緩存,不每次訪問eureka請求實例列表
*
* @param discoveryClient
* @param env
* @param zoneConfig
* @param context
* @return
*/
@Bean
@Order(Integer.MIN_VALUE)
public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
DiscoveryClient discoveryClient, Environment env,
LoadBalancerZoneConfig zoneConfig,
ApplicationContext context) {
ServiceInstanceListSupplier delegate = new SameZoneOnlyServiceInstanceListSupplier(
new DiscoveryClientServiceInstanceListSupplier(discoveryClient, env),
zoneConfig
);
ObjectProvider<LoadBalancerCacheManager> cacheManagerProvider = context
.getBeanProvider(LoadBalancerCacheManager.class);
if (cacheManagerProvider.getIfAvailable() != null) {
return new CachingServiceInstanceListSupplier(
delegate,
cacheManagerProvider.getIfAvailable()
);
}
return delegate;
}
}
2. 實現下一次重試的時候,如果存在其他實例,則一定會重試與本次不同的其他實例
默認的RoundRobinLoadBalancer
,其中的輪詢position
,是一個Atomic
類型的,在某個微服務的調用請求下,所有線程,所有請求共用(調用其他的每個微服務會創建一個RoundRobinLoadBalancer
)。在使用的時候,會有這樣的一個問題:
- 假設某個微服務有兩個實例,實例 A 和實例 B
- 某次請求 X 發往實例 A,position = position + 1
- 在請求沒有返回時,請求 Y 到達,發往實例 B,position = position + 1
- 請求 A 失敗,重試,重試的實例還是實例 A
這樣在重試的情況下,某個請求的重試可能會發送到上一次的實例進行重試,這不是我們想要的。針對這個,我提了個Issue:Enhance RoundRoubinLoadBalancer position。我修改的思路是,我們需要一個單次請求隔離的position
,這個position
對於實例個數取餘得出請求要發往的實例。那麼如何進行請求隔離呢?
首先想到的是線程隔離,但是這個是不行的。Spring Cloud LoadBalancer 底層運用了 reactor 框架,導致實際承載選擇實例的線程,不是業務線程,而是 reactor 裏面的線程池,如圖所示:
所以,不能用ThreadLocal
的方式實現position
。
由於我們用到了sleuth
,一般請求的context
會傳遞其中的traceId
,我們根據這個traceId
區分不同的請求,實現我們的 LoadBalancer
:
RoundRobinBaseOnTraceIdLoadBalancer
//這個超時時間,需要設置的比你的請求的 connectTimeout + readTimeout 長
private final LoadingCache<Long, AtomicInteger> positionCache = Caffeine.newBuilder().expireAfterWrite(3, TimeUnit.SECONDS).build(k -> new AtomicInteger(ThreadLocalRandom.current().nextInt(0, 1000)));
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> serviceInstances) {
if (serviceInstances.isEmpty()) {
log.warn("No servers available for service: " + this.serviceId);
return new EmptyResponse();
}
//如果沒有 traceId,就生成一個新的,但是最好檢查下爲啥會沒有
//是不是 MQ 消費這種沒有主動生成 traceId 的情況,最好主動生成下。
Span currentSpan = tracer.currentSpan();
if (currentSpan == null) {
currentSpan = tracer.newTrace();
}
long l = currentSpan.context().traceId();
int seed = positionCache.get(l).getAndIncrement();
return new DefaultResponse(serviceInstances.get(seed % serviceInstances.size()));
}
3. 替換默認的負載均衡相關 Bean 實現
我們要用上面的兩個類替換默認的實現,先編寫一個配置類:
public class CommonLoadBalancerConfig {
private volatile boolean isValid = false;
/**
* 同步環境下的ServiceInstanceListSupplier
* SameZoneOnlyServiceInstanceListSupplier限制僅返回同一個zone下的實例(注意)
* CachingServiceInstanceListSupplier啓用緩存,不每次訪問eureka請求實例列表
*
* @param discoveryClient
* @param env
* @param zoneConfig
* @param context
* @return
*/
@Bean
@Order(Integer.MIN_VALUE)
public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
DiscoveryClient discoveryClient, Environment env,
LoadBalancerZoneConfig zoneConfig,
ApplicationContext context) {
isValid = true;
ServiceInstanceListSupplier delegate = new SameZoneOnlyServiceInstanceListSupplier(
new DiscoveryClientServiceInstanceListSupplier(discoveryClient, env),
zoneConfig
);
ObjectProvider<LoadBalancerCacheManager> cacheManagerProvider = context
.getBeanProvider(LoadBalancerCacheManager.class);
if (cacheManagerProvider.getIfAvailable() != null) {
return new CachingServiceInstanceListSupplier(
delegate,
cacheManagerProvider.getIfAvailable()
);
}
return delegate;
}
@Bean
public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(
Environment environment,
ServiceInstanceListSupplier serviceInstanceListSupplier,
Tracer tracer) {
if (!isValid) {
throw new IllegalStateException("should use the ServiceInstanceListSupplier in this configuration, please check config");
}
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new RoundRobinBaseOnTraceIdLoadBalancer(
name,
serviceInstanceListSupplier,
tracer
);
}
}
然後,指定默認的負載均衡配置採取這個配置, 通過註解:
@LoadBalancerClients(defaultConfiguration = {CommonLoadBalancerConfig.class})