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

滴滴技術發表於2019-06-23

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所示,工作流程依次見圖中標號①~⑩,其結構主要包含以下部分:

HDFS 原始碼解讀:HadoopRPC 實現細節的探究圖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 的主要兩大介面如下:

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,如:

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 方法如下:

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

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

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 方法,其程式碼如下:

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 方法如下:

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 物件返回值。

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

圖2 RPC Client工作流程

RPC Server 解讀

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

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

圖3 Server類內部結構關係

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

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

圖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 更加健壯。

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

圖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所示。


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

圖6 CallQueue示例圖

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

圖7 FairCallQueue示例圖

啟用 FairCallQueue,同樣是在配置檔案中修改 Queue 的實現 callqueue.impl。其中,FairCallQueue 引入了優先順序機制,具體分析如下。

優先順序

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

優先順序確定

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

/**
 * 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 採用加權輪詢演算法,相關程式碼及註釋如下:

/**
 * 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 目錄結構:

hadoop
├── bin        指令碼命令核心
├── etc      配置
├── include    C標頭檔案等
├── lib        依賴
├── libexec    shell配置
├── logs       日誌
├── sbin       啟停服務
├── share      編譯打包檔案

其中 hadoop 即為 bin 目錄下的 hadoop 指令碼,找到相關指令碼:

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.   小結

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

本文作者

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

王 洪 兵

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

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

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69908606/viewspace-2648472/,如需轉載,請註明出處,否則將追究法律責任。

相關文章