前言
先抛一个问题给我聪明的读者,如果你们使用微服务SpringCloud-Netflix
进行业务开发,那么线上注册中心肯定也是用了集群部署,问题来了:
你了解Eureka注册中心集群如何实现客户端请求负载及故障转移吗?
可以先思考一分钟,我希望你能够带着问题来阅读此篇文章,也希望你看完文章后会有所收获!
背景
前段时间线上Sentry
平台报警,多个业务服务在和注册中心交互时,例如续约和注册表增量拉取等都报了Request execution failed with message : Connection refused
的警告:
![e5b40ca434bda7550906ae068f882f7d.png](https://i-blog.csdnimg.cn/blog_migrate/c73e2df36342cc6997beb465adbb71b6.jpeg)
紧接着又看到 Request execution succeeded on retry #2
的日志。
![f29c2f1ed44d8f1a4e562b55cda0e87c.png](https://i-blog.csdnimg.cn/blog_migrate/1d81b112253c97f89f43a063aeafc2a6.jpeg)
看到这里,表明我们的服务在尝试两次重连后和注册中心交互正常了。
一切都显得那么有惊无险,这里报Connection refused 是注册中心网络抖动导致的,接着触发了我们服务的重连,重连成功后一切又恢复正常。
这次的报警虽然没有对我们线上业务造成影响,并且也在第一时间恢复了正常,但作为一个爱思考的小火鸡,我很好奇这背后的一系列逻辑:Eureka注册中心集群如何实现客户端请求负载及故障转移?
![9572f6fc5eccc70fe570e3dda9eaad4e.png](https://i-blog.csdnimg.cn/blog_migrate/89a328ae084d7ce4dfea8246b972e405.png)
注册中心集群负载测试
线上注册中心是由三台机器组成的集群,都是4c8g
的配置,业务端配置注册中心地址如下(这里的peer来代替具体的ip地址
):
eureka.client.serviceUrl.defaultZone=http://peer1:8080/eureka/,http://peer2:8080/eureka/,http://peer3:8080/eureka/
我们可以写了一个Demo
进行测试:
注册中心集群负载测试
1、本地通过修改EurekaServer
服务的端口号来模拟注册中心集群部署,分别以8761
和8762
两个端口进行启动
2、启动客户端SeviceA
,配置注册中心地址为:http://localhost:8761/eureka,http://localhost:8762/eureka
![667659894cdfa217cced6379e401a98a.png](https://i-blog.csdnimg.cn/blog_migrate/d7a908c03b2bebf7ca42d7b8c2f3ba76.png)
3、启动SeviceA
时在发送注册请求的地方打断点:AbstractJerseyEurekaHttpClient.register()
,如下图所示:
![7269591e0dddcdf6574c751576c24767.png](https://i-blog.csdnimg.cn/blog_migrate/b195c67d47874a8f5c2411f87408c47c.jpeg)
这里看到请求注册中心时,连接的是8761
这个端口的服务。
4、更改ServiceA
中注册中心的配置:http://localhost:8762/eureka,http://localhost:8761/eureka
5、重新启动SeviceA
然后查看端口,如下图所示:
![a64989b99621f6e752ef633dd7f4d60b.png](https://i-blog.csdnimg.cn/blog_migrate/4d929e947a1c57b02bc8b66d956feb8a.jpeg)
此时看到请求注册中心是,连接的是8762
这个端口的服务。
注册中心故障转移测试
以两个端口分别启动EurekaServer
服务,再启动一个客户端ServiceA
。启动成功后,关闭一个8761
端口对应的服务,查看此时客户端是否会自动迁移请求到8762
端口对应的服务:
1、以8761
和8762
两个端口号启动EurekaServer
2、启动ServiceA
,配置注册中心地址为:http://localhost:8761/eureka,http://localhost:8762/eureka
3、启动成功后,关闭8761
端口的EurekaServer
4、在EurekaClient
端发送心跳请求
的地方打上断点:AbstractJerseyEurekaHttpClient.sendHeartBeat()
5、查看断点处数据,第一次请求的EurekaServer
是8761
端口的服务,因为该服务已经关闭,所以返回的response
是null
![cd8912439d272f41ebbc7bcd8411930d.png](https://i-blog.csdnimg.cn/blog_migrate/0dc9c020cdcc3e34eab08f93fbab11bd.jpeg)
6、第二次会重新请求8762
端口的服务,返回的response
为状态为200
,故障转移成功,如下图:
![45d1aff4318dc23552bfee82db2e68df.png](https://i-blog.csdnimg.cn/blog_migrate/ab7eab934418717055299afc7a50cdcc.jpeg)
思考
通过这两个测试Demo
,我以为EurekaClient
每次都会取defaultZone
配置的第一个host
作为请求EurekaServer
的请求的地址,如果该节点故障时,会自动切换配置中的下一个EurekaServer
进行重新请求。
那么疑问来了,EurekaClient
每次请求真的是以配置的defaultZone
配置的第一个服务节点作为请求的吗?这似乎也太弱了!!?
EurekaServer
集群不就成了伪集群
!!?除了客户端配置的第一个节点,其它注册中心的节点都只能作为备份和故障转移来使用!!?
真相是这样吗?NO!我们眼见也不一定为实,源码面前毫无秘密!
翠花,上干货!
客户端请求负载原理
原理图解
还是先上结论,负载原理如图所示:
![accd34b3b13b4497977fa62a8445a7bc.png](https://i-blog.csdnimg.cn/blog_migrate/54e780a8fcbcceaa012b32f56bb2faaf.jpeg)
这里会以EurekaClient
端的IP
作为随机的种子,然后随机打乱serverList
,例如我们在商品服务(192.168.10.56)中配置的注册中心集群地址为:peer1,peer2,peer3
,打乱后的地址可能变成peer3,peer2,peer1
。
用户服务(192.168.22.31)中配置的注册中心集群地址为:peer1,peer2,peer3
,打乱后的地址可能变成peer2,peer1,peer3
。
EurekaClient
每次请求serverList
中的第一个服务,从而达到负载的目的。
代码实现
我们直接看最底层负载代码的实现,具体代码在com.netflix.discovery.shared.resolver.ResolverUtils.randomize()
中:
![5f3042bef0d12cd0de9da2becbb58e9a.png](https://i-blog.csdnimg.cn/blog_migrate/a46204ee2c15229863f61f6596d89573.png)
这里面random
是通过我们EurekaClient
端的ipv4
做为随机的种子,生成一个重新排序的serverList
,也就是对应代码中的randomList
,所以每个EurekaClient
获取到的serverList
顺序可能不同,在使用过程中,取列表的第一个元素作为server
端host
,从而达到负载的目的。
![0c1c3f751f41f920d1c4efeed763145f.png](https://i-blog.csdnimg.cn/blog_migrate/be3886b4aa523770100d5e5b0db375cd.jpeg)
思考
原来代码是通过EurekaClient
的IP
进行负载的,所以刚才通过DEMO
程序结果就能解释的通了,因为我们做实验都是用的同一个IP
,所以每次都是会访问同一个Server
节点。
既然说到了负载,这里肯定会有另一个疑问:
通过IP进行的负载均衡,每次请求都会均匀分散到每一个Server
节点吗?
比如第一次访问Peer1
,第二次访问Peer2
,第三次访问Peer3
,第四次继续访问Peer1
等,循环往复......
我们可以继续做个试验,假如我们有10000个EurekaClient
节点,3个EurekaServer
节点。
Client
节点的IP
区间为:192.168.0.0 ~ 192.168.255.255
,这里面共覆盖6w多个ip
段,测试代码如下:
/**
* 模拟注册中心集群负载,验证负载散列算法
*
* @author 一枝花算不算浪漫
* @date 2020/6/21 23:36
*/
public class EurekaClusterLoadBalanceTest {
public static void main(String[] args) {
testEurekaClusterBalance();
}
/**
* 模拟ip段测试注册中心负载集群
*/
private static void testEurekaClusterBalance() {
int ipLoopSize = 65000;
String ipFormat = "192.168.%s.%s";
TreeMap<String, Integer> ipMap = Maps.newTreeMap();
int netIndex = 0;
int lastIndex = 0;
for (int i = 0; i < ipLoopSize; i++) {
if (lastIndex == 256) {
netIndex += 1;
lastIndex = 0;
}
String ip = String.format(ipFormat, netIndex, lastIndex);
randomize(ip, ipMap);
System.out.println("IP: " + ip);
lastIndex += 1;
}
printIpResult(ipMap, ipLoopSize);
}
/**
* 模拟指定ip地址获取对应注册中心负载
*/
private static void randomize(String eurekaClientIp, TreeMap<String, Integer> ipMap) {
List<String> eurekaServerUrlList = Lists.newArrayList();
eurekaServerUrlList.add("http://peer1:8080/eureka/");
eurekaServerUrlList.add("http://peer2:8080/eureka/");
eurekaServerUrlList.add("http://peer3:8080/eureka/");
List<String> randomList = new ArrayList<>(eurekaServerUrlList);
Random random = new Random(eurekaClientIp.hashCode());
int last = randomList.size() - 1;
for (int i = 0; i < last; i++) {
int pos = random.nextInt(randomList.size() - i);
if (pos != i) {
Collections.swap(randomList, i, pos);
}
}
for (String eurekaHost : randomList) {
int ipCount = ipMap.get(eurekaHost) == null ? 0 : ipMap.get(eurekaHost);
ipMap.put(eurekaHost, ipCount + 1);
break;
}
}
private static void printIpResult(TreeMap<String, Integer> ipMap, int totalCount) {
for (Map.Entry<String, Integer> entry : ipMap.entrySet()) {
Integer count = entry.getValue();
BigDecimal rate = new BigDecimal(count).divide(new BigDecimal(totalCount), 2, BigDecimal.ROUND_HALF_UP);
System.out.println(entry.getKey() + ":" + count + ":" + rate.multiply(new BigDecimal(100)).setScale(0, BigDecimal.ROUND_HALF_UP) + "%");
}
}
}
负载测试结果如下:
![1509bec297daafe15811878c518c68e9.png](https://i-blog.csdnimg.cn/blog_migrate/638609a0aeec2834352ff5c2b94d7f45.png)
可以看到第二个机器会有50%的请求,最后一台机器只有17%的请求,负载的情况并不是很均匀,我认为通过IP
负载并不是一个好的方案。
还记得我们之前讲过Ribbon
默认的轮询算法RoundRobinRule
,【一起学源码-微服务】Ribbon 源码四:进一步探究Ribbon的IRule和IPing 。
这种算法就是一个很好的散列算法,可以保证每次请求都很均匀,原理如下图:
![8c6c93960195307779a030bfc1a32fb0.png](https://i-blog.csdnimg.cn/blog_migrate/2f6ae5df0d46a91fdd447eea8a24e324.jpeg)
故障转移原理
原理图解
还是先上结论,如下图:
![74ead9a1325896781a9e2607119201ba.png](https://i-blog.csdnimg.cn/blog_migrate/4d3e86a815d0fa0e0238d9cd0c602fa6.jpeg)
我们的serverList
按照client
端的ip
进行重排序后,每次都会请求第一个元素作为和Server
端交互的host
,如果请求失败,会尝试请求serverList
列表中的第二个元素继续请求,这次请求成功后,会将此次请求的host
放到全局的一个变量中保存起来,下次client
端再次请求 就会直接使用这个host
。
这里最多会重试请求两次。
代码实现
直接看底层交互的代码,位置在com.netflix.discovery.shared.transport.decorator.RetryableEurekaHttpClient.execute()
中:
![62373caf57b46d0d7813e89dcb042f52.png](https://i-blog.csdnimg.cn/blog_migrate/b66483d1606429278ecb5e94d417e334.jpeg)
我们来分析下这个代码:
- 第101行,获取
client
上次成功server
端的host
,如果有值则直接使用这个host
- 第105行,
getHostCandidates()
是获取client
端配置的serverList
数据,且通过ip
进行重排序的列表
- 第114行,
candidateHosts.get(endpointIdx++)
,初始endpointIdx=0
,获取列表中第1个元素作为host
请求
- 第120行,获取返回的
response
结果,如果返回的状态码是200
,则将此次请求的host
设置到全局的delegate
变量中
- 第133行,执行到这里说明第120行执行的
response
返回的状态码不是200
,也就是执行失败,将全局变量delegate
中的数据清空
- 再次循环第一步,此时
endpointIdx=1
,获取列表中的第二个元素作为host
请求
- 依次执行,第100行的循环条件
numberOfRetries=3
,最多重试2次就会跳出循环
我们还可以第123和129行,这也正是我们业务抛出来的日志信息,所有的一切都对应上了。
总结
感谢你看到这里,相信你已经清楚了开头提问的问题。
上面已经分析完了Eureka
集群下Client
端请求时负载均衡的选择以及集群故障时自动重试请求的实现原理。
如果还有不懂的问题,可以添加我的微信或者给我公众号留言,我会单独和你讨论交流。
本文首发自:一枝花算不算浪漫
公众号,如若转载请在文章开头标明出处,如需开白可直接公众号回复即可。
![2fe8be8d250440f59a35d3a873a4af00.png](https://i-blog.csdnimg.cn/blog_migrate/cf2b24e18d08733ddee0eae52e136170.jpeg)