Ribbon原理解析
1. Introduction
ribbon是spring cloud netflix全家桶的一员,主要负责客户端的负载均衡。对于netflix全家桶,大家工作接触的比较多的是eureka, hystrix和feign,对于ribbon一般不是那么熟悉。本文的主要目的是通过分析源码,带大家深入了解Ribbon的工作原理,有助于工作上更好的运用Ribbon。
2. What is Ribbon
在微服务架构下,一个服务调用过程可以分为3步(简化的理想流程,不考虑cache等因素):
- 服务发现:通过eureka获得所有提供服务的Server地址,在微服务架构下,通常会有多个Server同时提供服务,所以这步的返回值通常是Server列表
- 负载均衡:从返回的Server列表中,选择一个Server
- 远程调用:通过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 | // 参数key可以是任何对象,具体如何运用取决于子类的实现 |
其中最重要的就是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 | public Server choose(Object key); |
目前IRule的常用实现类有:
- RoundRobinRule:经典的round robin策略,它通常被用作默认规则或更高级规则的后备规则
- AvailabilityFilteringRule:在round robin的基础上,剔除不可用的节点(可用的定义参见下文源码分析)
- WeightedResponseTimeRule:根据response time赋予不同Server不同的权重,从而实现weighted round robin
我们生产环境上使用的是AvailabilityFilteringRule,所以就先看看它的源代码:
1 | public Server choose(Object key) { |
代码讲解:
- 首先获取round robin规则下的下一个server
- 通过predicate断言,判断server是否可用
- 如果可用,直接返回该server
- 否则继续round robin,并检查可用性
- 若round robin 10次,还未找到可用的节点,退化到父类的负载均衡算法。该措施是为了保证方法一定能快速返回,不会因为大面积的server不可用而变得缓慢
值得一提的是第2步,predicate的类型为AvailabilityPredicate,其apply方法封装了server是否可用的具体逻辑,代码如下:
1 | public boolean apply(@Nullable PredicateKey input) { |
代码讲解:
- 获得load balancer运行时的统计信息
- 通过getSingleServerStat方法获得指定server的统计信息
- 判断server是否可用的条件有2个,是否熔断+是否过载,具体来说:
- stats.isCircuitBreakerTripped():server是否熔断,判断依据是连续的请求失败次数是否超过配置的阈值,配置项为
niws.loadbalancer.<clientName>.connectionFailureCountThreshold
- stats.getActiveRequestsCount() >= activeConnectionsLimit.get(): server的负载是否超过了配置的阈值,负载的衡量指标是并发请求数(Active Requests),阈值的配置项是
<clientName>.<clientConfigNameSpace>.ActiveConnectionsLimit
- stats.isCircuitBreakerTripped():server是否熔断,判断依据是连续的请求失败次数是否超过配置的阈值,配置项为
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 | public boolean[] pingServers(IPing ping, Server[] servers) { |
要注意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 | // 作为构造函数参数传入 |
首先来看核心方法chooseServer,可以看出本质上就是调用IRule的choose方法:
1 | public Server chooseServer(Object key) { |
其次是健康检测,BaseLoadBalancer在构造函数里会调用setupPingTask(),启动一个定时任务,定时调用IPing更新可用的server列表。定时任务的代码如下(做了一定简化):
1 | if (!pingInProgress.compareAndSet(false, true)) { // 1 |
代码讲解:
- pingInProgress的类型是AtomicBoolean,在任务开始时设置为true,结束时设置为false,这样做可以防止多次执行之间有冲突,是个很常用的小技巧
- 调用pingStrategy,返回所有server的可用性
- 更新BaseLoadBalancer的upServerList字段,既可用的Server列表
3.3 DynamicServerListLoadBalancer
DynamicServerListLoadBalancer是BaseLoadBalancer的子类,增加了从某个动态源获取Server列表的能力,并支持对返回的Server列表按照预设的规则进行过滤。这些新增的功能通过ServerList类和ServerListFilter类完成。
3.3.1 ServerList
ServerList表示一个从动态数据源获取的服务器列表。动态数据源可以是Eureka,也可以是配置文件。
ServerList的接口定义如下,主要有2个方法,对应初始化和更新:
1 | public List<T> getInitialListOfServers(); |
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的serverServerListSubsetFilter
: 在ZoneAffinityServerListFilter
的基础上,随机保留一小部分server。这样做的目的是节省资源,比方说现在有500个server,如果为每个server建立连接池既没有必要也浪费资源。
下面以ZoneAffinityServerListFilter的源码为例,看看一个典型的ServerListFilter是如何实现的。首先看下核心的getFilteredListOfServers方法:
1 | public List<T> getFilteredListOfServers(List<T> servers) { |
代码讲解:
- zone是指定的zone,通常会被初始化成程序运行时所在的zone
- 使用zoneAffinityPredicate过滤掉不在给定zone的server
- 调用shouldEnableZoneAffinity判断过滤后的server是否满足可用性的要求,如果满足直接返回
- 如果不满足,则返回过滤前的Server列表(通常包含所有可用区)
现在来看看shouldEnableZoneAffinity的实现:
1 |
|
代码讲解:
- filtered表示过滤后的Server列表,由之前的分析可知在同一个可用区里
- 计算该可用区的统计信息
- 获得该zone的平均负载 (loadPerServer),loadPerServer = active request总数/server总数,比如当前zone还未处理完的requests数量是50,Server数量是10,则loadPerServer = 50 / 10 = 5
- 获得该可用区内的Server总数
- 获得该可用区内熔断的Server数量,熔断的判断标准是连续失败的请求数是否超过了配置的阈值,配置项为
niws.loadbalancer.<clientName>.connectionFailureCountThreshold
- 判断该可用区是否满足高可用的要求,判断条件有
- 熔断Server的比例要小于配置的阈值,配置项为
<clientName>.<namespace>.zoneAffinity.maxBlackOutServersPercentage
,默认为80% - serverPerLoad要小于配置的阈值,配置项为
<clientName>.<namespace>.zoneAffinity.maxLoadPerServer
,默认为0.6 - 除去熔断server,剩余可用的server数量要大于配置的阈值,配置项为
<clientName>.<namespace>.zoneAffinity.minAvailableServers
,默认为1
- 熔断Server的比例要小于配置的阈值,配置项为
3.4 ZoneAwareLoadBalancer
ZoneAwareLoadBalancer是默认的LoadBalancer实现,也是我司生产环境上使用的LoadBalancer。ZoneAwareLoadBalancer继承了DynamicServerListLoadBalancer,并增加了如下主要字段:
1 | // key为zone, value为zone对应的LoadBalancer |
ZoneAwareLoadBalancer的核心逻辑是,剔除所有完全不可用(代码里使用的术语是blackout - 断电)的zone,和一个可用性最差的zone,在剩下的zone里随机选择一个。下面就来结合源代码看下细节实现:
1 | public Server chooseServer(Object key) { |
代码讲解:
- 如果可用区只有1个,则直接使用该可用区的lb返回结果
- 获取每个zone的统计信息
- 调用getAvailableZones方法,返回所有可用的zone。具体来说,方法会先剔除所有停电(blackout)的zone,在剩下的zone里再去掉一个可用性最差的zone,剩余的zone都认为是”可用“的。参数triggeringLoad和triggeringBlackoutPercentage的作用如下:
- triggeringBlackoutPercentage用来辅助判断一个zone是否blackout。判断方法是,计算blackout percentage = (Server总数 - 熔断的server数)/ Server总数,如果结果值 > triggeringBlackoutPercentage,则认为zone已经blackout。triggeringBlackoutPercentage的取值来自配置项
ZoneAwareNIWSDiscoveryLoadBalancer.<clientName>.avoidZoneWithBlackoutPercetage
,默认值为99.99% - triggeringLoad用来辅助寻找可用性最差的zone,可用性的量化指标是loadPerServer(定义参照上文,这里不再赘述)。ribbon会按loadPerServer给所有zone排序,如果负载最大的那个zone的loadPerServer > triggeringLoad,则该zone被剔除。triggeringLoad的取值来自于配置项
ZoneAwareNIWSDiscoveryLoadBalancer.<clientName>.triggeringLoadPerServerThreshold
,默认值为0.2
- triggeringBlackoutPercentage用来辅助判断一个zone是否blackout。判断方法是,计算blackout percentage = (Server总数 - 熔断的server数)/ Server总数,如果结果值 > triggeringBlackoutPercentage,则认为zone已经blackout。triggeringBlackoutPercentage的取值来自配置项
- 调用randomChooseZone,从可用的zone里随机选择一个zone,如果availableZones未空,则返回null
- 获得该zone对应的LoadBalancer,并调用chooseServer
- 在正常流程无法得到结果的情况下,退化到父类的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/