Ribbon原理解析

1. Introduction

ribbon是spring cloud netflix全家桶的一员,主要负责客户端的负载均衡。对于netflix全家桶,大家工作接触的比较多的是eureka, hystrix和feign,对于ribbon一般不是那么熟悉。本文的主要目的是通过分析源码,带大家深入了解Ribbon的工作原理,有助于工作上更好的运用Ribbon。

2. What is Ribbon

在微服务架构下,一个服务调用过程可以分为3步(简化的理想流程,不考虑cache等因素):

  1. 服务发现:通过eureka获得所有提供服务的Server地址,在微服务架构下,通常会有多个Server同时提供服务,所以这步的返回值通常是Server列表
  2. 负载均衡:从返回的Server列表中,选择一个Server
  3. 远程调用:通过feign的动态代理机制进行远程调用

其中第2步的工作就是由Ribbon完成。有同学可能会觉得客户端的负载均衡比较简单,没必要单独作为一个组件。其实不然,在微服务架构下,实现客户端负载均衡的复杂度主要体现在两方面:

多可用区+多地域的生产环境

地域(简称Region)是指物理的数据中心。可用区(简称Zone)是指在同一地域内,电力和网络互相独立的物理区域。同一个地域内的可用区之间使用低时延链路相连。

若client/server所处的可用区/地域不同,网络延时也不同:

  • 同一可用区内的网络延时最低,可忽略不计
  • 同一地域下,不同可用区之间的网络延时可以达到~1ms级别,具体看可用区之间的物理距离
  • 不同地域之间的网络延时,可以达到几十ms级别,具体看地域之间的物理距离

物理知识:光在真空中传播速度是30万km/hour,在光纤中的传播速度大概是20万km/hour,如果两个zone之间相隔100km,那光纤传输就需要0.5ms,再加上硬件设备处理信号的延时,所以不同可用区之间的网络延时可以达到~1ms级别。同理类推,可以得知为何不同地域之间的网络延时可以达到几十ms的数量级

出于容灾考虑,大部分互联网公司的业务会部署在多个可用区甚至多个地域。以我司为例,线上业务主要部署在阿里云华北地域的3个可用区内。所以一个完善的负载均衡机制必须考虑多可用区+地域的运行环境,尽最大可能保证网络调用发生在同一个可用区,以达到最小的网络延时。

高可用性

微服务架构下,Server可能由于各种原因而不可用,比如网络问题,服务过载,依赖服务不可用导致的级联故障等等。出于可用性考虑,负载均衡要能够及时过滤不可用的Server。

3. Main Components of Ribbon

Ribbon的核心模块主要有4个:

  • Rule:表示负载均衡的具体规则(或策略),比如round robin
  • Ping:负责健康检测(Health Check)的模块,ribbon会启动一个后台线程执行健康检测
  • ServerList:静态或者动态的服务器列表,如果是动态列表,ribbon会启动一个后台线程定期从指定数据源(比如eureka)更新服务器列表
  • LoadBalancer:整合以上模块,提供完整的负载均衡服务

下面就以LoadBalancer为入口,结合源代码,依次剖析各个模块的工作原理以及相关的参数。

3.1 ILoadBalancer

ILoadBalancer接口定义了所有LoadBalancer子类需要实现的方法列表(如下图,有删减):

1
2
3
4
// 参数key可以是任何对象,具体如何运用取决于子类的实现
Server chooseServer(Object key)
List<Server> getReachableServers()
List<Server> getAllServers()

其中最重要的就是chooseServer方法,也是负载均衡的核心。

3.2 BaseLoadBalancer

BaseLoadBalancer是ILoadBalancer的一个basic实现,允许用户自定义健康检测机制(IPing & IPingStrategy),和负载均衡策略(IRule)。

要理解BaseLoadBalancer,首先就要理解IRule和IPing/IPingStrategy。

3.2.1 IRule

IRule接口表示负载均衡的规则或者策略,比如round robin或response time based等等。IRule的接口定义比较简单,包含如下方法:

1
2
3
4
public Server choose(Object key);
// rule一般需要通过LoadBalancer获取一些统计信息
public void setLoadBalancer(ILoadBalancer lb);
public ILoadBalancer getLoadBalancer();

目前IRule的常用实现类有:

  • RoundRobinRule:经典的round robin策略,它通常被用作默认规则或更高级规则的后备规则
  • AvailabilityFilteringRule:在round robin的基础上,剔除不可用的节点(可用的定义参见下文源码分析)
  • WeightedResponseTimeRule:根据response time赋予不同Server不同的权重,从而实现weighted round robin

我们生产环境上使用的是AvailabilityFilteringRule,所以就先看看它的源代码:

1
2
3
4
5
6
7
8
9
10
11
public Server choose(Object key) {
int count = 0;
Server server = roundRobinRule.choose(key); // 1
while (count++ <= 10) { // 3
if (predicate.apply(new PredicateKey(server))) { // 2
return server;
}
server = roundRobinRule.choose(key);
}
return super.choose(key);
}

代码讲解:

  1. 首先获取round robin规则下的下一个server
  2. 通过predicate断言,判断server是否可用
  3. 如果可用,直接返回该server
  4. 否则继续round robin,并检查可用性
  5. 若round robin 10次,还未找到可用的节点,退化到父类的负载均衡算法。该措施是为了保证方法一定能快速返回,不会因为大面积的server不可用而变得缓慢

值得一提的是第2步,predicate的类型为AvailabilityPredicate,其apply方法封装了server是否可用的具体逻辑,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean apply(@Nullable PredicateKey input) {
LoadBalancerStats stats = getLBStats(); // 1
if (stats == null) {
return true;
}
return !shouldSkipServer(stats.getSingleServerStat(input.getServer())); // 2
}


private boolean shouldSkipServer(ServerStats stats) {
if (stats.isCircuitBreakerTripped()
|| stats.getActiveRequestsCount() >= activeConnectionsLimit.get()) { // 3
return true;
}
return false;
}

代码讲解:

  1. 获得load balancer运行时的统计信息
  2. 通过getSingleServerStat方法获得指定server的统计信息
  3. 判断server是否可用的条件有2个,是否熔断+是否过载,具体来说:
    1. stats.isCircuitBreakerTripped():server是否熔断,判断依据是连续的请求失败次数是否超过配置的阈值,配置项为niws.loadbalancer.<clientName>.connectionFailureCountThreshold
    2. stats.getActiveRequestsCount() >= activeConnectionsLimit.get(): server的负载是否超过了配置的阈值,负载的衡量指标是并发请求数(Active Requests),阈值的配置项是<clientName>.<clientConfigNameSpace>.ActiveConnectionsLimit

3.2.2 IPing

IPing定义了Health Check子类必须实现的接口,如下:

1
boolean isAlive(Server server);

IPing的常用实现类有:

  • DummyPing: 什么也不做
  • PingUrl:根据配置的url,发起http请求,是标准的health check模式
  • NIWDiscoveryPing:依赖服务发现机制(比如eureka)的in-memory cache,判断server是否alive。相比PingUrl,NIWDiscoveryPing效率更高,因为并不需要发起实际的http请求

3.2.3 IPingStrategy

IPingStrategy接口定义的是,给定IPing和Server列表,如何检测所有Server列表,接口如下:

1
boolean[] pingServers(IPing ping, Server[] servers)

目前IPingStrategy的实现类只有SerialPingStrategy,实现了串行的执行策略,其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean[] pingServers(IPing ping, Server[] servers) {
int numCandidates = servers.length;
boolean[] results = new boolean[numCandidates];


for (int i = 0; i < numCandidates; i++) {
results[i] = false; /* Default answer is DEAD. */
try {
if (ping != null) {
results[i] = ping.isAlive(servers[i]);
}
} catch (Exception e) {
logger.error("Exception while pinging Server: '{}'", servers[i], e);
}
}
return results;
}

要注意SerialPingStrategy 不适合和PingUrl搭配使用,因为PingUrl会触发一次真正的远程网络调用,如果server很多,串行的http调用会比较耗时,此时更好的做法是使用并行的strategy。netflix官方并没有提供并行的strategy,因为netflix线上采用的是NIWSDiscoveryPing策略,依赖eureka的in-memory cache,不会发起真正网络调用。

Ribbon把Health Check的功能拆分成了IPing和IPingStrategy两个类,分别表示ping策略和执行方式这两个独立的维度,可以组合起来使用。这样设计的好处是,可以大大减少需要创建的类的数量。比如说有M个IPing的子类,N种执行的Strategy。如果不采用这种设计模式,需要M*N个类,采用这种设计模式之后,只需要M+N个类。

3.2.4 BaseLoadBalancer综述

现在来看看BaseLoadBalancer是如何将IRule、IPing和IPingStrategy整合在一起。

BaseLoadBalancer的重要字段如下:

1
2
3
4
5
6
7
8
9
10
11
// 作为构造函数参数传入
IRule rule;
// 作为构造函数参数传入
IPingStrategy pingStrategy
// 作为构造函数参数传入
IPing ping
// 动态维护的所有服务器列表
volatile List<Server> allServerList
// 动态维护的可用服务器列表
volatile List<Server> upServerList

首先来看核心方法chooseServer,可以看出本质上就是调用IRule的choose方法:

1
2
3
4
5
6
7
8
9
10
11
12
public Server chooseServer(Object key) {
if (rule == null) {
return null;
} else {
try {
return rule.choose(key);
} catch (Exception e) {
logger.warn("LoadBalancer [{}]: Error choosing server for key {}", name, key, e);
return null;
}
}
}

其次是健康检测,BaseLoadBalancer在构造函数里会调用setupPingTask(),启动一个定时任务,定时调用IPing更新可用的server列表。定时任务的代码如下(做了一定简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
if (!pingInProgress.compareAndSet(false, true)) { // 1
return;
}


boolean[] results = null;

try {
allServers = allServerList.toArray(new Server[allServerList.size()]);

int numCandidates = allServers.length;
results = pingerStrategy.pingServers(ping, allServers); // 2

final List<Server> newUpList = new ArrayList<Server>();

for (int i = 0; i < numCandidates; i++) {
boolean isAlive = results[i];
Server svr = allServers[i];
if (isAlive) {
newUpList.add(svr);
}
}

upServerList = newUpList; // 3
} finally {
pingInProgress.set(false);
}

代码讲解:

  1. pingInProgress的类型是AtomicBoolean,在任务开始时设置为true,结束时设置为false,这样做可以防止多次执行之间有冲突,是个很常用的小技巧
  2. 调用pingStrategy,返回所有server的可用性
  3. 更新BaseLoadBalancer的upServerList字段,既可用的Server列表

3.3 DynamicServerListLoadBalancer

DynamicServerListLoadBalancer是BaseLoadBalancer的子类,增加了从某个动态源获取Server列表的能力,并支持对返回的Server列表按照预设的规则进行过滤。这些新增的功能通过ServerList类和ServerListFilter类完成。

3.3.1 ServerList

ServerList表示一个从动态数据源获取的服务器列表。动态数据源可以是Eureka,也可以是配置文件。

ServerList的接口定义如下,主要有2个方法,对应初始化和更新:

1
2
public List<T> getInitialListOfServers();
public List<T> getUpdatedListOfServers();

ServerList的实现类包括但不限于:

  • ConfigurationBasedServerList: 从ribbon配置文件中加载Server列表,配置项为 <clientName>.<nameSpace>.listOfServers
  • DiscoveryEnabledNIWSServerList: 从eureka获取提供服务的Server列表,服务名的配置项为<clientName>.<nameSpace>.DeploymentContextBasedVipAddresses

3.3.2 ServerListFilter

ServerListFilter的接口定义比较简单,只有一个方法如下:

1
List<T> getFilteredListOfServers(List<T> servers)

ServerListFilter的常用实现类有:

  • ZoneAffinityServerListFilter: 该Filter允许指定一个zone(通常是java进程所在zone),然后过滤掉不在指定zone的server
  • ServerListSubsetFilter: 在ZoneAffinityServerListFilter 的基础上,随机保留一小部分server。这样做的目的是节省资源,比方说现在有500个server,如果为每个server建立连接池既没有必要也浪费资源。

下面以ZoneAffinityServerListFilter的源码为例,看看一个典型的ServerListFilter是如何实现的。首先看下核心的getFilteredListOfServers方法:

1
2
3
4
5
6
7
8
9
10
public List<T> getFilteredListOfServers(List<T> servers) {
if (zone != null && servers !=null && servers.size() > 0){ // 1
List<T> filteredServers = Lists.newArrayList(Iterables.filter(
servers, this.zoneAffinityPredicate.getServerOnlyPredicate())); // 2
if (shouldEnableZoneAffinity(filteredServers)) { // 3
return filteredServers;
}
}
return servers; // 4
}

代码讲解:

  1. zone是指定的zone,通常会被初始化成程序运行时所在的zone
  2. 使用zoneAffinityPredicate过滤掉不在给定zone的server
  3. 调用shouldEnableZoneAffinity判断过滤后的server是否满足可用性的要求,如果满足直接返回
  4. 如果不满足,则返回过滤前的Server列表(通常包含所有可用区)

现在来看看shouldEnableZoneAffinity的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

private boolean shouldEnableZoneAffinity(List<T> filtered) { // 1
LoadBalancerStats stats = getLoadBalancerStats();
if (stats == null) {
return zoneAffinity;
} else {
ZoneSnapshot snapshot = stats.getZoneSnapshot(filtered); // 2
double loadPerServer = snapshot.getLoadPerServer(); // 3
int instanceCount = snapshot.getInstanceCount(); // 4
int circuitBreakerTrippedCount = snapshot.getCircuitTrippedCount(); // 5
if (((double) circuitBreakerTrippedCount) / instanceCount >= blackOutServerPercentageThreshold.get()
|| loadPerServer >= activeReqeustsPerServerThreshold.get()
|| (instanceCount - circuitBreakerTrippedCount) < availableServersThreshold.get()) { // 6
return false;
} else {
return true;
}

}
}

代码讲解:

  1. filtered表示过滤后的Server列表,由之前的分析可知在同一个可用区里
  2. 计算该可用区的统计信息
  3. 获得该zone的平均负载 (loadPerServer),loadPerServer = active request总数/server总数,比如当前zone还未处理完的requests数量是50,Server数量是10,则loadPerServer = 50 / 10 = 5
  4. 获得该可用区内的Server总数
  5. 获得该可用区内熔断的Server数量,熔断的判断标准是连续失败的请求数是否超过了配置的阈值,配置项为 niws.loadbalancer.<clientName>.connectionFailureCountThreshold
  6. 判断该可用区是否满足高可用的要求,判断条件有
    1. 熔断Server的比例要小于配置的阈值,配置项为<clientName>.<namespace>.zoneAffinity.maxBlackOutServersPercentage,默认为80%
    2. serverPerLoad要小于配置的阈值,配置项为<clientName>.<namespace>.zoneAffinity.maxLoadPerServer,默认为0.6
    3. 除去熔断server,剩余可用的server数量要大于配置的阈值,配置项为<clientName>.<namespace>.zoneAffinity.minAvailableServers,默认为1

3.4 ZoneAwareLoadBalancer

ZoneAwareLoadBalancer是默认的LoadBalancer实现,也是我司生产环境上使用的LoadBalancer。ZoneAwareLoadBalancer继承了DynamicServerListLoadBalancer,并增加了如下主要字段:

1
2
3
// key为zone, value为zone对应的LoadBalancer
ConcurrentHashMap<String, BaseLoadBalancer> balancers

ZoneAwareLoadBalancer的核心逻辑是,剔除所有完全不可用(代码里使用的术语是blackout - 断电)的zone,和一个可用性最差的zone,在剩下的zone里随机选择一个。下面就来结合源代码看下细节实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public Server chooseServer(Object key) {
if (getLoadBalancerStats().getAvailableZones().size() <= 1) { // 1
return super.chooseServer(key);
}

Server server = null;
try {
LoadBalancerStats lbStats = getLoadBalancerStats();
Map<String, ZoneSnapshot> zoneSnapshot = ZoneAvoidanceRule.createSnapshot(lbStats); // 2

Set<String> availableZones = ZoneAvoidanceRule.getAvailableZones(
zoneSnapshot,
triggeringLoad.get(),
triggeringBlackoutPercentage.get()
); // 3

if (availableZones != null && availableZones.size() < zoneSnapshot.keySet().size()) {
String zone = ZoneAvoidanceRule.randomChooseZone(zoneSnapshot, availableZones); // 4
if (zone != null) {
BaseLoadBalancer zoneLoadBalancer = getLoadBalancer(zone); // 5
server = zoneLoadBalancer.chooseServer(key);
}
}
} catch (Exception e) {
logger.error("Error choosing server using zone aware logic for load balancer={}", name, e);
}
if (server != null) {
return server;
} else {
return super.chooseServer(key); // 6
}
}

代码讲解:

  1. 如果可用区只有1个,则直接使用该可用区的lb返回结果
  2. 获取每个zone的统计信息
  3. 调用getAvailableZones方法,返回所有可用的zone。具体来说,方法会先剔除所有停电(blackout)的zone,在剩下的zone里再去掉一个可用性最差的zone,剩余的zone都认为是”可用“的。参数triggeringLoad和triggeringBlackoutPercentage的作用如下:
    1. triggeringBlackoutPercentage用来辅助判断一个zone是否blackout。判断方法是,计算blackout percentage = (Server总数 - 熔断的server数)/ Server总数,如果结果值 > triggeringBlackoutPercentage,则认为zone已经blackout。triggeringBlackoutPercentage的取值来自配置项ZoneAwareNIWSDiscoveryLoadBalancer.<clientName>.avoidZoneWithBlackoutPercetage,默认值为99.99%
    2. triggeringLoad用来辅助寻找可用性最差的zone,可用性的量化指标是loadPerServer(定义参照上文,这里不再赘述)。ribbon会按loadPerServer给所有zone排序,如果负载最大的那个zone的loadPerServer > triggeringLoad,则该zone被剔除。triggeringLoad的取值来自于配置项ZoneAwareNIWSDiscoveryLoadBalancer.<clientName>.triggeringLoadPerServerThreshold,默认值为0.2
  4. 调用randomChooseZone,从可用的zone里随机选择一个zone,如果availableZones未空,则返回null
  5. 获得该zone对应的LoadBalancer,并调用chooseServer
  6. 在正常流程无法得到结果的情况下,退化到父类的chooseServer方法

4. 我司生产环境的Ribbon配置

下面罗列了我司生产环境上关于Ribbon各个模块的配置:

  • IPing: 采用DummyPing,这是因为生产环境的ServerList使用的是DiscoveryEnabledNIWSServerList,它会从Eureka读取服务器列表时,eureka有自己的健康检查机制,为了避免重复工作,所以使用DummyPing
  • IRule: 采用AvailabilityFilteringRule
  • ServerList: 采用YqgZoneAwareServerList,它是DiscoveryEnabledNIWSServerList的一个简单封装,原理基本是一致的,只是修改了如何获取server所在zone的逻辑
  • ServerListFilter:采用ZoneAffinityServerListFilter
  • LoadBalancer:采用ZoneAwareLoadBalancer

相信有了之前的基础,已经不难理解这些类是如何工作在一起,从而实现一个高可用的客户端负载均衡组件。

5. 结束语

至此,加上之前的几篇blog(详见附录),微服务的技术分享系列已经涵盖了spring cloud netflix全家桶中的大部分组件。希望大家有时间都能阅读下,并下载源码系统学习下。提升代码能力,对优秀开源项目进行系统性学习永远是最有效的办法。

6.附录

https://blog.fintopia.tech/60a1e74c2078082a378ec5e5/

https://blog.fintopia.tech/60868c70ce7094706059f126/

https://blog.fintopia.tech/607d1e7ece7094706059f124/

Ribbon UML图