SpringCloud升級之路2020.0.x版-23.訂製Spring Cloud LoadBalancer

乾貨滿滿張雜湊發表於2021-08-28

本系列程式碼地址:https://github.com/HashZhang/spring-cloud-scaffold/tree/master/spring-cloud-iiford

我們使用 Spring Cloud 官方推薦的 Spring Cloud LoadBalancer 作為我們的客戶端負載均衡器。上一節我們瞭解了 Spring Cloud LoadBalancer 的結構,接下來我們來說一下我們在使用 Spring Cloud LoadBalancer 要實現的功能:

  1. 我們要實現不同叢集之間不互相呼叫,通過例項的metamap中的zone配置,來區分不同叢集的例項。只有例項的metamap中的zone配置一樣的例項才能互相呼叫。這個通過實現自定義的 ServiceInstanceListSupplier 即可實現
  2. 負載均衡的輪詢演算法,需要請求與請求之間隔離,不能共用同一個 position 導致某個請求失敗之後的重試還是原來失敗的例項。上一節看到的預設的 RoundRobinLoadBalancer 是所有執行緒共用同一個原子變數 position 每次請求原子加 1。在這種情況下會有問題:假設有微服務 A 有兩個例項:例項 1 和例項 2。請求 A 到達時,RoundRobinLoadBalancer 返回例項 1,這時有請求 B 到達,RoundRobinLoadBalancer 返回例項 2。然後如果請求 A 失敗重試,RoundRobinLoadBalancer 又返回了例項 1。這不是我們期望看到的。

針對這兩個功能,我們分別編寫自己的實現。

image

Spring Cloud LoadBalancer 中的 zone 配置

Spring Cloud LoadBalancer 定義了 LoadBalancerZoneConfig

public class LoadBalancerZoneConfig {
    //標識當前負載均衡器處於哪一個 zone
	private String zone;
	public LoadBalancerZoneConfig(String zone) {
		this.zone = zone;
	}
	public String getZone() {
		return zone;
	}
	public void setZone(String zone) {
		this.zone = zone;
	}
}

如果沒有引入 Eureka 相關依賴,則這個 zone 通過 spring.cloud.loadbalancer.zone 配置:
LoadBalancerAutoConfiguration

@Bean
@ConditionalOnMissingBean
public LoadBalancerZoneConfig zoneConfig(Environment environment) {
	return new LoadBalancerZoneConfig(environment.getProperty("spring.cloud.loadbalancer.zone"));
}

如果引入了 Eureka 相關依賴,則如果在 Eureka 後設資料配置了 zone,則這個 zone 會覆蓋 Spring Cloud LoadBalancer 中的 LoadBalancerZoneConfig

EurekaLoadBalancerClientConfiguration

@PostConstruct
public void postprocess() {
	if (!StringUtils.isEmpty(zoneConfig.getZone())) {
		return;
	}
	String zone = getZoneFromEureka();
	if (!StringUtils.isEmpty(zone)) {
		if (LOG.isDebugEnabled()) {
			LOG.debug("Setting the value of '" + LOADBALANCER_ZONE + "' to " + zone);
		}
		//設定 `LoadBalancerZoneConfig`
		zoneConfig.setZone(zone);
	}
}

private String getZoneFromEureka() {
	String zone;
	//是否配置了 spring.cloud.loadbalancer.eureka.approximateZoneFromHostname 為 true
	boolean approximateZoneFromHostname = eurekaLoadBalancerProperties.isApproximateZoneFromHostname();
	//如果配置了,則嘗試從 Eureka 配置的 host 名稱中提取
    //實際就是以 . 分割 host,然後第二個就是 zone
    //例如 www.zone1.com 就是 zone1
	if (approximateZoneFromHostname && eurekaConfig != null) {
		return ZoneUtils.extractApproximateZone(this.eurekaConfig.getHostName(false));
	}
	else {
	    //否則,從 metadata map 中取 zone 這個 key
		zone = eurekaConfig == null ? null : eurekaConfig.getMetadataMap().get("zone");
		//如果這個 key 不存在,則從配置中以 region 從 zone 列表取第一個 zone 作為當前 zone
		if (StringUtils.isEmpty(zone) && clientConfig != null) {
			String[] zones = clientConfig.getAvailabilityZones(clientConfig.getRegion());
			// Pick the first one from the regions we want to connect to
			zone = zones != null && zones.length > 0 ? zones[0] : null;
		}
		return zone;
	}
}

實現 SameZoneOnlyServiceInstanceListSupplier

為了實現通過 zone 來過濾同一 zone 下的例項,並且絕對不會返回非同一 zone 下的例項,我們來編寫程式碼:

SameZoneOnlyServiceInstanceListSupplier

/**
 * 只返回與當前例項同一個 Zone 的服務例項,不同 zone 之間的服務不互相呼叫
 */
public class SameZoneOnlyServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier {
    /**
     * 例項後設資料 map 中表示 zone 配置的 key
     */
    private final String ZONE = "zone";
    /**
     * 當前 spring cloud loadbalancer 的 zone 配置
     */
    private final LoadBalancerZoneConfig zoneConfig;
    private String zone;

    public SameZoneOnlyServiceInstanceListSupplier(ServiceInstanceListSupplier delegate, LoadBalancerZoneConfig zoneConfig) {
        super(delegate);
        this.zoneConfig = zoneConfig;
    }

    @Override
    public Flux<List<ServiceInstance>> get() {
        return getDelegate().get().map(this::filteredByZone);
    }

    //通過 zoneConfig 過濾
    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;
            }
        }
        /**
         * @see ZonePreferenceServiceInstanceListSupplier 在沒有相同zone例項的時候返回的是所有例項
         * 我們這裡為了實現不同 zone 之間不互相呼叫需要返回空列表
         */
        return List.of();
    }

    //讀取例項的 zone,沒有配置則為 null
    private String getZone(ServiceInstance serviceInstance) {
        Map<String, String> metadata = serviceInstance.getMetadata();
        if (metadata != null) {
            return metadata.get(ZONE);
        }
        return null;
    }
}

image

在之前章節的講述中,我們提到了我們使用 spring-cloud-sleuth 作為鏈路追蹤庫。我們想可以通過其中的 traceId,來區分究竟是否是同一個請求。

RoundRobinWithRequestSeparatedPositionLoadBalancer

//一定必須是實現ReactorServiceInstanceLoadBalancer
//而不是ReactorLoadBalancer<ServiceInstance>
//因為註冊的時候是ReactorServiceInstanceLoadBalancer
@Log4j2
public class RoundRobinWithRequestSeparatedPositionLoadBalancer implements ReactorServiceInstanceLoadBalancer {
    private final ServiceInstanceListSupplier serviceInstanceListSupplier;
    //每次請求算上重試不會超過1分鐘
    //對於超過1分鐘的,這種請求肯定比較重,不應該重試
    private final LoadingCache<Long, AtomicInteger> positionCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES)
            //隨機初始值,防止每次都是從第一個開始呼叫
            .build(k -> new AtomicInteger(ThreadLocalRandom.current().nextInt(0, 1000)));
    private final String serviceId;
    private final Tracer tracer;


    public RoundRobinWithRequestSeparatedPositionLoadBalancer(ServiceInstanceListSupplier serviceInstanceListSupplier, String serviceId, Tracer tracer) {
        this.serviceInstanceListSupplier = serviceInstanceListSupplier;
        this.serviceId = serviceId;
        this.tracer = tracer;
    }

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        return serviceInstanceListSupplier.get().next().map(serviceInstances -> getInstanceResponse(serviceInstances));
    }

    private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> serviceInstances) {
        if (serviceInstances.isEmpty()) {
            log.warn("No servers available for service: " + this.serviceId);
            return new EmptyResponse();
        }
        return getInstanceResponseByRoundRobin(serviceInstances);
    }

    private Response<ServiceInstance> getInstanceResponseByRoundRobin(List<ServiceInstance> serviceInstances) {
        if (serviceInstances.isEmpty()) {
            log.warn("No servers available for service: " + this.serviceId);
            return new EmptyResponse();
        }
        //為了解決原始演算法不同呼叫併發可能導致一個請求重試相同的例項
        Span currentSpan = tracer.currentSpan();
        if (currentSpan == null) {
            currentSpan = tracer.newTrace();
        }
        long l = currentSpan.context().traceId();
        AtomicInteger seed = positionCache.get(l);
        int s = seed.getAndIncrement();
        int pos = s % serviceInstances.size();
        log.info("position {}, seed: {}, instances count: {}", pos, s, serviceInstances.size());
        return new DefaultResponse(serviceInstances.stream()
                //例項返回列表順序可能不同,為了保持一致,先排序再取
                .sorted(Comparator.comparing(ServiceInstance::getInstanceId))
                .collect(Collectors.toList()).get(pos));
    }
}

image

在上一節,我們提到了可以通過 @LoadBalancerClients 註解配置預設的負載均衡器配置,我們這裡就是通過這種方式進行配置。首先在 spring.factories 中新增自動配置類:

spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.github.hashjang.spring.cloud.iiford.service.common.auto.LoadBalancerAutoConfiguration

然後編寫這個自動配置類,其實很簡單,就是新增一個 @LoadBalancerClients 註解,設定預設配置類:

LoadBalancerAutoConfiguration

@Configuration(proxyBeanMethods = false)
@LoadBalancerClients(defaultConfiguration = DefaultLoadBalancerConfiguration.class)
public class LoadBalancerAutoConfiguration {
}

編寫這個預設配置類,將上面我們實現的兩個類,組裝進去:

DefaultLoadBalancerConfiguration

@Configuration(proxyBeanMethods = false)
public class DefaultLoadBalancerConfiguration {

    @Bean
    public ServiceInstanceListSupplier serviceInstanceListSupplier(
            DiscoveryClient discoveryClient,
            Environment env,
            ConfigurableApplicationContext context,
            LoadBalancerZoneConfig zoneConfig
    ) {
        ObjectProvider<LoadBalancerCacheManager> cacheManagerProvider = context
                .getBeanProvider(LoadBalancerCacheManager.class);
        return  //開啟服務例項快取
                new CachingServiceInstanceListSupplier(
                        //只能返回同一個 zone 的服務例項
                        new SameZoneOnlyServiceInstanceListSupplier(
                                //啟用通過 discoveryClient 的服務發現
                                new DiscoveryClientServiceInstanceListSupplier(
                                        discoveryClient, env
                                ),
                                zoneConfig
                        )
                        , cacheManagerProvider.getIfAvailable()
                );
    }

    @Bean
    public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(
            Environment environment,
            ServiceInstanceListSupplier serviceInstanceListSupplier,
            Tracer tracer
    ) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new RoundRobinWithRequestSeparatedPositionLoadBalancer(
                serviceInstanceListSupplier,
                name,
                tracer
        );
    }
}

這樣,我們就實現了自定義的負載均衡器。也理解了 Spring Cloud LoadBalancer 的使用。

我們這一節詳細分析在我們專案中使用 Spring Cloud LoadBalancer 要實現的功能,實現了自定義的負載均衡器,也理解了 Spring Cloud LoadBalancer 的使用。下一節我們使用單元測試驗證我們要實現的這些功能是否有效。

微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer

相關文章