一個線上問題的思考:Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移?
阿新 • • 發佈:2020-06-22
### 前言
先拋一個問題給我聰明的讀者,如果你們使用微服務`SpringCloud-Netflix`進行業務開發,那麼線上註冊中心肯定也是用了叢集部署,問題來了:
**你瞭解Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移嗎?**
可以先思考一分鐘,我希望你能夠帶著問題來閱讀此篇文章,也希望你看完文章後會有所收穫!
### 背景
前段時間線上`Sentry`平臺報警,多個業務服務在和註冊中心互動時,例如**續約**和**登錄檔增量拉取**等都報了`Request execution failed with message : Connection refused` 的警告:
![連線拒絕.jpg](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133025126-1175948684.jpg)
緊接著又看到 `Request execution succeeded on retry #2` 的日誌。
![連線重試.jpg](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133027472-481643231.jpg)
看到這裡,表明我們的服務在嘗試兩次重連後和註冊中心互動正常了。
一切都顯得那麼有驚無險,這裡報**Connection refused** 是註冊中心網路抖動導致的,接著觸發了我們服務的重連,重連成功後一切又恢復正常。
這次的報警雖然沒有對我們線上業務造成影響,並且也在第一時間恢復了正常,但作為一個愛思考的小火雞,我很好奇這背後的一系列邏輯:`Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移?`
![問題思考梳理.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133027916-2116934182.png)
### 註冊中心叢集負載測試
線上註冊中心是由三臺機器組成的叢集,都是`4c8g`的配置,業務端配置註冊中心地址如下(`這裡的peer來代替具體的ip地址`):
```java
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`
![EurekaClient端配置.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133028180-21664263.png)
3、啟動`SeviceA`時在傳送註冊請求的地方打斷點:`AbstractJerseyEurekaHttpClient.register()`,如下圖所示:
![8761在前.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133028486-912535698.png)
這裡看到請求註冊中心時,連線的是`8761`這個埠的服務。
4、更改`ServiceA`中註冊中心的配置:`http://localhost:8762/eureka,http://localhost:8761/eureka`
5、重新啟動`SeviceA`然後檢視埠,如下圖所示:
![8762在前.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133028771-502734031.png)
此時看到請求註冊中心是,連線的是`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`
![8761故障.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133030008-2098948074.png)
6、第二次會重新請求`8762`埠的服務,返回的`response`為狀態為`200`,故障轉移成功,如下圖:
![8762故障轉移.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133031121-804544103.png)
#### 思考
通過這兩個測試`Demo`,我以為`EurekaClient`每次都會取`defaultZone`配置的第一個`host`作為請求`EurekaServer`的請求的地址,如果該節點故障時,會自動切換配置中的下一個`EurekaServer`進行重新請求。
那麼疑問來了,`EurekaClient`每次請求真的是以配置的`defaultZone`配置的第一個服務節點作為請求的嗎?這似乎也太弱了!!?
`EurekaServer`叢集不就成了`偽叢集`!!?除了客戶端配置的第一個節點,其它註冊中心的節點都只能作為備份和故障轉移來使用!!?
真相是這樣嗎?NO!我們眼見也不一定為實,原始碼面前毫無祕密!
**翠花,上乾貨!**
### 客戶端請求負載原理
#### 原理圖解
還是先上結論,負載原理如圖所示:
![負載原理.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133031550-735917105.png)
這裡會以`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()` 中:
![程式碼實現.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133032205-1583389804.png)
這裡面`random` 是通過我們`EurekaClient`端的`ipv4`做為隨機的種子,生成一個重新排序的`serverList`,也就是對應程式碼中的`randomList`,所以每個`EurekaClient`獲取到的`serverList`順序可能不同,在使用過程中,取列表的第一個元素作為`server`端`host`,從而達到負載的目的。
![負載均衡程式碼實現.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133032961-1628677148.png)
#### 思考
原來程式碼是通過`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`段,測試程式碼如下:
```java
/**
* 模擬註冊中心叢集負載,驗證負載雜湊演算法
*
* @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 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 ipMap) {
List eurekaServerUrlList = Lists.newArrayList();
eurekaServerUrlList.add("http://peer1:8080/eureka/");
eurekaServerUrlList.add("http://peer2:8080/eureka/");
eurekaServerUrlList.add("http://peer3:8080/eureka/");
List 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 ipMap, int totalCount) {
for (Map.Entry 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) + "%");
}
}
}
```
負載測試結果如下:
![負載測試結果.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133033296-594869186.png)
可以看到第二個機器會有**50%**的請求,最後一臺機器只有**17%**的請求,負載的情況並不是很均勻,我認為通過`IP`負載並不是一個好的方案。
還記得我們之前講過`Ribbon`預設的輪詢演算法`RoundRobinRule`,[【一起學原始碼-微服務】Ribbon 原始碼四:進一步探究Ribbon的IRule和IPing][1] 。
這種演算法就是一個很好的雜湊演算法,可以保證每次請求都很均勻,原理如下圖:
![Ribbon輪詢演算法.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133033642-580699006.png)
### 故障轉移原理
#### 原理圖解
還是先上結論,如下圖:
![故障轉移原理.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133033911-557954540.png)
我們的`serverList`按照`client`端的`ip`進行重排序後,每次都會請求第一個元素作為和`Server`端互動的`host`,如果請求失敗,會嘗試請求`serverList`列表中的第二個元素繼續請求,這次請求成功後,會將此次請求的`host`放到全域性的一個變數中儲存起來,下次`client`端再次請求 就會直接使用這個`host`。
這裡最多會重試請求兩次。
#### 程式碼實現
直接看底層互動的程式碼,位置在
`com.netflix.discovery.shared.transport.decorator.RetryableEurekaHttpClient.execute()` 中:
![重試程式碼.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133034165-482707156.png)
**我們來分析下這個程式碼:**
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`端請求時負載均衡的選擇以及叢集故障時自動重試請求的實現原理。
如果還有不懂的問題,可以新增我的微信或者給我公眾號留言,我會單獨和你討論交流。
本文首發自:`一枝花算不算浪漫` 公眾號,如若轉載請在文章開頭標明出處,如需開白可直接公眾號回覆即可。
[1]:https://juejin.im/post/5e8e5460e51d4546f70d11ff
![原創乾貨分享.png](https://img2020.cnblogs.com/other/799093/202006/799093-20200622133034471-1918541