前言
先拋一個問題給我聰明的讀者,如果你們使用微服務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
然後檢視埠,如下圖所示:
此時看到請求註冊中心是,連線的是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
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()
中:
我們來分析下這個程式碼:
- 第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
端請求時負載均衡的選擇以及叢集故障時自動重試請求的實現原理。
如果還有不懂的問題,可以新增我的微信或者給我公眾號留言,我會單獨和你討論交流。
本文首發自:一枝花算不算浪漫
公眾號,如若轉載請在文章開頭標明出處,如需開白可直接公眾號回覆即可。