12437字,帶你深入探究RPC通訊原理
為什麼要學習RPC
如下是Http請求案例:
請求過程會有3次握手4次揮手:
1:瀏覽器請求伺服器(訂單服務),請求建立連結 1次握手
2:伺服器(訂單服務)響應瀏覽器,可以建立連結,並詢問瀏覽器是否可以建立連結 2次握手
3:瀏覽器響應伺服器(訂單服務),可以建立連結 3次握手
------開始傳輸資料------
1:瀏覽器向服務端(訂單服務)發起請求,要求斷開連結 1次揮手
2:伺服器(訂單服務)迴應瀏覽器,資料還在傳輸中 2次揮手
3:伺服器(訂單服務)接收完資料後,向瀏覽器發訊息要求斷開連結 3次揮手
4:瀏覽器收到伺服器訊息後,回覆伺服器(訂單服務)同意斷開連結 4次揮手
1.1 PRC概述
RPC 的主要功能目標是讓構建分散式計算(應用)更容易,在提供強大的遠端呼叫能力時不損失本地呼叫的語義簡潔性。為實現該目標,RPC 框架需提供一種透明呼叫機制,讓使用者不必顯式的區分本地呼叫和遠端呼叫。
RPC的優點:
-
分散式設計
-
部署靈活
-
解耦服務
-
擴充套件性強
RPC框架優勢:
- RPC框架一般使用長連結,不必每次通訊都要3次握手,減少網路開銷。
- RPC框架一般都有註冊中心,有豐富的監控管理、釋出、下線介面、動態擴充套件等,對呼叫方來說是無感知、統一化的操作、協議私密,安全性較高
- RPC 協議更簡單內容更小,效率更高,服務化架構、服務化治理,RPC框架是一個強力的支撐。
- RPC基於TCP實現,也可以基於Http2實現
1.2 RPC框架
主流RPC框架:
- Dubbo:國內最早開源的 RPC 框架,由阿里巴巴公司開發並於 2011 年末對外開源,僅支援 Java 語言。
- Motan:新浪微博內部使用的 RPC 框架,於 2016 年對外開源,僅支援 Java 語言。
- Tars:騰訊內部使用的 RPC 框架,於 2017 年對外開源,僅支援 C++ 語言。
- Spring Cloud:國外 Pivotal 公司 2014 年對外開源的 RPC 框架,提供了豐富的生態元件。
- gRPC:Google 於 2015 年對外開源的跨語言 RPC 框架,支援多種語言。
- Thrift:最初是由 Facebook 開發的內部系統跨語言的 RPC 框架,2007 年貢獻給了 Apache 基金,成為 Apache 開源專案之一,支援多種語言。
1.3 應用場景
應用例舉:
- 分散式作業系統的程序間通訊 程序間通訊是作業系統必須提供的基本設施之一,分散式作業系統必須提供分佈於異構的結點機上程序間的通訊機制,RPC是實現訊息傳送模式的分散式程序間通訊方式之一。
- 構造分散式設計的軟體環境 由於分散式軟體設計,服務與環境的分佈性, 它的各個組成成份之間存在大量的互動和通訊, RPC是其基本的實現方法之一。Dubbo分散式服務框架基於RPC實現,Hadoop也採用了RPC方式實現客戶端與服務端的互動。
- 遠端資料庫服務 在分散式資料庫系統中,資料庫一般駐存在伺服器上,客戶機通過遠端資料庫服務功能訪問資料庫伺服器,現有的遠端資料庫服務是使用RPC模式的。例如,Sybase和Oracle都提供了儲存過程機制,系統與使用者定義的儲存過程儲存在資料庫伺服器上,使用者在客戶端使用RPC模式呼叫儲存過程。
- 分散式應用程式設計 RPC機制與RPC工具為分散式應用程式設計提供了手段和方便, 使用者可以無需知道網路結構和協議細節而直接使用RPC工具設計分散式應用程式。
- 分散式程式的除錯 RPC可用於分散式程式的除錯。使用反向RPC使伺服器成為客戶並向它的客戶程序發出RPC,可以除錯分散式程式。例如,在伺服器上執行一個遠端除錯程式,它不斷接收客戶端的RPC,當遇到一個除錯程式斷點時,它向客戶機發回一個RPC,通知斷點已經到達,這也是RPC用於程序通訊的例子。
2. 深入RPC原理
2.1 設計與呼叫流程
具體呼叫過程:
- 服務消費者(client客戶端)通過本地呼叫的方式呼叫服務。
- 客戶端存根(client stub)接收到請求後負責將方法、入參等資訊序列化(組裝)成能夠進行網路傳輸的訊息體。
- 客戶端存根(client stub)找到遠端的服務地址,並且將訊息通過網路傳送給服務端。
- 服務端存根(server stub)收到訊息後進行解碼(反序列化操作)。
- 服務端存根(server stub)根據解碼結果呼叫本地的服務進行相關處理。
- 本地服務執行具體業務邏輯並將處理結果返回給服務端存根(server stub)。
- 服務端存根(server stub)將返回結果重新打包成訊息(序列化)並通過網路傳送至消費方。
- 客戶端存根(client stub)接收到訊息,並進行解碼(反序列化)。
- 服務消費方得到最終結果。
所涉及的技術:
-
動態代理
生成Client Stub(客戶端存根)和Server Stub(服務端存根)的時候需要用到java動態代理技術。
-
序列化 在網路中,所有的資料都將會被轉化為位元組進行傳送,需要對這些引數進行序列化和反序列化操作。
目前主流高效的開源序列化框架有Kryo、fastjson、Hessian、Protobuf等。
-
NIO通訊
Java 提供了 NIO 的解決方案,Java 7 也提供了更優秀的 NIO.2 支援。可以採用Netty或者mina框架來解決NIO資料傳輸的問題。開源的RPC框架Dubbo就是採用NIO通訊,整合支援netty、mina、grizzly。
-
服務註冊中心
通過註冊中心,讓客戶端連線呼叫服務端所釋出的服務。主流的註冊中心元件:Redis、Nacos、Zookeeper、Consul 、Etcd。Dubbo採用的是ZooKeeper提供服務註冊與發現功能。
-
負載均衡
在高併發的場景下,需要多個節點或叢集來提升整體吞吐能力。
-
健康檢查
健康檢查包括,客戶端心跳和服務端主動探測兩種方式。
2.2 RPC深入解析
2.2.1 序列化技術
-
序列化的作用
在網路傳輸中,資料必須採用二進位制形式, 所以在RPC呼叫過程中, 需要採用序列化技術,對入參物件和返回值物件進行序列化與反序列化。
-
序列化原理
自定義的二進位制協議來實現序列化: 一個物件是如何進行序列化? 下面以User物件例舉講解:
User物件:
package com.itcast; public class User { /** * 使用者編號 */ private String userNo = "0001"; /** * 使用者名稱稱 */ private String name = "zhangsan"; }
包體的資料組成:
業務指令為0x00000001佔1個位元組,類的包名com.itcast佔10個位元組, 類名User佔4個位元組;
屬性UserNo名稱佔6個位元組,屬性型別string佔2個位元組表示,屬性值為0001佔4個位元組;
屬性name名稱佔4個位元組,屬性型別string佔2個位元組表示,屬性值為zhangsan佔8個位元組;
包體共計佔有1+10+4+6+2+4+4+2+8 = 41位元組。
包頭的資料組成:
版本號v1.0佔4個位元組,訊息包體實際長度為41佔4個位元組表示,序列號0001佔4個位元組,校驗碼32位表示佔4個位元組。
包頭共計佔有4+4+4+4 = 16位元組。
包尾的資料組成:
通過回車符標記結束\r\n,佔用1個位元組。
整個包的序列化二進位制位元組流共41+16+1 = 58位元組。這裡講解的是整個序列化的處理思路, 在實際的序列化處理中還要考慮更多細節,比如說方法和屬性的區分,方法許可權的標記,巢狀型別的處理等等。
-
序列化的處理要素
- 解析效率:序列化協議應該首要考慮的因素,像xml/json解析起來比較耗時,需要解析dom樹,二進位制自定義協議解析起來效率要快很多。
- 壓縮率:同樣一個物件,xml/json傳輸起來有大量的標籤冗餘資訊,資訊有效性低,二進位制自定義協議佔用的空間相對來說會小很多。
- 擴充套件性與相容性:是否能夠利於資訊的擴充套件,並且增加欄位後舊版客戶端是否需要強制升級,這都是需要考慮的問題,在自定義二進位制協議時候,要做好充分考慮設計。
- 可讀性與可除錯性:xml/json的可讀性會比二進位制協議好很多,並且通過網路抓包是可以直接讀取,二進位制則需要反序列化才能檢視其內容。
- 跨語言:有些序列化協議是與開發語言緊密相關的,例如dubbo的Hessian序列化協議就只能支援Java的RPC呼叫。
- 通用性:xml/json非常通用,都有很好的第三方解析庫,各個語言解析起來都十分方便,二進位制資料的處理方面也有Protobuf和Hessian等外掛,在做設計的時候儘量做到較好的通用性。
-
常用的序列化技術
-
JDK原生序列化
程式碼:
... public static void main(String[] args) throws IOException, ClassNotFoundException { String basePath = "D:/TestCode"; FileOutputStream fos = new FileOutputStream(basePath + "tradeUser.clazz"); TradeUser tradeUser = new TradeUser(); tradeUser.setName("Mirson"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(tradeUser); oos.flush(); oos.close(); FileInputStream fis = new FileInputStream(basePath + "tradeUser.clazz"); ObjectInputStream ois = new ObjectInputStream(fis); TradeUser deStudent = (TradeUser) ois.readObject(); ois.close(); System.out.println(deStudent); } ...
(1) 在Java中,序列化必須要實現java.io.Serializable介面。
(2) 通過ObjectOutputStream和ObjectInputStream物件進行序列化及反序列化操作。
(3) 虛擬機器是否允許反序列化,不僅取決於類路徑和功能程式碼是否一致,一個非常重要的一點是兩個類的序列化 ID 是否一致 (也就是在程式碼中定義的序列ID private static final long serialVersionUID)
(4) 序列化並不會儲存靜態變數。
(5) 要想將父類物件也序列化,就需要讓父類也實現Serializable 介面。
(6) Transient 關鍵字的作用是控制變數的序列化,在變數宣告前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被反序列化後,transient 變數的值被設為初始值,如基本型別 int為 0,封裝物件型Integer則為null。
(7) 伺服器端給客戶端傳送序列化物件資料並非加密的,如果物件中有一些敏感資料比如密碼等,那麼在對密碼欄位序列化之前,最好做加密處理, 這樣可以一定程度保證序列化物件的資料安全。
-
JSON序列化
一般在HTTP協議的RPC框架通訊中,會選擇JSON方式。
優勢:JSON具有較好的擴充套件性、可讀性和通用性。
缺陷:JSON序列化佔用空間開銷較大,沒有JAVA的強型別區分,需要通過反射解決,解析效率和壓縮率都較差。
如果對併發和效能要求較高,或者是傳輸資料量較大的場景,不建議採用JSON序列化方式。
-
Hessian2序列化
Hessian 是一個動態型別,二進位制序列化,並且支援跨語言特性的序列化框架。
Hessian 效能上要比 JDK、JSON 序列化高效很多,並且生成的位元組數也更小。有非常好的相容性和穩定性,所以 Hessian 更加適合作為 RPC 框架遠端通訊的序列化協議。
程式碼示例:
... TradeUser tradeUser = new TradeUser(); tradeUser.setName("Mirson"); //tradeUser物件序列化處理 ByteArrayOutputStream bos = new ByteArrayOutputStream(); Hessian2Output output = new Hessian2Output(bos); output.writeObject(tradeUser); output.flushBuffer(); byte[] data = bos.toByteArray(); bos.close(); //tradeUser物件反序列化處理 ByteArrayInputStream bis = new ByteArrayInputStream(data); Hessian2Input input = new Hessian2Input(bis); TradeUser deTradeUser = (TradeUser) input.readObject(); input.close(); System.out.println(deTradeUser); ...
Dubbo Hessian Lite序列化流程:
Dubbo Hessian Lite反序列化流程:
Hessian自身也存在一些缺陷,大家在使用過程中要注意:
-
對Linked系列物件不支援,比如LinkedHashMap、LinkedHashSet 等,但可以通過CollectionSerializer類修復。
-
Locale 類不支援,可以通過擴充套件 ContextSerializerFactory 類修復。
-
Byte/Short 在反序列化的時候會轉成 Integer。
Dubbo2.7.3通訊序列化原始碼實現分析:
- 序列化實現流程:
-
ExchangeCodec的encode方法:
```java
@Override
public void encode(Channel channel, ChannelBuffer buffer, Object msg) throws IOException {
if (msg instanceof Request) {
encodeRequest(channel, buffer, (Request) msg);
} else if (msg instanceof Response) {
encodeResponse(channel, buffer, (Response) msg);
} else {
super.encode(channel, buffer, msg);
}
}
```
- 反序列化實現流程:
原始碼:
ExchangeCodec的decode方法:
```java
@Override
public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
int readable = buffer.readableBytes();
byte[] header = new byte[Math.min(readable, HEADER_LENGTH)];
buffer.readBytes(header);
return decode(channel, buffer, readable, header);
}
```
ExchangeCodec的decodeBody方法:
```java
protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {
...
} else {
// decode request.
Request req = new Request(id);
req.setVersion(Version.getProtocolVersion());
req.setTwoWay((flag & FLAG_TWOWAY) != 0);
if ((flag & FLAG_EVENT) != 0) {
req.setEvent(true);
}
try {
ObjectInput in = CodecSupport.deserialize(channel.getUrl(), is, proto);
}
...
}
```
-
Protobuf序列化
Protobuf 是 Google 推出的開源序列庫,它是一種輕便、高效的結構化資料儲存格式,可以用於結構化資料序列化,支援 Java、Python、C++、Go 等多種語言。
Protobuf 使用的時候需要定義 IDL(Interface description language),然後使用不同語言的 IDL 編譯器,生成序列化工具類,它具備以下優點:
- 壓縮比高,體積小,序列化後體積相比 JSON、Hessian 小很多;
- IDL 能清晰地描述語義,可以幫助並保證應用程式之間的型別不會丟失,無需類似 XML 解析器;
- 序列化反序列化速度很快,不需要通過反射獲取型別;
- 訊息格式的擴充套件、升級和相容性都不錯,可以做到向後相容。
程式碼示例:
Protobuf指令碼定義:
// 定義Proto版本 syntax = "proto3"; // 是否允許生成多個JAVA檔案 option java_multiple_files = false; // 生成的包路徑 option java_package = "com.itcast.bulls.stock.struct.netty.trade"; // 生成的JAVA類名 option java_outer_classname = "TradeUserProto"; // 預警通知訊息體 message TradeUser { /** * 使用者ID */ int64 userId = 1 ; /** * 使用者名稱稱 */ string userName = 2 ; }
程式碼操作:
// 建立TradeUser的Protobuf物件 TradeUserProto.TradeUser.Builder builder = TradeUserProto.TradeUser.newBuilder(); builder.setUserId(101); builder.setUserName("Mirson"); //將TradeUser做序列化處理 TradeUserProto.TradeUser msg = builder.build(); byte[] data = msg.toByteArray(); //反序列化處理, 將剛才序列化的byte陣列轉化為TradeUser物件 TradeUserProto.TradeUser deTradeUser = TradeUserProto.TradeUser.parseFrom(data); System.out.println(deTradeUser);
2.2.2 動態代理
-
內部介面如何呼叫實現?
RPC的呼叫對使用者來講是透明的,那內部是如何實現呢?內部核心技術採用的就是動態代理,RPC 會自動給介面生成一個代理類,當我們在專案中注入介面的時候,執行過程中實際繫結的是這個介面生成的代理類。在介面方法被呼叫的時候,它實際上是被生成代理類攔截到了,這樣就可以在生成的代理類裡面,加入其他呼叫處理邏輯,比如連線負載管理,日誌記錄等等。
JDK動態代理:
被代理物件必須實現1個介面
-
JDK動態代理的如何實現?
例項程式碼:
public class JdkProxyTest { /** * 定義使用者的介面 */ public interface User { String job(); } /** * 實際的呼叫物件 */ public static class Teacher { public String invoke(){ return "i'm Teacher"; } } /** * 建立JDK動態代理類 */ public static class JDKProxy implements InvocationHandler { private Object target; JDKProxy(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] paramValues) { return ((Teacher)target).invoke(); } } public static void main(String[] args){ // 構建代理器 JDKProxy proxy = new JDKProxy(new Teacher()); ClassLoader classLoader = ClassLoaderUtils.getClassLoader(); // 生成代理類 User user = (User) Proxy.newProxyInstance(classLoader, new Class[]{User.class}, proxy); // 介面呼叫 System.out.println(user.job()); } }
JDK動態代理的實現原理:
JDK內部如何處理?
反編譯生成的代理類可以知道,代理類 $Proxy裡面會定義相同簽名的介面(也就是上面程式碼User的job介面),然後內部會定義一個變數繫結JDKProxy代理物件,當呼叫User.job介面方法,實質上呼叫的是JDKProxy.invoke()方法,從而實現了介面的動態代理。
-
為什麼要加入動態代理?
第一, 如果沒有動態代理, 服務端大量的介面將不便於管理,需要大量的if判斷,如果擴充套件了新的介面,需要更改呼叫邏輯, 不利於擴充套件維護。
第二, 是可以攔截,新增其他額外功能, 比如連線負載管理,日誌記錄等等。
-
動態代理開源技術
(1) Cglib 動態代理
Cglib是一個強大的、高效能的程式碼生成包,它廣泛被許多AOP框架使用,支援方法級別的攔截。它是高階的位元組碼生成庫,位於ASM之上,ASM是低階的位元組碼生成工具,ASM的使用對開發人員要求較高,相比較來講, ASM效能更好。
(2) Javassist 動態代理
一個開源的分析、編輯和建立Java位元組碼的類庫。javassist是jboss的一個子專案,它直接使用java編碼的形式,不需要了解虛擬機器指令,可以動態改變類的結構,或者動態生成類。Javassist 的定位是能夠操縱底層位元組碼,所以使用起來並不簡單,Dubbo 框架的設計者為了追求效能花費了不少精力去適配javassist。
(3) Byte Buddy 位元組碼增強庫
Byte Buddy是致力於解決位元組碼操作和 簡化操作複雜性的開源框架。Byte Buddy 目標是將顯式的位元組碼操作隱藏在一個型別安全的領域特定語言背後。它屬於後起之秀,在很多優秀的專案中,像 Spring、Jackson 都用到了 Byte Buddy 來完成底層代理。相比 Javassist,Byte Buddy 提供了更容易操作的 API,編寫的程式碼可讀性更高。
幾種動態代理效能比較:
單位是納秒。大括號內代表的是樣本標準差,綜合結果:
Byte Buddy > CGLIB > Javassist> JDK。
原始碼剖析:
核心原始碼:
2.2.3 服務註冊發現
-
服務註冊發現的作用
在高可用的生產環境中,一般都以叢集方式提供服務,叢集裡面的IP可能隨時變化,也可能會隨著維護擴充或減少節點,客戶端需要能夠及時感知服務端的變化,獲取叢集最新服務節點的連線資訊。
-
服務註冊發現功能
服務註冊:在服務提供方啟動的時候,將對外暴露的介面註冊到註冊中心內,註冊中心將這個服務節點的 IP 和介面等連線資訊儲存下來。為了檢測服務的服務端的有效狀態,一般會建立雙向心跳機制。
服務訂閱:在服務呼叫方啟動的時候,客戶端去註冊中心查詢並訂閱服務提供方的 IP,然後快取到本地,並用於後續的遠端呼叫。如果註冊中心資訊發生變化, 一般會採用推送的方式做更新。
-
服務註冊發現的具體流程
主流服務註冊工具有Nacos、Consul、Zookeeper等,
基於 ZooKeeper 的服務發現:
ZooKeeper 叢集作為註冊中心叢集,服務註冊的時候只需要服務節點向 ZooKeeper 節點寫入註冊資訊即可,利用 ZooKeeper 的 Watcher 機制完成服務訂閱與服務下發功能。
A. 先在 ZooKeeper 中建立一個服務根路徑,可以根據介面名命名(例如:/dubbo/com.itcast.xxService),在這個路徑再建立服務提供方與呼叫方目錄(providers、consumers),分別用來儲存服務提供方和呼叫方的節點資訊。
B. 服務端發起註冊時,會在服務提供方目錄中建立一個臨時節點,節點中儲存註冊信 息,比如IP,埠,服務名稱等等。
C. 客戶端發起訂閱時,會在服務呼叫方目錄中建立一個臨時節點,節點中儲存呼叫方的資訊,同時watch 服務提供方的目錄(/dubbo/com.itcast.xxService/providers)中所有的服務節點資料。當服務端產生變化時,比如下線或宕機等,ZooKeeper 就會通知給訂閱的客戶端。
ZooKeeper方案的特點:
ZooKeeper 的一大特點就是強一致性,ZooKeeper 叢集的每個節點的資料每次發生更新操作,都會通知其它 ZooKeeper 節點同時執行更新。它要求保證每個節點的資料能夠實時的完全一致,這樣也就會導致ZooKeeper 叢集效能上的下降,ZK是採用CP模式(保證強一致性),如果要注重效能, 可以考慮採用AP模式(保證最終一致)的註冊中心元件, 比如Nacos等。
-
原始碼剖析
Dubbo Spring Cloud 訂閱的原始碼(客戶端):
核心原始碼:
RegistryProtocol的doRefer方法:
private <T> Invoker<T> doRefer(Cluster cluster, Registry registry, Class<T> type, URL url) { RegistryDirectory<T> directory = new RegistryDirectory<T>(type, url); directory.setRegistry(registry); directory.setProtocol(protocol); // all attributes of REFER_KEY Map<String, String> parameters = new HashMap<String, String>(directory.getUrl().getParameters()); URL subscribeUrl = new URL(CONSUMER_PROTOCOL, parameters.remove(REGISTER_IP_KEY), 0, type.getName(), parameters); if (!ANY_VALUE.equals(url.getServiceInterface()) && url.getParameter(REGISTER_KEY, true)) { directory.setRegisteredConsumerUrl(getRegisteredConsumerUrl(subscribeUrl, url)); registry.register(directory.getRegisteredConsumerUrl()); } directory.buildRouterChain(subscribeUrl); directory.subscribe(subscribeUrl.addParameter(CATEGORY_KEY, PROVIDERS_CATEGORY + "," + CONFIGURATORS_CATEGORY + "," + ROUTERS_CATEGORY)); Invoker invoker = cluster.join(directory); ProviderConsumerRegTable.registerConsumer(invoker, url, subscribeUrl, directory); return invoker; }
Dubbo Spring Cloud 註冊發現的原始碼(服務端):
核心原始碼:
RegistryProtocol的export方法:
public <T> Exporter<T> export(final Invoker<T> originInvoker) throws RpcException { // 獲取註冊資訊 URL registryUrl = getRegistryUrl(originInvoker); // 獲取服務提供方資訊 URL providerUrl = getProviderUrl(originInvoker); // Subscribe the override data // FIXME When the provider subscribes, it will affect the scene : a certain JVM exposes the service and call // the same service. Because the subscribed is cached key with the name of the service, it causes the // subscription information to cover. final URL overrideSubscribeUrl = getSubscribedOverrideUrl(providerUrl); final OverrideListener overrideSubscribeListener = new OverrideListener(overrideSubscribeUrl, originInvoker); overrideListeners.put(overrideSubscribeUrl, overrideSubscribeListener); providerUrl = overrideUrlWithConfig(providerUrl, overrideSubscribeListener); //export invoker final ExporterChangeableWrapper<T> exporter = doLocalExport(originInvoker, providerUrl); // 獲取訂閱註冊器 final Registry registry = getRegistry(originInvoker); final URL registeredProviderUrl = getRegisteredProviderUrl(providerUrl, registryUrl); ProviderInvokerWrapper<T> providerInvokerWrapper = ProviderConsumerRegTable.registerProvider(originInvoker, registryUrl, registeredProviderUrl); //to judge if we need to delay publish boolean register = registeredProviderUrl.getParameter("register", true); if (register) { // 進入服務端資訊註冊處理 register(registryUrl, registeredProviderUrl); providerInvokerWrapper.setReg(true); } // Deprecated! Subscribe to override rules in 2.6.x or before. // 服務端資訊訂閱處理 registry.subscribe(overrideSubscribeUrl, overrideSubscribeListener); exporter.setRegisterUrl(registeredProviderUrl); exporter.setSubscribeUrl(overrideSubscribeUrl); //Ensure that a new exporter instance is returned every time export return new DestroyableExporter<>(exporter); }
2.2.4 網路IO模型
-
有哪些網路IO模型
分為五種:
- 同步阻塞 IO(BIO)
- 同步非阻塞 IO(NIO)
- IO 多路複用
- 訊號驅動IO
- 非同步非阻塞 IO(AIO)
常用的是同步阻塞 IO 和 IO 多路複用模型。
-
什麼是阻塞IO模型
通常由一個獨立的 Acceptor 執行緒負責監聽客戶端的連線。一般通過在while(true)
迴圈中服務端會呼叫 accept()
方法等待接收客戶端的連線的方式監聽請求,請求一旦接收到一個連線請求,就可以建立通訊套接字,在這個通訊套接字上進行讀寫操作,此時不能再接收其他客戶端連線請求,直到客戶端的操作執行完成。 系統核心處理 IO 操作分為兩個階段——等待資料和拷貝資料。而在這兩個階段中,應用程序中 IO 操作的執行緒會一直都處於阻塞狀態,如果是基於 Java 多執行緒開發,那麼每一個 IO 操作都要佔用執行緒,直至 IO 操作結束。
-
IO多路複用
概念: 服務端採用單執行緒過select/epoll機制,獲取fd列表, 遍歷fd中的所有事件, 可以關注多個檔案描述符,使其能夠支援更多的併發連線。
IO多路複用的實現主要有select,poll和epoll模式。
檔案描述符:
在Linux系統中一切皆可以看成是檔案,檔案又可分為:普通檔案、目錄檔案、連結檔案和裝置檔案。
檔案描述符(file descriptor)是核心為了高效管理已被開啟的檔案所建立的索引,用來指向被開啟的檔案。檔案描述符的值是一個非負整數。
下圖說明(左邊是程序、中間是核心、右邊是檔案系統):
1) A的檔案描述符1和30都指向了同一個開啟的檔案控制代碼, 代表程序多次執行開啟操作。
2) A的檔案描述符2和B的檔案描述符2都指向檔案控制代碼(#73),代表A和程B可能是父子程序或者A和程序B打開了同一個檔案(低概率)。
3) (時間緊張可不講)A的描述符0和B的描述符3分別指向不同的開啟檔案控制代碼,但這些控制代碼均指向i-node表的相同條目(#1936),這種情況是因為每個程序各自對同一個檔案發起了開啟請求。
程式剛剛啟動的時候,0是標準輸入,1是標準輸出,2是標準錯誤。如果此時去開啟一個新的檔案,它的檔案描述符會是3。
三者的區別:
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍歷 | 遍歷 | 回撥 |
底層實現 | bitmap | 陣列 | 紅黑樹 |
IO效率 | 每次呼叫都進行線性遍歷,時間複雜度為O(n) | 每次呼叫都進行線性遍歷,時間複雜度為O(n) | 事件通知方式,每當fd就緒,系統註冊的回撥函式就會被呼叫,將就緒fd放到readyList裡面,時間複雜度O(1) |
最大連線數 | 1024(x86)或2048(x64) | 無上限 | 無上限 |
fd拷貝 | 每次呼叫select,都需要把fd集合從使用者態拷貝到核心態 | 每次呼叫poll,都需要把fd集合從使用者態拷貝到核心態 | 呼叫epoll_ctl時拷貝進核心並儲存,之後每次epoll_wait不拷貝 |
select/poll處理流程:
此處是動圖
epoll的處理流程:
此處是動圖
當連線有I/O流事件產生的時候,epoll就會去告訴程序哪個連線有I/O流事件產生,然後程序就去處理這個程序。這樣效能相比要高效很多!
epoll 可以說是I/O 多路複用最新的一個實現,epoll 修復了poll 和select絕大部分問題, 比如
epoll 是執行緒安全的。 epoll 不僅告訴你sock組裡面的資料,還會告訴你具體哪個sock連線有資料,不用程序獨自輪詢查詢。
-
select 模型
使用示例:
while (1) { // 阻塞獲取 // 每次需要把fd從使用者態拷貝到核心態 nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout); // 每次需要遍歷所有fd,判斷有無讀寫事件發生 for (int i = 0; i <= max && nfds; ++i) { if (i == listenfd) { --nfds; // 這裡處理accept事件 FD_SET(i, &read_fd);//將客戶端socket加入到集合中 } if (FD_ISSET(i, &read_fd)) { --nfds; // 這裡處理read事件 } if (FD_ISSET(i, &write_fd)) { --nfds; // 這裡處理write事件 } } }
缺點:
- 單個程序所開啟的FD最大數限制為1024。
- 每次呼叫select,都需要把fd集合從使用者態拷貝到核心態,fd資料較大時影響效能。
- 對socket掃描時是線性掃描,效率較低(高併發場景)
-
POLL模型
int max = 0; // 佇列的實際長度 while (1) { // 阻塞獲取 // 每次需要把fd從使用者態拷貝到核心態 nfds = poll(fds, max+1, timeout); if (fds[0].revents & POLLRDNORM) { // 這裡處理accept事件 connfd = accept(listenfd); //將新的描述符新增到讀描述符集合中 } // 每次需要遍歷所有fd,判斷有無讀寫事件發生 for (int i = 1; i < max; ++i) { if (fds[i].revents & POLLRDNORM) { sockfd = fds[i].fd if ((n = read(sockfd, buf, MAXLINE)) <= 0) { // 這裡處理read事件 if (n == 0) { close(sockfd); fds[i].fd = -1; } } else { // 這裡處理write事件 } if (--nfds <= 0) { break; } } } }
缺點:
- poll與select相比,只是沒有fd的限制,都存在相同的缺陷。
-
EPOLL模型
使用示例:
// 需要監聽的socket放到ep中 epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev); while(1) { // 阻塞獲取 nfds = epoll_wait(epfd,events,20,0); for(i=0;i<nfds;++i) { if(events[i].data.fd==listenfd) { // 這裡處理accept事件 connfd = accept(listenfd); // 接收新連線寫到核心物件中 epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); } else if (events[i].events&EPOLLIN) { // 這裡處理read事件 read(sockfd, BUF, MAXLINE); //讀完後準備寫 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); } else if(events[i].events&EPOLLOUT) { // 這裡處理write事件 write(sockfd, BUF, n); //寫完後準備讀 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); } } }
缺點:
- 目前只能工作在linux環境下
- 資料量很小的時候沒有效能優勢
epoll下的兩種模式(拓展瞭解):
EPOLLLT和EPOLLET兩種觸發模式,LT是預設的模式,ET是“高速”模式。
LT(水平觸發)模式下,只要這個fd還有資料可讀,每次 epoll_wait都會返回它的事件,提醒使用者程式去操作
ET(邊緣觸發)模式下,它只會提示一次,直到下次再有資料流入之前都不會再提示了,無論fd中是否還有資料可讀。所以在ET模式下,read一個fd的時候一定要把它的buffer讀完,或者遇到EAGAIN錯誤
-
三種模式對比
對比項 select poll epoll 資料結構 bitmap 陣列 紅黑樹 最大連線數 1024 無上限 無上限 fd拷貝 每次呼叫select拷貝 每次呼叫poll拷貝 fd首次呼叫epoll_ctl拷貝,每次呼叫epoll_wait不拷貝 工作效率 輪詢:O(n) 輪詢:O(n) 回撥:O(1) -
為什麼阻塞 IO 和 IO 多路複用最為常用?
在實際的網路 IO 的應用中,需要的是系統核心的支援以及程式語言的支援。現在大多數系統核心都會支援阻塞 IO、非阻塞 IO 和 IO 多路複用,但像訊號驅動 IO、非同步 IO,只有高版本的 Linux 系統核心才會支援。
同步阻塞IO、同步非阻塞IO、同步IO多路複用與非同步IO區別:
- 同步阻塞IO(實質上, 每個請求不管成功或失敗, 都會阻塞)
- 同步非阻塞IO(相比第一種的完全阻塞,如果資料沒準備好,會返回EWOULDBLOCK, 這樣就不會造成阻塞)
-
同步IO多路複用
(kernel會根據select/epoll等機制, 監聽所有select接入的socket,當任何一個socket中的資料準備好了,select就會返回, 使得一個程序能同時等待多個檔案描述符。)
- 非同步IO
-
RPC 框架採用哪種網路 IO 模型?
- IO 多路複用應用特點:
IO 多路複用更適合高併發的場景,可以用較少的程序(執行緒)處理較多的 socket 的 IO 請求,但使用難度比較高。
- 阻塞 IO應用特點:
與 IO 多路複用相比,阻塞 IO 每處理一個 socket 的 IO 請求都會阻塞程序(執行緒),但使用難度較低。在併發量較低、業務邏輯只需要同步進行 IO 操作的場景下,阻塞 IO 已經滿足了需求,並且不需要發起 大量的select 呼叫,開銷上要比 IO 多路複用低。
- RPC框架應用:
RPC 呼叫在大多數的情況下,是一個高併發呼叫的場景, 在 RPC 框架的實現中,一般會選擇 IO 多路複用的方式。在開發語言的網路通訊框架的選型上,我們最優的選擇是基於 Reactor 模式實現的框架,如 Java 語言,首選的框架便是 Netty 框架(目前 Netty 是應用最為廣泛的框架),並且在 Linux 環境下,也要開啟 epoll 來提升系統性能(Windows 環境下是無法開啟 epoll 的,因為系統核心不支援)。
2.2.5 時間輪
-
為什麼需要時間輪?
在Dubbo中,為增強系統的容錯能力,會有相應的監聽判斷處理機制。比如RPC呼叫的超時機制的實現,消費者判斷RPC呼叫是否超時,如果超時會將超時結果返回給應用層。在Dubbo最開始的實現中,是將所有的返回結果(DefaultFuture)都放入一個集合中,並且通過一個定時任務,每隔一定時間間隔就掃描所有的future,逐個判斷是否超時。
這樣的實現方式雖然比較簡單,但是存在一個問題就是會有很多無意義的遍歷操作開銷。比如一個RPC呼叫的超時時間是10秒,而設定的超時判定的定時任務是2秒執行一次,那麼可能會有4次左右無意義的迴圈檢測判斷操作。
為了解決上述場景中的類似問題,Dubbo借鑑Netty,引入了時間輪演算法,減少無意義的輪詢判斷操作。
-
時間輪原理
對於以上問題, 目的是要減少額外的掃描操作就可以了。比如說一個定時任務是在5 秒之後執行,那麼在 4.9 秒之後才掃描這個定時任務,這樣就可以極大減少 CPU開銷。這時我們就可以利用時鐘輪的機制了。
時鐘輪的實質上是參考了生活中的時鐘跳動的原理,那麼具體是如何實現呢?
在時鐘輪機制中,有時間槽和時鐘輪的概念,時間槽就相當於時鐘的刻度;而時鐘輪就相當於指標跳動的一個週期,我們可以將每個任務放到對應的時間槽位上。
如果時鐘輪有 10 個槽位,而時鐘輪一輪的週期是 10 秒,那麼我們每個槽位的單位時間就是 1 秒,而下一層時間輪的週期就是 100 秒,每個槽位的單位時間也就是 10 秒,這就好比秒針與分針, 在秒針週期下, 刻度單位為秒, 在分針週期下, 刻度為分。
假設現在我們有 3 個任務,分別是任務 A(0.9秒之後執行)、任務 B(2.1秒後執行)與任務 C(12.1秒之後執行),我們將這 3 個任務新增到時鐘輪中,任務 A 被放到第 0 槽位,任務 B 被放到第 2槽位,任務 C 被放到下一層時間輪的第2個槽位,如下圖所示:
通過這個場景我們可以瞭解到,時鐘輪的掃描週期仍是最小單位1秒,但是放置其中的任務並沒有反覆掃描,每個任務會按要求只掃描執行一次, 這樣就能夠很好的解決CPU 浪費的問題。
疊加時鐘輪, 無限增長, 效率會不斷下降,該如何解決?設定三個時鐘輪, 小時輪, 分鐘輪, 秒級輪
Dubbo中的時間輪原理是如何實現?
主要是通過Timer,Timeout,TimerTask幾個介面定義了一個定時器的模型,再通過HashedWheelTimer這個類實現了一個時間輪定時器(預設的時間槽的數量是512,可以自定義這個值)。它對外提供了簡單易用的介面,只需要呼叫newTimeout介面,就可以實現對只需執行一次任務的排程。通過該定時器,Dubbo在響應的場景中實現了高效的任務排程。
-
Dubbo原始碼剖析
時間輪核心類HashedWheelTimer結構:
-
時間輪在RPC的應用
-
呼叫超時與重試處理: 上面所講的客戶端呼叫超時的處理,就可以應用到時鐘輪,我們每發一次請求,都建立一個處理請求超時的定時任務放到時鐘輪裡,在高併發、高訪問量的情況下,時鐘輪每次只輪詢一個時間槽位中的任務,這樣會節省大量的 CPU。
原始碼:
FailbackRegistry, 程式碼片段:
// 構造方法 public FailbackRegistry(URL url) { super(url); this.retryPeriod = url.getParameter(REGISTRY_RETRY_PERIOD_KEY, DEFAULT_REGISTRY_RETRY_PERIOD); // since the retry task will not be very much. 128 ticks is enough. // 重試器的時間槽數量, 設定為128 retryTimer = new HashedWheelTimer(new NamedThreadFactory("DubboRegistryRetryTimer", true), retryPeriod, TimeUnit.MILLISECONDS, 128); } // 失敗時間任務註冊器 private void addFailedRegistered(URL url) { FailedRegisteredTask oldOne = failedRegistered.get(url); if (oldOne != null) { return; } FailedRegisteredTask newTask = new FailedRegisteredTask(url, this); oldOne = failedRegistered.putIfAbsent(url, newTask); if (oldOne == null) { // never has a retry task. then start a new task for retry. // 舊任務不存在, 則放置時間輪,開啟新一個任務 retryTimer.newTimeout(newTask, retryPeriod, TimeUnit.MILLISECONDS); } }
-
定時心跳檢測: RPC 框架呼叫端定時向服務端傳送的心跳檢測,來維護連線狀態,我們可以將心跳的邏輯封裝為一個心跳任務,放到時鐘輪裡。心跳是要定時重複執行的,而時鐘輪中的任務執行一遍就被移除了,對於這種需要重複執行的定時任務我們該如何處理呢?我們在定時任務邏輯結束的最後,再加上一段邏輯, 重設這個任務的執行時間,把它重新丟回到時鐘輪裡。這樣就可以實現迴圈執行。
原始碼:
HeaderExchangeServer程式碼片段:
... // 建立心跳時間輪, 槽位數預設為128 private static final HashedWheelTimer IDLE_CHECK_TIMER = new HashedWheelTimer(new NamedThreadFactory("dubbo-server-idleCheck", true), 1, TimeUnit.SECONDS, TICKS_PER_WHEEL); ... // 啟動心跳任務檢測 private void startIdleCheckTask(URL url) { if (!server.canHandleIdle()) { AbstractTimerTask.ChannelProvider cp = () -> unmodifiableCollection(HeaderExchangeServer.this.getChannels()); int idleTimeout = getIdleTimeout(url); long idleTimeoutTick = calculateLeastDuration(idleTimeout); CloseTimerTask closeTimerTask = new CloseTimerTask(cp, idleTimeoutTick, idleTimeout); this.closeTimerTask = closeTimerTask; // init task and start timer. // 開啟心跳檢測任務 IDLE_CHECK_TIMER.newTimeout(closeTimerTask, idleTimeoutTick, TimeUnit.MILLISECONDS); } } ...
連線檢測, 會不斷執行, 加入時間輪中。
AbstractTimerTask原始碼:
@Override public void run(Timeout timeout) throws Exception { Collection<Channel> c = channelProvider.getChannels(); for (Channel channel : c) { if (channel.isClosed()) { continue; } // 呼叫心跳檢測任務 doTask(channel); } // 重新放入時間輪中 reput(timeout, tick); }
還可以參考HeartbeatTimerTask、ReconnectTimerTask原始碼實現。
-
3. RPC的高階機制
3.1 非同步處理機制
-
為什麼要採用非同步?
如果採用同步呼叫, CPU 大部分的時間都在等待而沒有去計算,從而導致 CPU 的利用率不夠。
RPC 請求比較耗時的原因主要是在哪裡?
在大多數情況下,RPC 本身處理請求的效率是在毫秒級的。RPC 請求的耗時大部分都是業務耗時,比如業務邏輯中有訪問資料庫執行慢 SQL 的操作,核心是在I/O瓶頸。所以說,在大多數情況下,影響到 RPC 呼叫的吞吐量的原因也就是業務邏輯處理慢了,CPU 大部分時間都在等待資源。
-
呼叫端如何實現非同步?
常用的方式就是Future 方式,它是返回 Future 物件,通過GET方式獲取結果;或者採用入參為 Callback 物件的回撥方式,處理結果。
從DUBBO框架, 來看具體是如何實現非同步呼叫?
-
服務端如何實現非同步?
為了提升效能,連線請求與業務處理不會放在一個執行緒處理, 這個就是服務端的非同步化。服務端業務處理邏輯加入非同步處理機制。
在RPC 框架提供一種回撥方式,讓業務邏輯可以非同步處理,處理完之後呼叫 RPC 框架的回撥介面。
RPC 框架的非同步策略主要是呼叫端非同步與服務端非同步。呼叫端的非同步就是通過 Future 方式。
服務端非同步則需要一種回撥方式,讓業務邏輯可以非同步處理。這樣就實現了RPC呼叫的全非同步化。
-
RPC框架的非同步實現
RPC 框架的非同步策略主要是呼叫端非同步與服務端非同步。呼叫端的非同步就是通過 Future 方式實現非同步,呼叫端發起一次非同步請求並且從請求上下文中拿到一個 Future,之後通過 Future 的 get 方法獲取結果,如果業務邏輯中同時呼叫多個其它的服務,則可以通過 Future 的方式減少業務邏輯的耗時,提升吞吐量。
服務端非同步則需要一種回撥方式,讓業務邏輯可以非同步處理,之後呼叫 RPC 框架提供的回撥介面,將最終結果非同步通知給呼叫端。這樣就實現了RPC呼叫的全非同步。
Dubbo原始碼:
非同步呼叫: AsyncToSyncInvoker.invoke方法
獲取結果:ChannelWrappedInvoker.doInvoke方法
3.2 路由與負載均衡(瞭解)
我們後面會講解灰度釋出機制,基於Nginx+Lua、擴充套件SpringCloud Gateway原始碼灰度釋出和負載均衡,只要專案叢集、分散式應用就會涉及到路由與負載均衡。
-
為什麼要採用路由?
真實的環境中一般是以叢集的方式提供服務,對於服務呼叫方來說,一個介面會有多個服務提供方同時提供服務,所以 RPC 在每次發起請求的時候,都需要從多個服務節點裡面選取一個用於處理請求的服務節點。
這就需要在RPC應用中增加路由功能。
-
如何實現路由?
服務註冊發現方式:
通過服務發現的方式從邏輯上看是可行,但註冊中心是用來保證資料的一致性。通過服務發現方式來實現請求隔離並不理想。
RPC路由策略:
從服務提供方節點集合裡面選擇一個合適的節點(負載均衡),把符合我們要求的節點篩選出來。這個就是路由策略:
接收請求-->請求校驗-->路由策略-->負載均衡-->
使用了 IP 路由策略後,整個叢集的呼叫拓撲如下圖所示:
有些場景下,可能還需要更細粒度的路由方式,比如說根據SESSIONID要落到相同的服務節點上以保持會話的有效性;
可以考慮採用引數化路由:
-
RPC框架中的負載均衡
RPC 的負載均衡是由 RPC 框架自身提供實現,自主選擇一個最佳的服務節點,發起 RPC 呼叫請求。
RPC 負載均衡策略一般包括輪詢、隨機、權重、最少連線等。Dubbo預設就是使用隨機負載均衡策略。
-
自適應的負載均衡策略
RPC 的負載均衡完全由 RPC 框架自身實現,服務呼叫方發起請求時,會通過所配置的負載均衡元件,自主地選擇合適服務節點。呼叫方如果能知道每個服務節點處理請求的能力,再根據服務節點處理請求的能力來判斷分配相應的流量,叢集資源就能夠得到充分的利用, 當一個服務節點負載過高或響應過慢時,就少給它傳送請求,反之則多給它傳送請求。這個就是自適應的負載均衡策略。
具體如何實現?
這就需要判定服務節點的處理能力。
主要步驟:
(1)新增計分器和指標採集器。
(2)指標採集器收集服務節點 CPU 核數、CPU 負載以及記憶體佔用率等指標。
(3)可以配置開啟哪些指標採集器,並設定這些參考指標的具體權重。
(4)通過對服務節點的綜合打分,最終計算出服務節點的實際權重,選擇合適的服務節點。
3.3 熔斷限流(瞭解)
我們後面課程會詳細講解熔斷限流元件Sentinel高階用法、原始碼剖析、策略機制,但是RPC需要考慮熔斷限流機制,我們一起來了解一下。
-
為什麼要進行限流?
在實際生產環境中,每個服務節點都可能由於訪問量過大而引起一系列問題,就需要業務提供方能夠進行自我保護,從而保證在高訪問量、高併發的場景下,系統依然能夠穩定,高效執行。
-
服務端的自我保護實現
在Dubbo框架中, 可以通過Sentinel來實現更為完善的熔斷限流功能,服務端是具體如何實現限流邏輯的?
方法有很多種, 最簡單的是計數器,還有平滑限流的滑動視窗、漏斗演算法以及令牌桶演算法等等。Sentinel採用的是滑動視窗來實現的限流。
windowStart: 時間視窗的開始時間,單位是毫秒
windowLength: 時間視窗的長度,單位是毫秒
value: 時間視窗的內容
初始的時候arrays陣列中只有一個視窗,每個時間視窗的長度是500ms,這就意味著只要當前時間與時間視窗的差值在500ms之內,時間視窗就不會向前滑動。
時間繼續往前走,當超過500ms時,時間視窗就會向前滑動到下一個,這時就會更新當前視窗的開始時間:
在當前時間點中進入的請求,會被統計到當前時間所對應的時間視窗中。
-
呼叫方的自我保護
一個服務 A 呼叫服務 B 時,服務 B 的業務邏輯又呼叫了服務 C,這時服務 C 響應超時,服務 B 就可能會因為堆積大量請求而導致服務宕機,由此產生服務雪崩的問題。
熔斷處理流程:
熔斷機制:
熔斷器的工作機制主要是關閉、開啟和半開啟這三個狀態之間的切換。
Sentinel 熔斷降級元件它可以支援以下降級策略:
- 平均響應時間 (
DEGRADE_GRADE_RT
):當 1s 內持續進入 N 個請求,對應時刻的平均響應時間(秒級)均超過閾值(count
,以 ms 為單位),那麼在接下的時間視窗(DegradeRule
中的timeWindow
,以 s 為單位)之內,對這個方法的呼叫都會自動地熔斷(丟擲DegradeException
)。注意 Sentinel 預設統計的 RT 上限是 4900 ms,超出此閾值的都會算作 4900 ms,若需要變更此上限可以通過啟動配置項-Dcsp.sentinel.statistic.max.rt=xxx
來配置。 - 異常比例 (
DEGRADE_GRADE_EXCEPTION_RATIO
):當資源的每秒請求量 >= N(可配置),並且每秒異常總數佔通過量的比值超過閾值(DegradeRule
中的count
)之後,資源進入降級狀態,即在接下的時間視窗(DegradeRule
中的timeWindow
,以 s 為單位)之內,對這個方法的呼叫都會自動地返回。異常比率的閾值範圍是[0.0, 1.0]
,代表 0% - 100%。 - 異常數 (
DEGRADE_GRADE_EXCEPTION_COUNT
):當資源近 1 分鐘的異常數目超過閾值之後會進行熔斷。注意由於統計時間視窗是分鐘級別的,若timeWindow
小於 60s,則結束熔斷狀態後仍可能再進入熔斷狀態。
更多資料,參考Sentinel官方文件。
本文由傳智教育博學谷 - 狂野架構師教研團隊釋出 轉載請註明出處!
- 身為程式設計師的我們如何卷死別人?破局重生。
- 資料庫擴容也可以如此絲滑,MySQL千億級資料生產環境擴容實戰
- 【詳細教程】一文參透MongoDB聚合查詢
- 【超詳細】手把手教你搭建MongoDB叢集
- 【超詳細】手把手教你搭建MongoDB叢集
- 新一代服務閘道器Gateway的實踐筆記
- 手撕Nacos原始碼,今日撕服務端原始碼
- 超詳細教程,一文入門Istio架構原理及實戰應用
- 六種常用事務解決方案,你方唱罷,我登場(沒有最好只有更好)
- 六種常用事務解決方案,你方唱罷,我登場(沒有最好只有更好)
- 超詳細教程,一文入門Istio架構原理及實戰應用
- 【知其然,知其所以然】配置中心 Apollo原始碼剖析
- 【開悟篇】Java多執行緒之JUC從入門到精通
- JVM調優實戰演練,媽媽再也不同擔心我的效能優化了
- 【圖解原始碼】Zookeeper3.7原始碼分析,包含服務啟動流程原始碼、網路通訊原始碼、RequestProcessor處理請求原始碼
- 【推薦】我認為這是最完整的Apollo教程從入門到精通
- 探針技術-JavaAgent 和位元組碼增強技術-Byte Buddy
- 探針技術-JavaAgent 和位元組碼增強技術-Byte Buddy
- 想學設計模式、想搞架構設計,先學學UML系統建模吧您
- 100003字,帶你解密 雙11、618電商大促場景下的系統架構體系