查看原文
其他

一个线上问题的思考:Eureka注册中心集群如何实现客户端请求负载及故障转移?

ImportNew 2021-12-02

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 端请求时负载均衡的选择以及集群故障时自动重试请求的实现原理。

推荐阅读  点击标题可跳转

手写负载均衡算法

一致性 Hash 在负载均衡中的应用

使用 Eureka 实现服务注册与发现


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存