HDFS 源碼解讀:HadoopRPC 實現細節的探究

HDSF 作爲分佈式文件系統,常常涉及 DataNode、NameNode、Client 之間的配合、相互調用才能完成完整的流程。爲了降低節點之間的耦合性,HDFS 將節點間的調用抽象成不同的接口,其接口主要分爲兩類:HadoopRPC 接口和基於 TCP 或 HTTP 的流式接口。流式接口主要用於數據傳輸,HadoopRPC 接口主要用於方法調用。HadoopRPC 框架設計巧妙,本文將結合 hadoop2.7 源碼,對 HadoopRPC 做初步剖析。

0.  目錄

1. RPC工作原理

2. HadoopRPC架構設計

  • RPC Client解讀

  • RPC Server解讀

3. 關於併發時的優化

  • 參數配置

  • CallQueue與FairCallQueue

    • 優先級

    • 優先級確定

    • 優先級權重

4. 從一個命令解析

5. 小結

1.   RPC工作原理

RPC(Remote Procedure Call)即遠程過程調用,是一種通過網絡從遠程計算機程序上請求服務的協議。RPC允許本地程序像調用本地方法一樣調用遠程計算機上的應用程序,其使用常見的網絡傳輸協議(如TCP或UDP)傳遞RPC請求以及相應信息,使得分佈式程序的開發更加容易。

RPC採用客戶端/服務器模式,請求程序就是客戶端,服務提供程序就是服務器。RPC框架工作原理如圖1所示,工作流程依次見圖中標號①~⑩,其結構主要包含以下部分:

bb圖1 RPC框架工作原理示例圖

  • client functions

    請求程序,會像調用本地方法一樣調用客戶端stub程序(如圖中①),然後接受stub程序的響應信息(如圖中⑩)

  • client stub

    客戶端stub程序,表現得就像本地程序一樣,但底層卻會調用請求和參數序列化並通過通信模塊發送給服務器(如圖中②);客戶端stub程序也會等待服務器的響應信息(如圖中⑨),將響應信息反序列化並返回給請求程序(如圖中⑩)

  • sockets

    網絡通信模塊,用於傳輸RPC請求和響應(如圖中的③⑧),可以基於TCP或UDP協議

  • server stub

    服務端stub程序,會接收客戶端發送的請求和參數(如圖中④)並反序列化,根據調用信息觸發對應的服務程序(如圖中⑤),然後將服務程序的響應信息(如圖⑥),並序列化併發回給客戶端(如圖中⑦)

  • server functions

    服務程序,會接收服務端stub程序的調用請求(如圖中⑤),執行對應的邏輯並返回執行結果(如圖中⑥)

那麼要實現RPC框架,基本上要解決三大問題:

  • 函數/方法識別

    sever functions如何識別client functions請求及參數,並執行函數調用。java 中可利用反射可達到預期目標。

  • 序列化及反序列化

    如何將請求及參數序列化成網絡傳輸的字節類型,反之還原請求及參數。已有主流的序列化框架如 protobuf、avro 等。

  • 網絡通信

    java 提供網絡編程支持如 NIO。

主流的 RPC 框架,除 HadoopRPC 外,還有 gRPC、Thrift、Hessian 等,以及 Dubbo 和 SpringCloud 中的 RPC 模塊,在此不再贅述。下文將解讀 HDFS 中 HadoopRPC 的實現。

2.    HadoopRPC架構設計

HadoopRPC 實現了圖 1 中所示的結構,其實現主要在 org.apache.hadoop.ipc 包下,主要由三個類組成:RPC 類、Client 類和Server 類。HadoopRPC 實現了基於 TCP/IP/Sockets 的網絡通信功能。客戶端可以通過 Client 類將序列化的請求發送到遠程服務器,服務器會通過 Server 類接收客戶端的請求。

客戶端 Client 在收到請求後,會將請求序列化,然後調用 Client.call() 方法發送請求到到遠程服務器。爲使 RPC 機制更加健壯,HadoopRPC 允許配置不同的序列化框架如 protobuf。Client 將序列化的請求 rpcRequest 封裝成 Writable 類型用於網絡傳輸。具體解析見下節—— RPC Client 解讀。

服務端 Server 採用 java NIO 提供的基於 Reactor 設計模式。Sever 接收到一個 RPC Writable 類型請求後,會調用 Server.call() 方法響應這個請求,並返回 Writable 類型作爲響應結果。具體解析見下節—— RPC Server 解讀。

RPC 類提供一個統一的接口,在客戶端可以獲取 RPC 協議代理對象,在服務端可以調用 build() 構造 Server 類,並調用 start() 啓動 Server 對象監聽並響應 RPC 請求。同時,RPC 類提供 setProtocolEngine() 爲客戶端或服務端適配當前使用的序列化引擎。RPC 的主要兩大接口如下:

1
2
public static ProtocolProxy getProxy/waitForProxy(…):構造一個客戶端代理對象(該對象實現了某個協議),用於向服務器發送RPC請求。
public static Server RPC.Builder(Configuration).build():爲某個協議(實際上是Java接口)實例構造一個服務器對象,用於處理客戶端發送的請求

那麼,如何使用HadoopRPC呢?只需按如下4個步驟:

1. 定義RPC協議

RPC協議是客戶端和服務器端之間的通信接口,它定義了服務器端對外提供的服務接口。如ClientProtocol定義了HDFS客戶端與NameNode的通信接口, ClientDatanodeProtocol定義了HDFS客戶端與DataNode的通信接口等。

2. 實現RPC協議

對接口的實現,將會調用Server端的接口的實現。

3. 構造並啓動RPC Server

構造Server並監聽請求。可使用靜態類Builder構造一個RPC Server,並調用函數start()啓動該Server,如:

1
2
3
4
5
6
7
RPC.Server server = new RPC.Builder(conf).setProtocol(MyProxyProtocol.class)
        .setInstance(new MyProxy())
        .setBindAddress(HOST)
        .setNumHandlers(2)
        .setPort(PORT)
        .build();
server.start();

4. 構造RPC Client併發送請求


構造客戶端代理對象,當有請求時客戶端將通過動態代理,調用代理方法進行後續實現,如:

MyProxyProtocol proxy = RPC.getProxy(MyProxyProtocol.class,        MyProxyProtocol.versionID,        new InetSocketAddress(HOST, PORT), conf);XXX result = proxy.fun(args);
RPC Client解讀

在 IPC(Inter-Process Communication)發生之前,客戶端需要通過 RPC 提供的 getProxy 或 waitForProxy 獲得代理對象,以 getProxy 的具體實現爲例。RPC.getProxy 直接調用了 RPC.getProtocolProxy 方法,getProtocolProxy 方法如下:

1
2
3
4
public static <T> ProtocolProxy<T> getProtocolProxy(...) throws IOException {
   ...
   return getProtocolEngine(protocol, conf).getProxy(...);
}

RPC 類提供了 getProtocolEngine 類方法用於適配 RPC 框架當前使用的序列化引擎,hadoop 本身實現了 Protobuf 和 Writable 序列化的RpcEngine 。以WritableRPCEngine 爲例,getProxy(...) 實現如下:

1
2
3
4
5
6
7
8
public <T> ProtocolProxy<T> getProxy(...) throws IOException {   
  ...
  // 這裏調用到原生的代理
  T proxy = (T) Proxy.newProxyInstance(protocol.getClassLoader(),
      new Class[] { protocol }, new WritableRpcEngine.Invoker(protocol, addr, ticket, conf,
          factory, rpcTimeout, fallbackToSimpleAuth));
  return new ProtocolProxy<T>(protocol, proxy, true);
}

上述使用動態代理模式,Proxy 實例化時 newProxyInstance 傳進去的 InvocationHandler 的實現類是 WritableRpcEngine 的內部類 Invoker。 當 proxy 調用方法時,會代理到 WritableRpcEngine.Invoker 中的 invoke 方法,其代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static class Invoker implements RpcInvocationHandler {
    ....
      
    // 構造器
    public Invoker(...) throws IOException {
      ...
      this.client = CLIENTS.getClient(conf, factory);
      ...
    }
  
    // 執行的invoke方法
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      ...
      ObjectWritable value;
      try {
        value = (ObjectWritable)
          client.call(RPC.RpcKind.RPC_WRITABLE, new WritableRpcEngine.Invocation(method, args), remoteId, fallbackToSimpleAuth);
      finally {
        if (traceScope != null) traceScope.close();
      }
      ...
      return value.get();
    }
    ...
}


在 invoke 方法中,調用了 Client 類的 call 方法,並得到 RPC 請求的返回結果。其中 new WritableRpcEngine.Invocation(method, args) 實現了 Writable 接口,這裏的作用是將 method 和 args 進行序列化成 Writable 傳輸類型。Client 類中的 call 方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public Writable call(RPC.RpcKind rpcKind, Writable rpcRequest, ConnectionId remoteId, int serviceClass, AtomicBoolean fallbackToSimpleAuth) throws IOException {
    final Call call = createCall(rpcKind, rpcRequest);
    Connection connection = getConnection(remoteId, call, serviceClass, fallbackToSimpleAuth);
    try {
      // 將遠程調用信息發送給server端
      connection.sendRpcRequest(call);                 // send the rpc request
    catch (RejectedExecutionException e) {
      throw new IOException("connection has been closed", e);
    catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new IOException(e);
    }
  
    synchronized (call) {
      // 判斷call是否完成,等待server端notify
      while (!call.done) {
        try {
          // 當前線程blocking住,
          // 等待Connection線程中receiveRpcResponse調用call.notify
          call.wait();                           // wait for the result
        catch (InterruptedException ie) {
          Thread.currentThread().interrupt();
          throw new InterruptedIOException("Call interrupted");
        }
      }
  
      if (call.error != null) {
        if (call.error instanceof RemoteException) {
          call.error.fillInStackTrace();
          throw call.error;
        else // local exception
          InetSocketAddress address = connection.getRemoteAddress();
          throw NetUtils.wrapException(address.getHostName(),
                  address.getPort(),
                  NetUtils.getHostname(),
                  0,
                  call.error);
        }
      else {
        // 得到server結果
        return call.getRpcResponse();
      }
    }
  }

以上代碼展現了 call() 方法作爲代理方法的整個流程。從整體來講,客戶端發送請求和接收請求在兩個獨立的線程中完成,發送線程調用 Client.call() 線程,而接收響應則是 call() 啓動的 Connection 線程( getConnection 方法中,由於篇幅原因不再展示)。

那麼二者如何同步 Server 的響應信息呢?內部類 Call 對象在此起到巧妙地同步作用。當線程1調用 Client.call() 方法發送 RPC 請求到 Server,會在請求對應的 Call 對象上調用 Call.wait() 方法等待 Server 響應信息;當線程2接收到 Server 響應信息後,將響應信息保存在 Call.rpcResponse 字段中,然後調用 Call.notify() 喚醒線程1。線程1被喚醒從 Call 中取出響應信息並返回。整個流程如圖2所示,分析如下。

  • 在 call 方法中先將遠程調用信息封裝成一個 Client.Call 對象(保存了完成標誌、返回信息、異常信息等),然後得到 connection 對象用於管理 Client 與 Server 的 Socket 連接。

  • getConnection 方法中通過 setupIOstreams 建立與 Server 的 socket 連接,啓動 Connection 線程,監聽 socket 讀取 server 響應。

  • call() 方法發送 RCP 請求。

  • call() 方法調用 Call.wait() 在 Call 對象上等待 Server 響應信息。

  • Connection 線程收到響應信息設置 Call 對象返回信息字段,並調用 Call.notify() 喚醒 call() 方法線程讀取 Call 對象返回值。

bb

圖2 RPC Client工作流程

RPC Server 解讀

Server部分主要負責讀取請求並將其反序列化,然後處理請求並將響應序列化,最後返回響應。爲了提高性能,Server 採用 NIO Reactor 設計模式。服務器只有在指定 IO 事件發生時纔會執行對應業務邏輯,避免 IO 上無謂的阻塞。首先看一下 Server 類的內部結構,如圖3所示,其中有4個內部類主要線程類:Listener、Reader、Hander、Resonder。

bb

圖3 Server類內部結構關係

Server將各個部分的處理如請求讀取、處理邏輯等開闢各自的線程。整個 Server 處理流程如圖4所示。

bb

圖4 RPC Server處理流程

    Server 處理流程解讀如下:

  1. 整個 Server 只有一個 Listener 線程,Listener 對象中的 Selector 對象 acceptorSelector 負責監聽來自客戶端的 Socket 連接請求。acceptorSelector 在ServerSocketChannel 上註冊 OP_ACCEPT 事件,等待客戶端 Client.call() 中的 getConnection 觸發該事件喚醒 Listener 線程,創建新的 SocketChannel 並創建 readers 線程池;Listener 會在 reader 線程池中選取一個線程,並在 Reader 的 readerSelector 上註冊 OP_READ 事件。

  2. readerSelector 監聽 OP_READ 事件,當客戶端發送 RPC 請求,觸發 readerSelector 喚醒 Reader 線程;Reader 線程從 SocketChannel 中讀取數據封裝成 Call 對象,然後放入共享隊列 callQueue。

  3. 最初,handlers 線程池都在 callQueue 上阻塞(BlockingQueue.take()),當有 Call 對象加入,其中一個 Handler 線程被喚醒。根據 Call 對象上的信息,調用 Server.call() 方法(類似 Client.call() ),反序列化並執行 RPC 請求對應的本地函數,最後將響應返回寫入 SocketChannel。

  4. Responder 線程起着緩衝作用。當有大量響應或網絡不佳時,Handler 不能將完整的響應返回客戶端,會在 Responder 的 respondSelector 上註冊 OP_WRITE 事件,當監聽到寫條件時,會喚醒 Responder 返回響應。

整個 HadoopRPC 工作流程如圖5所示。其中,動態代理與反射執行目標方法貫穿整個 Client 與 Server,Server 整體又採用 NIO Reactor 模式,使得整個 HadoopRPC 更加健壯。

bb

圖5 HadoopRPC整體工作流程

3.  關於併發時的優化

參與配置

Server 端僅存在一個 Listener 線程和 Responder 線程,而 Reader 線程和 Handler 線程卻有多個,那個如何配置 Reader 與 Handler 線程個數呢?HadoopRPC 對外提供參數配置,使用常見的配置方式即在 etc/hadoop 下配置 xml 屬性:

  • ipc.server.read.threadpool.size:Reader線程數,默認1

  • dfs.namenode.service.handler.count:Handler線程數,默認10

  • ipc.server.handler.queue.size:每個 Handler 處理的最大 Call 隊列長度,默認100。結合 Handler 線程數,則默認可處理的 callQueue 最大長度爲 10*1000=1000

CallQueue 與 FairCallQueue

共享隊列 CallQueue 以先進先出(FIFO)方式提供請求,如果 99% 的請求來自一個用戶,則 99% 的時間將會爲一個用戶服務。因此,惡意用戶便可以通過每秒發出許多請求來影響 NameNode 性能。爲了防止某個用戶的 cleint 的大量請求導致 NameNode 無法響應,HadoopRPC 引入 FairCallQueue 來替代共享隊列 CallQueue,請求多的用戶將會被請求降級處理。CallQueue 和 FairCallQueue 對比圖如圖6、圖7所示。


bb

圖6 CallQueue示例圖

bb

圖7 FairCallQueue示例圖

啓用 FairCallQueue,同樣是在配置文件中修改 Queue 的實現 callqueue.impl。其中,FairCallQueue 引入了優先級機制,具體分析如下。

優先級

共享隊列 callQueue 導致 RPC 擁塞,主要原因是將 Call 對象放在一起處理。FairCallQueue 首先改進的是劃分出優先級關係,每個優先級對應一個隊列,比如 Queue0,Queue1,Queue2 ...,然後定義一個規則,數字越小的,優先級越高。

優先級確定

如何確定哪些請求該放到哪些優先級隊列中呢?比較智能的做法是根據用戶的請求頻率確定優先級。頻率越高,分到優先級越低的隊列。比如,在相同時限內,A用戶請求50次,B用戶請求5次,則B用戶將放入優先級較高的隊列。這就涉及到在一定時限內統計用戶請求頻率,FairCallQueue 進入了一種頻率衰減算法,前面時段內的計數結果通過衰減因子在下一輪的計算中,佔比逐步衰減,這種做法比完全清零統計要平滑得多。相關代碼如下:

1
2
3
4
5
6
7
/**
 * The decay RPC scheduler counts incoming requests in a map, then
 * decays the counts at a fixed time interval. The scheduler is optimized
 * for large periods (on the order of seconds), as it offloads work to the
 * decay sweep.
 */
public class DecayRpcScheduler implements RpcScheduler, DecayRpcSchedulerMXBean {...}

從註釋可知,衰減調度將對請求進行間隔幾秒鐘的計數統計,用於平滑計數。

優先級權重

爲了防止低優先級隊列“飢餓”,用輪詢的方式從各個隊列中取出一定的批次請求,再針對各個隊列設置一個理論比重。FairCallQueue 採用加權輪詢算法,相關代碼及註釋如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * Determines which queue to start reading from, occasionally drawing from
 * low-priority queues in order to prevent starvation. Given the pull pattern
 * [9, 4, 1] for 3 queues:
 *
 * The cycle is (a minimum of) 9+4+1=14 reads.
 * Queue 0 is read (at least) 9 times
 * Queue 1 is read (at least) 4 times
 * Queue 2 is read (at least) 1 time
 * Repeat
 *
 * There may be more reads than the minimum due to race conditions. This is
 * allowed by design for performance reasons.
 */
public class WeightedRoundRobinMultiplexer implements RpcMultiplexer {...}

從註釋可知,若 Q0、Q1、Q2 的比重爲 9:4:1,理想情況下在 15 次請求中,Q0 隊列處理 9 次請求,Q1 隊列處理 4 次請求,Q2 隊列處理 1 次請求。

4.  從一個命令解析

接下來將從常見的一條命令解讀 HadoopRPC 在 HDFS 中的應用:

hadoop fs -mkdir /user/test

首先看一下 hadoop 目錄結構:

1
2
3
4
5
6
7
8
9
hadoop
├── bin        腳本命令核心
├── etc      配置
├── include    C頭文件等
├── lib        依賴
├── libexec    shell配置
├── logs       日誌
├── sbin       啓停服務
├── share      編譯打包文件

其中 hadoop 即爲 bin 目錄下的 hadoop 腳本,找到相關腳本:

1
2
3
4
5
6
7
8
9
10
case $COMMAND in
    ...
    #core commands 
    *)
      # the core commands
      if [ "$COMMAND" "fs" ] ; then
         CLASS=org.apache.hadoop.fs.FsShell
    ...
export CLASSPATH=$CLASSPATH
exec "$JAVA" $JAVA_HEAP_MAX $HADOOP_OPTS $CLASS "$@"

由腳本可知,最終執行了 java -OPT xxx org.apache.hadoop.fs.FsShell -mkdir /user/test ,轉換爲最熟悉的 java 類調用。

進入 org.apache.hadoop.fs.FsShell 類的 main 方法中,調用 ToolRunner.run(),並由FsShell.run() 根據參數“-mkdir”解析出對應的 Command 對象。最後由ClientProtocol.mkdirs() 發送RPC請求,向NameNode請求創建文件夾。相關代碼如下:}

rpcProxy.mkdirs() 過程則 HadoopRPC 完成。

4.   小結               鄭州不孕不育醫院:http://byby.zztjyy.com/yiyuanzaixian/zztjyy//

HadoopRPC 是優秀的、高性能的 RPC 框架,不管是設計模式,還是其他細節技巧都值得開發者學習。

本文作者

bb

王 洪 兵

滴滴出行 | 軟件開發工程師

                            2018年畢業加入滴滴,任職於大數據架構部,對調度系統、大數據底層原理有一定的研究。熱愛技術,也熱愛旅行。


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