RPC是分佈式系統中不可缺少的一部分。之前接觸過幾種RPC模塊,這裏就總結一下常見RPC模塊的設計思想和實現。最後我們來設計一個可以方便進行RPC調用的RPC模塊。
RPC模塊設計需要考慮的問題
RPC模塊將網絡通信的過程封裝成了方法調用的過程。從使用者的角度來看,在調用端進行RPC調用,就像進行本地函數調用一樣;而在背後,RPC模塊會將先調用端的函數名稱、參數等調用信息序列化,其中序列化的方式有很多種,比如Java原生序列化、JSON、Protobuf等。接着RPC模塊會將序列化後的消息通過某種協議(如TCP, AMQP等)發送到被調用端,被調用端在收到消息以後會對其解碼,還原成調用信息,然後在本地進行方法調用,然後把調用結果發送回調用端,這樣一次RPC調用過程就完成了。在這個過程中,我們要考慮到一些問題:
- 設計成什麼樣的調用模型?
- 調用信息通過什麼樣的方式序列化?通過哪種協議傳輸?性能如何?可靠性如何?
- 分佈式系統中最關注的問題:出現failure如何應對?如何容錯?
我們一點一點來思考。第一點是設計成什麼樣的調用模型。常見的幾種模型:
服務代理。即實現一個服務接口,被調用端實現此服務接口,實現對應的方法邏輯,並寫好RPC調用信息接收部分;調用端通過RPC模塊獲取一個服務代理實例,這個
服務代理
實例繼承了服務接口並封裝了相應的遠程調用邏輯(包括消息的編碼、解碼、傳輸等)。調用端通過這個服務代理實例進行RPC調用。像Vert.x
Service
Proxy
和grpc
都是這種模型。這樣的RPC模塊需要具備生成服務代理類的功能直接調用,即設計特定的API用於RPC調用。比如Go的rpc包,裏面的Client就提供了一個Call方法用於任意RPC調用,調用者需要傳入方法名稱、參數以及返回值指針(異步模式下傳入callback handler)
我更傾向於選擇服務代理這種模型,因爲服務代理這種模型在進行RPC調用的時候就像直接LPC一樣方便,但是需要RPC模塊生成服務代理類,實現起來可能會麻煩些;當然Go的rpc包封裝的也比較好,調用也比較方便,考慮到Go的類型系統,這已經不錯了。
RPC
調用耗時會包含通信耗時和本地調用耗時。當網絡狀況不好的時候,RPC調用可能會很長時間才能得到結果。對傳統的同步RPC模式來說,這期間會阻塞調用者的調用線程。當需要進行大量RPC調用的時候,這種阻塞就傷不起了。這時候,異步RPC模式就派上用場了。我們可以對傳統RPC模式稍加改造,把服務接口設計成異步模式的,即每個方法都要綁定一個回調函數,或利用Future-Promise模型返回一個Future。設計成異步模式以後,整個架構的靈活性就能得到很大的提升。
第二點是調用信息的序列化反序列化以及傳輸。序列化主要分爲文本(如JSON, XML等)和二進制(如Thrift, Protocol等)兩種,不同的序列化策略性能不同,因此我們應該儘量選擇性能高,同時便於開發的序列化策略。在大型項目中我們常用Protobuf,性能比較好,支持多語言,但是需要單獨定義.proto文件;有的時候我們會選擇JSON,儘管效率不是很高但是方便,比如Vert.x Service Proxy
就選擇了JSON格式(底層依賴Event Bus
)。另一點就是傳輸協議的選擇。通常情況下我們會選擇TCP協議(各種基於TCP的應用層協議,如HTTP/2)進行通信,當然用基於AMQP
比如 RabbitMQ就是AMQP協議的一種實現,協議的消息隊列也可以,兩者都比較可靠。
這裏還需提一點:如何高效地併發處理request/response
,這依賴於通信模塊的實現。拿Java來說,基於Netty NIO
或者Java AIO
的I/O多路複用
都可以很好地併發處理請求;而像Go RPC則是來一個request就創建一個Goroutine
並在其中處理請求(Goroutine作爲輕量級用戶態線程,創建性能消耗小)。
最後一點也是最重要的一點:實現容錯,這也是分佈式系統設計要考慮的一個核心。想象一下一次RPC調用過程中可能產生的各種failure:
- 網絡擁塞
- 丟包,通信異常
- 服務提供端掛了,調用端得不到response
一種簡單的應對方式是不斷地超時重傳,即 at least once
模式。調用端設置一個超時定時器
,若一定時間內沒有收到response
就繼續發送調用請求,直到收到response
或請求次數達到閾值。這種模式會發送重複請求,因此只適用於冪等性的操作,即執行多次結果相同的操作,比如讀取操作。當然服務提供端也可以實現對應的邏輯來檢查重複的請求。
更符合我們期望的容錯方案是 at most once
模式。at most once
模式要求服務提供端檢查重複請求,如果檢查到當前請求是重複請求則返回之前的調用結果。服務提供端需要緩存之前的調用結果。
這裏面有幾點需要考慮:
如何實現重傳和重複請求檢測?是依靠協議(如TCP的超時重傳)還是自己實現?
如果自己實現的話:
如何檢查重複請求?我們可以給每個請求生成一個獨一無二的標識符(xid),並且在重傳請求的時候使用相同的xid進行重傳。用僞代碼可以表示爲:
if (seen(xid)) {
result = oldResult;
} else {
result = call(...);
oldResult = result;
setCurrentId(xid);
}
如何保證xid是獨一無二的?可以考慮使用UUID或者不同seed下的隨機數。
服務請求端需要在一個合適的時間丟棄掉保存的之前緩存的調用結果。
當某個RPC調用過程還正在執行時,如何應對另外的重複請求?這種情況可以設置一個flag用於標識是否正在執行。
如果服務調用端掛了並且重啓怎麼辦?如果服務調用端將xid和調用結果緩存在內存中,那麼保存的信息就丟失了。因此我們可以考慮將緩存信息定時寫入硬盤,或者寫入replication server
中,當然這些情況就比較複雜了,涉及到高可用和一致性的問題。
由此可見,雖然RPC模塊看似比較簡單,但是設計的時候要考慮的問題還是非常多的。尤其是在保證性能的基礎上又要保證可靠性,還要保證開發者的易用性,這就需要細緻地思考了。
常見RPC模塊實現
這裏我來簡單總結一下用過的常見的幾個RPC模塊的使用及實現思路。
Go RPC
Go
的rpc
包使用了Go自己的gob
協議作爲序列化協議(通過encoding/gob
模塊內的Encoder/Decoder
進行編碼和解碼),而傳輸協議可以直接使用TCP(Dial方法)或者使用HTTP(DialHTTP)方法。開發者需要在服務端定義struct並且實現各種方法,然後將struct註冊到服務端。需要進行RPC調用的時候,我們就可以在調用端通過Call方法(同步)或者Go方法(異步)進行調用。同步模式下調用結果即爲reply指針所指的對象,而異步模式則會在調用結果準備就緒後通知綁定的channel
並執行處理。
在rpc包的實現中(net/rpc/server.go)
,每個註冊的服務類都被封裝成了一個service
結構體,而其中的每個方法則被封裝成了一個methodType
結構體:
type methodType struct {
sync.Mutex // protects counters
method reflect.Method
ArgType reflect.Type
ReplyType reflect.Type
numCalls uint
}
type service struct {
name string // name of service
rcvr reflect.Value // receiver of methods for the service
typ reflect.Type // type of the receiver
method map[string]*methodType // registered methods
}
每個服務端都被封裝成了一個Server結構體,其中的serviceMap存儲着各個服務類的元數據:
type Server struct {
mu sync.RWMutex // protects the serviceMap
serviceMap map[string]*service
reqLock sync.Mutex // protects freeReq
freeReq *Request
respLock sync.Mutex // protects freeResp
freeResp *Response
}
RPC Server
處理調用請求的默認路徑是/_goRPC_
。當請求到達時,Go就會調用Server結構體實現的ServeHTTP
方法,經ServeConn
方法傳入gob codec
預處理以後最終在ServeCodec
方法內處理請求並進行調用:
func (server *Server) ServeCodec(codec ServerCodec) {
sending := new(sync.Mutex)
for {
service, mtype, req, argv, replyv, keepReading, err := server.readRequest(codec)
if err != nil {
if debugLog && err != io.EOF {
log.Println("rpc:", err)
}
if !keepReading {
break
}
// send a response if we actually managed to read a header.
if req != nil {
server.sendResponse(sending, req, invalidRequest, codec, err.Error())
server.freeRequest(req)
}
continue
}
go service.call(server, sending, mtype, req, argv, replyv, codec)
}
codec.Close()
}
如果成功讀取請求數據,那麼接下來RPC Server就會新建一個Goroutine用來在本地執行方法,並向調用端返回response:
func (s *service) call(server *Server, sending *sync.Mutex, mtype *methodType, req *Request, argv, replyv reflect.Value, codec ServerCodec) {
mtype.Lock()
mtype.numCalls++
mtype.Unlock()
function := mtype.method.Func
// Invoke the method, providing a new value for the reply.
returnValues := function.Call([]reflect.Value{s.rcvr, argv, replyv})
// The return value for the method is an error.
errInter := returnValues[0].Interface()
errmsg := ""
if errInter != nil {
errmsg = errInter.(error).Error()
}
server.sendResponse(sending, req, replyv.Interface(), codec, errmsg)
server.freeRequest(req)
}
在執行調用的過程中應該注意併發問題,防止資源爭用,修改數據時需要對數據加鎖;至於方法的執行就是利用了Go的反射機制。調用完以後,RPC Server接着調用sendResponse方法發送response,其中寫入response的時候同樣需要加鎖,防止資源爭用。
grpc
grpc是Google開源的一個通用的RPC框架,支持C, Java和Go等語言。既然是Google出品,序列化協議必然用protobuf啦(畢竟高效),傳輸協議使用HTTP/2,非常不錯。開發時需要在.proto文件裏定義數據類型以及服務接口,然後配上protoc的grpc插件就能夠自動生成各個語言的服務接口和代理類。粗略地看了下grpc-java的源碼,底層利用Netty和OkHttp實現HTTP通信,性能應該不錯。
Vert.x Service Proxy
Vert.x Service Proxy是Vert.x的一個異步RPC組件,支持通過各種JVM語言(Java, Scala, JS, JRuby, Groovy等)進行RPC調用。使用Vert.x Service Proxy時我們只需要按照異步開發模式編寫服務接口,加上相應的註解,Vert.x Service Proxy就會自動生成相應的服務代理類和服務調用處理類。Vert.x Service Proxy底層藉助Event Bus進行通信,調用時將調用消息包裝成JSON數據然後通過Event Bus傳輸到服務端,得到結果後再返回給調用端。Vert.x的一大特性就是異步、響應式編程,因此Vert.x Service Proxy的RPC模型爲異步RPC,用起來非常方便。幾個異步過程可以通過各種組合子串成一串,妥妥的reactive programming的風格~
更多的關於Vert.x Service Proxy的實現原理的內容可以看這一篇:Vert.x 技術內幕 | 異步RPC實現原理
Java RMI
Java RMI(Remote Method Invocation)是Java裏的一種RPC編程接口,類似於服務代理的一種模式。用起來不是很方便。