RPC 原理的前生今世

  在校期間大家都寫過不少程序,比如寫個hello world服務類,然後本地調用下,如下所示。這些程序的特點是服務消費方和服務提供方是本地調用關係。

  而一旦踏入公司,尤其是大型互聯網公司就會發現,公司的系統都由成千上萬大大小小的服務組成,各服務部署在不同的機器上,由不同的團隊負責。這時就會遇到兩個問題:

  (1) 要搭建一個新服務,免不了需要依賴他人的服務,而現在他人的服務都在遠端,怎麼調用?

  (2) 其它團隊要使用我們的服務,我們的服務該怎麼發佈以便他人調用?  

  下文我們將對這兩個問題展開探討:

  public interface HelloWorldService {
    String sayHello(String msg);
  }

  public class HelloWorldServiceImpl implements HelloWorldService {
    public String sayHello(String msg) {
      String result = "hello world " + msg;
      System.out.println(result);
      return result;
    }
  }
  public class Test {
    public static void main(String[] args) {
      HelloWorldService helloWorldService = new HelloWorldServiceImpl();
      helloWorldService.sayHello("test");
    }
  }

一、如何調用他人的遠程服務?

  由於各服務部署在不同的機器上,服務間的調用免不了網絡通信過程,服務消費方每調用一個服務都要寫一坨網絡通信相關的代碼,不僅複雜而且極易出錯。

  如果有一種方式能讓我們像調用本地服務一樣調用遠程服務,而讓調用者對網絡通信這些細節透明,那麼將大大提高生產力,比如服務消費方在執行helloWorldService.sayHello(“test”)時,實質上調用的是遠端的服務。

  這種方式其實就是RPC(Remote Procedure Call Protocol),在各大互聯網公司中被廣泛使用,如阿里巴巴的hsf、dubbo(開源)、Facebook的thrift(開源)、Google grpc(開源)等。

  要讓網絡通信細節對使用者透明,我們自然需要對通信細節進行封裝,我們先看下一個RPC調用的流程:

  a) 服務消費方(client)調用以本地調用方式調用服務;
  b) client stub接收到調用後負責將方法、參數等組裝成能夠進行網絡傳輸的消息體;
  c) client stub找到服務地址,並將消息發送到服務端;
  d) server stub收到消息後進行解碼;
  e) server stub根據解碼結果調用本地的服務;
  f) 本地服務執行並將結果返回給server stub;
  g) server stub將返回結果打包成消息併發送至消費方;
  h) client stub接收到消息,並進行解碼;
  j) 服務消費方得到最終結果。
  RPC的目標就是要b~h這些步驟都封裝起來,讓用戶對這些細節透明。

二、怎麼做到透明化遠程服務調用?

  怎麼封裝通信細節才能讓用戶像以本地調用方式調用遠程服務呢?對java來說就是使用代理!java代理有兩種方式:

  (1) 動態代理

  (2) 字節碼生成

  儘管字節碼生成方式實現的代理更爲強大和高效,但代碼不易維護,大部分公司實現RPC框架時還是選擇動態代理方式。

  下面簡單介紹下動態代理怎麼實現我們的需求。我們需要實現RPCProxyClient代理類,代理類的invoke方法中封裝了與遠端服務通信的細節,消費方首先從RPCProxyClient獲得服務提供方的接口,當執行helloWorldService.sayHello(“test”)方法時就會調用invoke方法。



  public class RPCProxyClient implements InvocationHandler {
    private Object obj;
    public RPCProxyClient(Object obj){
      this.obj=obj;
    }
    //得到被代理對象;
    public static Object getProxy(Object obj){
      return java.lang.reflect.Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getInterfaces(), new RPCProxyClient(obj));
    }
    //調用此方法執行
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      //結果參數
      Object result = new Object();
      // ...執行通信相關邏輯
      return result;
    }
  }
  public class Test {
    public static void main(String[] args) {
      HelloWorldService helloWorldService = (HelloWorldService)RPCProxyClient.getProxy(HelloWorldService.class);
      helloWorldService.sayHello("test");
    }
  }

三、怎麼對消息進行編碼和解碼?

  上節講了invoke裏需要封裝通信細節,而通信的第一步就是要確定客戶端和服務端相互通信的消息結構。客戶端的請求消息結構一般需要包括以下內容:  

  (1) 接口名稱
    在我們的例子裏接口名是“HelloWorldService”,如果不傳,服務端就不知道調用哪個接口了;
  (2) 方法名
    一個接口內可能有很多方法,如果不傳方法名服務端也就不知道調用哪個方法;
  (3) 參數類型&參數值
    參數類型有很多,比如有bool、int、long、double、string、map、list,甚至如struct(class);以及相應的參數值;
  (4) 超時時間  
  (5) requestID,標識唯一請求id

  同理服務端返回的消息結構一般包括以下內容:

  (1) 返回值

  (2) 狀態code

  (3) requestID

四、序列化

  一旦確定了消息的數據結構後,下一步就是要考慮序列化與反序列化了。

  什麼是序列化?序列化就是將數據結構或對象轉換成二進制串的過程,也就是編碼的過程。

  什麼是反序列化?將在序列化過程中所生成的二進制串轉換成數據結構或者對象的過程。

  爲什麼需要序列化?轉換爲二進制串後纔好進行網絡傳輸嘛!爲什麼需要反序列化?將二進制轉換爲對象纔好進行後續處理!

  現如今序列化的方案越來越多,每種序列化方案都有優點和缺點,它們在設計之初有自己獨特的應用場景,那到底選擇哪種呢?從RPC的角度上看,主要看三點:

  (1) 通用性,比如是否能支持Map等複雜的數據結構;

  (2) 性能,包括時間複雜度和空間複雜度,由於RPC框架將會被公司幾乎所有服務使用,如果序列化上能節約一點時間,對整個公司的收益都將非常可觀,同理如果序列化上能節約一點內存,網絡帶寬也能省下不少;

  (3) 可擴展性,對互聯網公司而言,業務變化快,如果序列化協議具有良好的可擴展性,支持自動增加新的業務字段,刪除老的字段,而不影響老的服務,這將大大提供系統的健壯性。

  目前國內各大互聯網公司廣泛使用hessian、protobuf、thrift、avro等成熟的序列化解決方案來搭建RPC框架,這些都是久經考驗的解決方案。

五、通信

  消息數據結構被序列化爲二進制串後,下一步就要進行網絡通信了。目前有兩種IO通信模型:

  (1) BIO;

  (2) NIO。

  一般RPC框架需要支持這兩種IO模型,原理可參考:《一個故事講清楚 NIO》

  如何實現RPC的IO通信框架?

  (1) 使用java nio方式自研,這種方式較爲複雜,而且很有可能出現隱藏bug;

  (2) 基於mina,mina在早幾年比較火熱,不過這些年版本更新緩慢;

  (3) 基於netty,現在很多RPC框架都直接基於netty這一IO通信框架,比如阿里巴巴的HSF、dubbo,Twitter的finagle等。

六、消息裏爲什麼要帶有requestID?

  如果使用netty的話,一般會用channel.writeAndFlush()方法來發送消息二進制串,這個方法調用後對於整個遠程調用(從發出請求到接收到結果)來說是一個異步的,即對於當前線程來說,將請求發送出來後,線程就可以往後執行了,至於服務端的結果,是服務端處理完成後,再以消息的形式發送給客戶端的。於是這裏出現以下兩個問題:

  (1) 怎麼讓當前線程“暫停”,等結果回來後,再向後執行?

  (2) 如果有多個線程同時進行遠程方法調用,這時建立在client server之間的socket連接上會有很多雙方發送的消息傳遞,前後順序也可能是隨機的,server處理完結果後,將結果消息發送給client,client收到很多消息,怎麼知道哪個消息結果是原先哪個線程調用的?

  如下圖所示,線程A和線程B同時向client socket發送請求requestA和requestB,socket先後將requestA和requestB發送至server,而server可能將responseB先返回,儘管requestB請求到達時間更晚。我們需要一種機制保證responseA丟給ThreadA,responseB丟給ThreadB。

  怎麼解決呢?

  (1) client線程每次通過socket調用一次遠程接口前,生成一個唯一的ID,即requestID(requestID必需保證在一個Socket連接裏面是唯一的),一般常常使用AtomicLong從0開始累計數字生成唯一ID;

  (2) 將處理結果的回調對象callback,存放到全局ConcurrentHashMap裏面put(requestID, callback);

  (3) 當線程調用channel.writeAndFlush()發送消息後,緊接着執行callback的get()方法試圖獲取遠程返回的結果。在get()內部,則使用synchronized獲取回調對象callback的鎖,再先檢測是否已經獲取到結果,如果沒有,然後調用callback的wait()方法,釋放callback上的鎖,讓當前線程處於等待狀態。

  (4) 服務端接收到請求並處理後,將response結果(此結果中包含了前面的requestID)發送給客戶端,客戶端socket連接上專門監聽消息的線程收到消息,分析結果,取到requestID,再從前面的ConcurrentHashMap裏面get(requestID),從而找到callback對象,再用synchronized獲取callback上的鎖,將方法調用結果設置到callback對象裏,再調用callback.notifyAll()喚醒前面處於等待狀態的線程。  

 public Object get() {
    synchronized (this) {     // 旋鎖
      while (!isDone) {     // 是否有結果了
        wait();       //沒結果是釋放鎖,讓當前線程處於等待狀態
      }
    }
  }

  private void setDone(Response res) {
    this.res = res;
    isDone = true;
    synchronized (this) {   // 獲取鎖,因爲前面wait()已經釋放了callback的鎖了
      notifyAll();         // 喚醒處於等待的線程
    }
  }

七、如何發佈自己的服務?

  如何讓別人使用我們的服務呢?有同學說很簡單嘛,告訴使用者服務的IP以及端口就可以了啊。確實是這樣,這裏問題的關鍵在於是自動告知還是人肉告知。

  人肉告知的方式:如果你發現你的服務一臺機器不夠,要再添加一臺,這個時候就要告訴調用者我現在有兩個ip了,你們要輪詢調用來實現負載均衡;調用者咬咬牙改了,結果某天一臺機器掛了,調用者發現服務有一半不可用,他又只能手動修改代碼來刪除掛掉那臺機器的ip。現實生產環境當然不會使用人肉方式。

  有沒有一種方法能實現自動告知,即機器的增添、剔除對調用方透明,調用者不再需要寫死服務提供方地址?當然可以,現如今zookeeper被廣泛用於實現服務自動註冊與發現功能!

  簡單來講,zookeeper可以充當一個服務註冊表(Service Registry),讓多個服務提供者形成一個集羣,讓服務消費者通過服務註冊表獲取具體的服務訪問地址(ip+端口)去訪問具體的服務提供者。如下圖所示:

  具體來說,zookeeper就是個分佈式文件系統,每當一個服務提供者部署後都要將自己的服務註冊到zookeeper的某一路徑上: /{service}/{version}/{ip:port}, 比如我們的HelloWorldService部署到兩臺機器,那麼zookeeper上就會創建兩條目錄:

  (1) /HelloWorldService/1.0.0/100.19.20.01:16888

  (2) /HelloWorldService/1.0.0/100.19.20.02:16888。

  zookeeper提供了“心跳檢測”功能,它會定時向各個服務提供者發送一個請求(實際上建立的是一個 socket 長連接),如果長期沒有響應,服務中心就認爲該服務提供者已經“掛了”,並將其剔除,比如100.19.20.02這臺機器如果宕機了,那麼zookeeper上的路徑就會只剩/HelloWorldService/1.0.0/100.19.20.01:16888。

  服務消費者會去監聽相應路徑(/HelloWorldService/1.0.0),一旦路徑上的數據有任務變化(增加或減少),zookeeper都會通知服務消費方服務提供者地址列表已經發生改變,從而進行更新。

  更爲重要的是zookeeper 與生俱來的容錯容災能力(比如leader選舉),可以確保服務註冊表的高可用性。

八、小結

  RPC幾乎是每一個從學校進入互聯網公司的同學都要首先學習的框架,之前面試過一個在大型互聯網公司工作過兩年的同學,對RPC還是停留在使用層面,這是不應該的。本文也僅是對RPC的一個比較粗糙的描述,希望對大家有所幫助,錯誤之處也請指出修正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章