深入研究Spring Cloud負載平衡器 – Piotr

banq發表於2020-06-04

Spring Cloud當前即將發生大的變化。雖然幾乎所有的Spring Cloud Netflix元件都將在下一版本中刪除,但最大的變化似乎是將Ribbon客戶端替換為Spring Cloud Load Balancer。當前,關於Spring Cloud Load Balancer的線上文章很少。實際上,該元件仍在積極開發中,因此我們可以在不久的將來期待一些新功能。Netflix Ribbon客戶端是穩定的解決方案,但不幸的是它不再開發。但是,它仍被用作所有Spring Cloud專案中的預設負載均衡器,並具有許多有趣的功能,例如與斷路器整合或根據來自服務例項的平均響應時間進行負載均衡。目前,Spring Cloud Load Balancer尚不提供此類功能,但是我們可以建立一些自定義程式碼來實現它們。在本文中,我將向您展示如何將spring-cloud-loadbalancer模組與RestTemplate 對於應用程式之間的通訊,如何基於平均響應時間實現自定義負載均衡器,最後如何提供服務地址的靜態列表。

您可以在我的GitHub儲存庫https://github.com/piomin/course-spring-microservices.git中找到與本文相關的原始碼片段。該儲存庫也用於我的線上課程,因此我決定透過新示例對其進行擴充套件。所有必需的更改都在該儲存庫中的目錄內部通訊/內部呼叫者服務中執行。該程式碼用Kotlin編寫。這裡有三個應用程式,它們是示例系統的一部分:discovery-server(Spring Cloud Netflix Eureka),inter-callme-service(公開REST API的Spring Boot應用程式),最後是inter-caller-service(呼叫公開的端點的Spring Boot應用程式inter-callme-service)。

如何開始
為了為我們的應用程式啟用Spring Cloud Load Balancer,我們首先需要包括以下啟動器對Maven的依賴關係(此模組也可以與其他一些Spring Cloud啟動器一起包含在內)。

dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

由於Ribbon仍用作應用程式之間基於REST的通訊的預設客戶端負載平衡器,因此我們需要在應用程式屬性中將其禁用。這是application.yml檔案的片段。

spring:
  application:
    name: inter-caller-service
  cloud:
    loadbalancer:
      ribbon:
        enabled: false

對於發現整合,我們還需要包含spring-cloud-starter-netflix-eureka-client。要RestTemplate與客戶端負載平衡器一起使用,我們應該定義此類bean並使用進行註釋@LoadBalanced。正如你在下面的程式碼我還設定攔截上RestTemplate,但更多的是在接下來的一節。

@Bean
@LoadBalanced
fun template(): RestTemplate = RestTemplateBuilder()
        .interceptors(responseTimeInterceptor())
        .build()


使流量適應平均響應時間
Spring Cloud Load Balancer提供了簡單的迴圈規則,可在單個服務的多個例項之間實現負載平衡。我們的目標是實施一個規則,該規則可測量每個應用程式的響應時間並根據該時間給出權重。響應時間越長,重量就越少。該規則應隨機選擇一個可能性,該可能性由其權重決定。要記錄每個呼叫的響應時間,我們需要設定已經提到的實現的攔截器ClientHttpRequestInterceptor。攔截器在每個請求上執行。由於實施非常典型,因此需要一行解釋。我從Slf4J中存在的執行緒作用域變數獲取目標應用程式的地址MDC。當然,我也可以基於實現一個簡單的執行緒範圍上下文ThreadLocal,但是MDC 此處僅用於簡化。

class ResponseTimeInterceptor(private val responseTimeHistory: ResponseTimeHistory) : ClientHttpRequestInterceptor {
 
    private val logger: Logger = LoggerFactory.getLogger(ResponseTimeInterceptor::class.java)
 
    override fun intercept(request: HttpRequest, array: ByteArray,
                           execution: ClientHttpRequestExecution): ClientHttpResponse {
        val startTime: Long = System.currentTimeMillis()
        val response: ClientHttpResponse = execution.execute(request, array) // 1
        val endTime: Long = System.currentTimeMillis()
        val responseTime: Long = endTime - startTime
        logger.info("Response time: instance->{}, time->{}", MDC.get("address"), responseTime)
        responseTimeHistory.addNewMeasure(MDC.get("address"), responseTime) // 2
        return response
    }
}


當然,計算平均響應時間只是我們工作的一部分。最重要的是自定義負載均衡器的實現,如下所示。它應該實現interface ReactorServiceInstanceLoadBalancer。它需要注入ServiceInstanceListSupplierbean來以重寫方法獲取給定服務的可用例項列表choose。選擇正確的例項時,我們正在分析ResponseTimeHistoryby 儲存的每個例項的平均響應時間ResponseTimeInterceptor。首先,我們的負載均衡器的作用就像簡單的迴圈輪詢。

class WeightedTimeResponseLoadBalancer(
        private val serviceInstanceListSupplierProvider: ObjectProvider<ServiceInstanceListSupplier>,
        private val serviceId: String,
        private val responseTimeHistory: ResponseTimeHistory) : ReactorServiceInstanceLoadBalancer {
 
    private val logger: Logger = LoggerFactory.getLogger(WeightedTimeResponseLoadBalancer::class.java)
    private val position: AtomicInteger = AtomicInteger()
 
    override fun choose(request: Request<*>?): Mono<Response<ServiceInstance>> {
        val supplier: ServiceInstanceListSupplier = serviceInstanceListSupplierProvider
                .getIfAvailable { NoopServiceInstanceListSupplier() }
        return supplier.get().next()
                .map { serviceInstances: List<ServiceInstance> -> getInstanceResponse(serviceInstances) }
    }
 
    private fun getInstanceResponse(instances: List<ServiceInstance>): Response<ServiceInstance> {
        return if (instances.isEmpty()) {
            EmptyResponse()
        } else {
            val address: String? = responseTimeHistory.getAddress(instances.size)
            val pos: Int = position.incrementAndGet()
            var instance: ServiceInstance = instances[pos % instances.size]
            if (address != null) {
                val found: ServiceInstance? = instances.find { "${it.host}:${it.port}" == address }
                if (found != null)
                    instance = found
            }
            logger.info("Current instance: [address->{}:{}, stats->{}ms]", instance.host, instance.port,
                    responseTimeHistory.stats["${instance.host}:${instance.port}"])
            MDC.put("address", "${instance.host}:${instance.port}")
            DefaultResponse(instance)
        }
    }
}


這是ResponseTimeHistorybean 的實現,它負責儲存度量並根據計算的權重選擇服務例項。

class ResponseTimeHistory(private val history: MutableMap<String, Queue<Long>> = mutableMapOf(),
                          val stats: MutableMap<String, Long> = mutableMapOf()) {
 
    private val logger: Logger = LoggerFactory.getLogger(ResponseTimeHistory::class.java)
 
    fun addNewMeasure(address: String, measure: Long) {
        var list: Queue<Long>? = history[address]
        if (list == null) {
            history[address] = LinkedList<Long>()
            list = history[address]
        }
        logger.info("Adding new measure for->{}, measure->{}", address, measure)
        if (measure == 0L)
            list!!.add(1L)
        else list!!.add(measure)
        if (list.size > 9)
            list.remove()
        stats[address] = countAvg(address)
        logger.info("Counting avg for->{}, stat->{}", address, stats[address])
    }
 
    private fun countAvg(address: String): Long {
        val list: Queue<Long>? = history[address]
        return list?.sum()?.div(list.size) ?: 0
    }
 
    fun getAddress(numberOfInstances: Int): String? {
        if (stats.size < numberOfInstances)
            return null
        var sum: Long = 0
        stats.forEach { sum += it.value }
        var r: Long = Random.nextLong(100)
        var current: Long = 0
        stats.forEach {
            val weight: Long = (sum - it.value)*100 / sum
            logger.info("Weight for->{}, value->{}, random->{}", it.key, weight, r)
            current += weight
            if (r <= current)
                return it.key
        }
        return null
    }
 
}


自定義LOADBALANCER
我們的加權響應時間規則機制的實現已經準備就緒,因此最後一步是將其應用於Spring Cloud Load Balancer。為此,我們需要使用ReactorLoadBalancerbean宣告建立一個專用的配置類,如下所示。

class CustomCallmeClientLoadBalancerConfiguration(private val responseTimeHistory: ResponseTimeHistory) {
 
    @Bean
    fun loadBalancer(environment: Environment, loadBalancerClientFactory: LoadBalancerClientFactory):
            ReactorLoadBalancer<ServiceInstance> {
        val name: String? = environment.getProperty("loadbalancer.client.name")
        return WeightedTimeResponseLoadBalancer(
                loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier::class.java),
                name!!, responseTimeHistory)
    }
}

可以使用註釋將自定義配置傳遞給負載均衡器@LoadBalancerClient。客戶端名稱應與發現中註冊的名稱相同。目前,這部分程式碼已在GitHub儲存庫中註釋掉,因此,如果要啟用它以進行測試,則只需取消註釋即可。

@SpringBootApplication
@LoadBalancerClient(value = "inter-callme-service", configuration = [CustomCallmeClientLoadBalancerConfiguration::class])
class InterCallerServiceApplication {
 
    @Bean
    fun responseTimeHistory(): ResponseTimeHistory = ResponseTimeHistory()
 
    @Bean
    fun responseTimeInterceptor(): ResponseTimeInterceptor = ResponseTimeInterceptor(responseTimeHistory())
 
    // THE REST OF IMPLEMENTATION...
}


定製提供者例項列表
當前,Spring Cloud Load Balancer不支援在配置屬性中設定的靜態例項列表(與Netflix Ribbon不同)。我們可以輕鬆新增這樣的機制。如下所示,將定義每個服務的例項的靜態列表。

spring:
  application:
    name: inter-caller-service
  cloud:
    loadbalancer:
      ribbon:
        enabled: false
      instances:
        - name: inter-callme-service
          servers: localhost:59600, localhost:59800


第一步,我們應該定義一個實現介面ServiceInstanceListSupplier並覆蓋兩個方法的類:getServiceId()和get()。的以下實現ServiceInstanceListSupplier從應用程式屬性到獲取服務地址列表@ConfigurationProperties。

class StaticServiceInstanceListSupplier(private val properties: LoadBalancerConfigurationProperties,
                                        private val environment: Environment) : ServiceInstanceListSupplier {
 
    override fun getServiceId(): String = environment.getProperty("loadbalancer.client.name")!!
 
    override fun get(): Flux<MutableList<ServiceInstance>> {
        val serviceConfig: LoadBalancerConfigurationProperties.ServiceConfig? =
                properties.instances.find { it.name == serviceId }
        val list: MutableList<ServiceInstance> =
                serviceConfig!!.servers.split(",", ignoreCase = false, limit = 0)
                        .map { StaticServiceInstance(serviceId, it) }.toMutableList()
        return Flux.just(list)
    }
 
}

這是帶有屬性的配置類的實現。

@Configuration
@ConfigurationProperties("spring.cloud.loadbalancer")
class LoadBalancerConfigurationProperties {
 
    val instances: MutableList<ServiceConfig> = mutableListOf()
 
    class ServiceConfig {
        var name: String = ""
        var servers: String = ""
    }
 
}

與前面的示例相同,我們還應該ServiceInstanceListSupplier在自定義配置類中將Bean的實現註冊為Bean。

class CustomCallmeClientLoadBalancerConfiguration) {
 
    @Bean
    fun discoveryClientServiceInstanceListSupplier(discoveryClient: ReactiveDiscoveryClient, environment: Environment,
        zoneConfig: LoadBalancerZoneConfig, context: ApplicationContext,
        properties: LoadBalancerConfigurationProperties): ServiceInstanceListSupplier {
        val delegate = StaticServiceInstanceListSupplier(properties, environment)
        val cacheManagerProvider = context.getBeanProvider(LoadBalancerCacheManager::class.java)
        return if (cacheManagerProvider.ifAvailable != null) {
            CachingServiceInstanceListSupplier(delegate, cacheManagerProvider.ifAvailable)
        } else delegate
    }
}


測試中
要測試針對本文目的實現的解決方案,您應該:

  1. 執行發現伺服器例項(僅在StaticServiceInstanceListSupplier禁用時)
  2. 執行兩個例項inter-callme-service(對於一個選定的例項,使用VM引數啟用隨機延遲-Dspring.profiles.active=delay)
  3. 執行的例項inter-caller-service,該例項在埠上可用8080
  4. 例如,使用命令將一些測試請求傳送到呼叫者間服務 curl -X POST http://localhost:8080/caller/random-send/12345

下圖顯示了我們的測試場景。

深入研究Spring Cloud負載平衡器 – Piotr

結論
當前,Spring Cloud Load Balancer並沒有提供像Netflix Ribbon客戶端那樣的用於服務間通訊的有趣功能。當然,Spring Team仍在積極開發它。好訊息是我們可以輕鬆自定義Spring Cloud Load Balancer來新增一些自定義功能。在本文中,我演示瞭如何提供更高階的負載平衡演算法或如何建立自定義例項列表供應商。


 

相關文章