一个线上问题的思考:Eureka注册中心集群如何实现客户端请求负载及故障转移?
The following article is from 一枝花算不算浪漫 Author 一枝花算不算浪漫
(给ImportNew加星标,提高Java技能)
转自:一枝花算不算浪漫
前言
先抛一个问题给我聪明的读者,如果你们使用微服务 SpringCloud-Netflix 进行业务开发,那么线上注册中心肯定也是用了集群部署,问题来了:
你了解 Eureka 注册中心集群如何实现客户端请求负载及故障转移吗?
可以先思考一分钟,我希望你能够带着问题来阅读此篇文章,也希望你看完文章后会有所收获!
背景
前段时间线上 Sentry 平台报警,多个业务服务在和注册中心交互时,例如续约和注册表增量拉取等都报了 Request execution failed with message : Connection refused 的警告:
紧接着又看到 Request execution succeeded on retry #2 的日志。
看到这里,表明我们的服务在尝试两次重连后和注册中心交互正常了。
一切都显得那么有惊无险,这里报 Connection refused 是注册中心网络抖动导致的,接着触发了我们服务的重连,重连成功后一切又恢复正常。
这次的报警虽然没有对我们线上业务造成影响,并且也在第一时间恢复了正常,但作为一个爱思考的小火鸡,我很好奇这背后的一系列逻辑:Eureka 注册中心集群如何实现客户端请求负载及故障转移?
注册中心集群负载测试
线上注册中心是由三台机器组成的集群,都是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。
3. 启动SeviceA时在发送注册请求的地方打断点:AbstractJerseyEurekaHttpClient.register(),如下图所示:
这里看到请求注册中心时,连接的是8761这个端口的服务。
4. 更改 ServiceA 中注册中心的配置:http://localhost:8762/eureka,http://localhost:8761/eureka
5.重新启动SeviceA然后查看端口,如下图所示:
注册中心故障转移测试
以两个端口分别启动 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。
6. 第二次会重新请求8762端口的服务,返回的 response 为状态为200,故障转移成功,如下图:
思考
通过这两个测试 Demo,我以为 EurekaClient 每次都会取 defaultZone 配置的第一个 host 作为请求 EurekaServer 的请求的地址,如果该节点故障时,会自动切换配置中的下一个 EurekaServer 进行重新请求。
那么疑问来了,EurekaClient 每次请求真的是以配置的 defaultZone 配置的第一个服务节点作为请求的吗?这似乎也太弱了!!?
EurekaServer 集群不就成了伪集群!!?除了客户端配置的第一个节点,其它注册中心的节点都只能作为备份和故障转移来使用!!?
真相是这样吗?NO!我们眼见也不一定为实,源码面前毫无秘密!
翠花,上干货!
客户端请求负载原理
原理图解
还是先上结论,负载原理如图所示:
这里会以 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() 中:
这里面 random 是通过我们 EurekaClient 端的 ipv4 做为随机的种子,生成一个重新排序的 serverList,也就是对应代码中的 randomList。所以每个 EurekaClient 获取到的 serverList 顺序可能不同。在使用过程中,取列表的第一个元素作为 server 端 host,从而达到负载的目的。
思考
原来代码是通过 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) + "%");
}
}
}
负载测试结果如下:
可以看到第二个机器会有50%的请求,最后一台机器只有17%的请求,负载的情况并不是很均匀,我认为通过IP负载并不是一个好的方案。
还记得我们之前讲过Ribbon默认的轮询算法RoundRobinRule,【一起学源码-微服务】Ribbon 源码四:进一步探究Ribbon的IRule和IPing 。
这种算法就是一个很好的散列算法,可以保证每次请求都很均匀,原理如下图:
故障转移原理
原理图解
还是先上结论,如下图:
我们的 serverList 按照 client 端的 IP 进行重排序后,每次都会请求第一个元素作为和 Server 端交互的 host,如果请求失败,会尝试请求 serverList 列表中的第二个元素继续请求,这次请求成功后,会将此次请求的 host 放到全局的一个变量中保存起来,下次 client 端再次请求 就会直接使用这个 host。
这里最多会重试请求两次。
代码实现
直接看底层交互的代码,位置在
com.netflix.discovery.shared.transport.decorator.RetryableEurekaHttpClient.execute() 中:
我们来分析下这个代码:
1. 第101行,获取 client 上次成功 server 端的 host,如果有值则直接使用这个 host;
2. 第105行,getHostCandidates() 是获取 client 端配置的 serverList 数据,且通过 IP 进行重排序的列表;
3. 第114行,candidateHosts.get(endpointIdx++),初始 endpointIdx=0,获取列表中第1个元素作为 host 请求;
4. 第120行,获取返回的 response 结果。如果返回的状态码是200,则将此次请求的 host 设置到全局的 delegate 变量中;
5. 第133行,执行到这里说明第120行执行的 response 返回的状态码不是200,也就是执行失败,将全局变量 delegate 中的数据清空;
6. 再次循环第一步,此时 endpointIdx=1,获取列表中的第二个元素作为 host 请求;
7. 依次执行,第100行的循环条件 numberOfRetries=3,最多重试2次就会跳出循环。
我们还可以看123和129行,这也正是我们业务抛出来的日志信息,所有的一切都对应上了。
总结
感谢你看到这里,相信你已经清楚了开头提问的问题。
上面已经分析完了 Eureka 集群下 Client 端请求时负载均衡的选择以及集群故障时自动重试请求的实现原理。
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
好文章,我在看❤️