天天看點

通過 HTTP/2 協定案例學習 Java & Netty 性能調優:工具、技巧與方法論

作者:碼客生活

Dubbo3 Triple 協定是參考 gRPC、gRPC-Web、Dubbo2 等協定特點設計而來,它吸取各自協定特點,完全相容 gRPC、Streaming 通信、且無縫支援 HTTP/1 和浏覽器。

當你在 Dubbo 架構中使用 Triple 協定,然後你就可以直接使用 Dubbo 用戶端、gRPC 用戶端、curl、浏覽器等通路你釋出的服務,不需要任何額外元件與配置。

除易用性以外,Dubbo3 Triple 在性能調優方面做了大量工作,本文将側重對 Triple 協定背後的高性能秘密進行深入講解,涉及一些有價值的性能調優工具、技巧及代碼實作;在下一篇文章中,我們将具體展開 Triple 協定在易用性方面的一些具體使用場景。

為什麼要優化 Triple 協定的性能?

Aliware

自 2021 年開始 Dubbo3 就已經作為下一代服務架構逐漸開始取代阿裡内部廣泛使用的 HSF 架構,截止目前,阿裡以淘寶、天貓等電商為代表的絕大多數核心應用已經成功更新到 Dubbo3。作為過去兩年支撐阿裡雙十一萬億級服務調用的關鍵架構,Triple 通信協定的性能直接影響整個系統的運作效率。

前置知識

Aliware

1. Triple 協定簡介

Triple 協定是參考 gRPC 與 gRPC-Web 兩個協定設計而來,它吸取了兩個協定各自的特性和優點,将它們整合在一起,成為一個完全相容 gRPC 且支援 Streaming 通信的協定,同時 Triple 還支援 HTTP/1、HTTP/2。

Triple 協定的設計目标如下:

  • Triple 設計為對人類友好、開發調試友好的一款基于 HTTP 的協定,尤其是對 unary 類型的 RPC 請求。
  • 完全相容基于 HTTP/2 的 gRPC 協定,是以 Dubbo Triple 協定實作可以 100% 與 gRPC 體系互調互通。

當你在 Dubbo 架構中使用 Triple 協定,然後你就可以直接使用 Dubbo 用戶端、gRPC 用戶端、curl、浏覽器等通路你釋出的服務。

以下是使用 curl 用戶端通路 Dubbo 服務端一個 Triple 協定服務的示例:

curl \
  --header "Content-Type: application/json"\
  --data '{"sentence": "Hello Dubbo."}'\
https://host:port/org.apache.dubbo.sample.GreetService/sayHello           

在具體實作上,Dubbo Triple 支援 Protobuf Buffer 但并不綁定,比如 Dubbo Java 支援以 Java Interface 定義 Triple 服務,這對于關注特定語言易用性的開發者将更容易上手。另外,Dubbo 目前已經提供了 Java、Go、Rust 等語言實作,目前正在推進 Node.js 等語言的協定實作,我們計劃通過多語言和 Triple 協定打通移動端、浏覽器、後端微服務體系。

在 Triple 的實作中核心的元件有以下幾個:

TripleInvoker 是 Triple 協定的核心元件之一,用于請求調用 Triple 協定的服務端。其中核心方法為 doInvoke,該方法會根據請求類型如 UNARY、BiStream 等,發起不一樣類型的請求。如 UNARY 在 SYNC 下即同步阻塞調用,一個請求對應一個響應。BiStream 則是雙向通訊,用戶端可以持續發送請求,而服務端同樣也可以持續推送消息,他們之間通過回調 StreamObserver 元件的方法實作互動。

TripleClientStream 是 Triple 協定的核心元件之一,該元件與 HTTP/2 中的Stream 概念與之對應,每次發起一個新的請求均會建立一個新的TripleClientStream,同理與之對應的 HTTP/2 的 Stream 也是不相同的。TripleClientStream 提供核心的方法有 sendHeader 用來發送頭部幀 Header Frame,以及 sendMessage 用來發送資料幀 Data Frame。

WriteQueue 是 Triple 協定中用于寫出消息的緩沖隊列,其核心邏輯就是将各種操作指令 QueueCommand 添加到内部維護的隊列中,并嘗試将這些 QueueCommand 對應的任務送出到 Netty 的 EventLoop 線程中單線程、有序的執行。

QueueCommand 是專門用于送出到 WriteQueue 的任務抽象類,不同的 Command 對應了不同的執行邏輯。

TripleServerStream 是 Triple 協定中服務端的 Stream 抽象,該元件與 HTTP/2 中的 Stream 概念與之對應,用戶端每通過一個新的 Stream 發起請求,服務端便會建立一個與之對應的 TripleServerStream,以便處理用戶端發來的請求資訊。

2. HTTP/2

HTTP/2 是一種新一代的 HTTP 協定,是 HTTP/1.1 的替代品,HTTP/2 相較于 HTTP/1.1 的最大改進在于減少了資源的消耗提高了性能。HTTP/1.1 中,浏覽器隻能在一個 TCP 連接配接中發送一個請求。如果浏覽器需要加載多個資源,那麼浏覽器就需要建立多個 TCP 連接配接。這種方式會導緻一些問題,例如 TCP 連接配接的建立和斷開會增加網絡延遲,而且浏覽器可能會在同一時間内發送多個請求導緻網絡擁塞。

相反,HTTP/2 允許浏覽器在一個 TCP 連接配接中同時發送多個請求,多個請求對應多個 Stream 流,多個流之間互相獨立,并以并行的方式流轉。而在每個流中,這些請求會被拆分成多個 Frame 幀,這些幀在同一個流中以串行的方式流轉,嚴格的保證了幀的有序性。是以用戶端可以并行發送多個請求,而伺服器也可以并行發送多個響應,這有助于減少網絡連接配接數,以及網絡延遲和提高性能。

HTTP/2 還支援伺服器推送,這意味着伺服器可以在浏覽器請求之前預加載資源。例如,如果伺服器知道浏覽器将要請求一個特定的資源,那麼伺服器可以在浏覽器請求之前将該資源推送到浏覽器。這有助于提高性能,因為浏覽器不需要等待資源的請求和響應。

HTTP/2 還支援頭部壓縮,這意味着 HTTP 頭部中的重複資訊可以被壓縮。這有助于減少網絡帶寬的使用。

3. Netty

Netty 是一個高性能異步事件驅動的網絡架構,主要用于快速開發可維護的高性能協定伺服器和用戶端。它的主要特點是易于使用、靈活性強、性能高、可擴充性好。Netty 使用 NIO 作為基礎,可以輕松地實作異步、非阻塞的網絡程式設計,支援 TCP、UDP、HTTP、SMTP、WebSocket、SSL 等多種協定。Netty 的核心元件包括Channel、EventLoop、ChannelHandler 和 ChannelPipeline。

Channel 是一個傳輸資料的雙向通道,可以用來處理網絡 I/O 操作。Netty 的Channel實作了 Java NIO 的 Channel 接口,并在此基礎上添加了一些功能,例如支援異步關閉、綁定多個本地位址、綁定多個事件處理器等。

EventLoop 是 Netty 的核心元件之一,它負責處理所有 I/O 事件和任務。一個 EventLoop 可以管理多個 Channel,每個 Channel 都有一個對應的 EventLoop。EventLoop 使用單線程模型來處理事件,避免了線程之間的競争和鎖的使用,進而提高了性能。

ChannelHandler 是連接配接到 ChannelPipeline 的處理器,它可以處理入站和出站的資料,例如編碼、解碼、加密、解密等。一個 Channel 可以有多個 ChannelHandler,ChannelPipeline 會按照添加的順序依次調用它們來處理資料。

ChannelPipeline 是 Netty 的另一個核心元件,它是一組按順序連接配接的ChannelHandler,用于處理入站和出站的資料。每個 Channel 都有自己獨占的 ChannelPipeline,當資料進入或離開 Channel 時,會經過所有的 ChannelHandler,由它們來完成處理邏輯。

工具準備

Aliware

為了對代碼進行調優,我們需要借助一些工具來找到 Triple 協定性能瓶頸的位置,例如阻塞、熱點方法。而本次調優用到的工具主要有 VisualVM 以及 JFR。

Visual VM

Visual VM 是一個可以監視本地和遠端的 Java 虛拟機的性能和記憶體使用情況的圖形化工具。它是一個開源項目,可以用于識别和解決 Java 應用程式的性能問題。

Visual VM 可以顯示 Java 虛拟機的運作狀況,包括 CPU 使用率、線程數、記憶體使用情況、垃圾回收等。它還可以顯示每個線程的 CPU 使用情況和堆棧跟蹤,以便識别瓶頸。

Visual VM 還可以分析堆轉儲檔案,以識别記憶體洩漏和其他記憶體使用問題。它可以檢視對象的大小、引用和類型,以及對象之間的關系。

Visual VM 還可以在運作時監視應用程式的性能,包括方法調用次數、耗時、異常等。它還可以生成 CPU 和記憶體使用情況的快照,以便進一步分析和優化。

JFR

JFR 全稱為 Java Flight Recorder,是 JDK 提供的性能分析工具。JFR 是一種輕量級的、低開銷的事件記錄器,它可以用來記錄各種事件,包括線程的生命周期、垃圾回收、類加載、鎖競争等等。JFR 的資料可以用來分析應用程式的性能瓶頸,以及識别記憶體洩漏等問題。與其他性能分析工具相比,JFR 的特點在于它的開銷非常低,可以一直開啟記錄,而不會對應用程式本身的性能産生影響。

JFR 的使用非常簡單,隻需要在啟動 JVM 時添加啟動參數 -XX:+UnlockCommercialFeatures -XX:+FlightRecorder,就可以開啟 JFR 的記錄功能。當 JVM 運作時,JFR 會自動記錄各種事件,并将它們儲存到一個檔案中。記錄結束後,我們可以使用工具 JDK Mission Control 來分析這些資料。例如,我們可以檢視 CPU 的使用率、記憶體的使用情況、線程的數量、鎖競争情況等等。JFR還提供了一些進階的功能,例如事件過濾、自定義事件、事件堆棧跟蹤等等。

在本次性能調優中,我們重點關注 Java 中能明顯影響性能的事件:Monitor Blocked、Monitor Wait、Thread Park、Thread Sleep。

  • Monitor Blocked 事件由 synchronized 塊觸發,表示有線程進入了同步代碼塊
  • Monitor Wait 事件由 Object.wait 觸發,表示有代碼調用了該方法
  • Thread Park 事件由 LockSupport.park 觸發,表示有線程被挂起
  • Thread Sleep 事件由 Thread.sleep() 觸發,表示代碼中存在手動調用該方法的情況

調優思路

Aliware

1. 非阻塞

高性能的關鍵點之一是編碼時必須是非阻塞的,代碼中如果出現了 sleep、await 等類似方法的調用,将會阻塞線程并直接影響到程式的性能,是以在代碼中應盡可能避免使用阻塞式的 API,而是使用非阻塞的 API。

2. 異步

在調優思路中,異步是其中一個關鍵點。在代碼中,我們可以使用異步的程式設計方式,例如使用 Java8 中的 CompletableFuture 等。這樣做的好處在于可以避免線程的阻塞,進而提高程式的性能。

3. 分治

在調優過程中,分治也是一個很重要的思路。例如可以将一個大的任務分解成若幹個小任務,然後使用多線程并行的方式來處理這些任務。這樣做的好處在于可以提高程式的并行度,進而充分利用多核 CPU 的性能,達到優化性能的目的。

4. 批量

在調優思路中,批量也是一個很重要的思路。例如可以将多個小的請求合并成一個大的請求,然後一次性發送給伺服器,這樣可以減少網絡請求的次數,進而降低網絡延遲和提高性能。另外,在處理大量資料時,也可以使用批量處理的方式,例如一次性将一批資料讀入記憶體,然後進行處理,這樣可以減少 IO 操作的次數,進而提高程式的性能。

高性能的基石:非阻塞

Aliware

不合理的 syncUninterruptibly

通過直接檢查代碼,我們發現了一處明顯明顯會阻塞目前線程的方法 syncUninterruptibly。而使用 DEBUG 的方式可以很輕松的得知該代碼會在使用者線程中進行,其中源碼如下所示。

private WriteQueue createWriteQueue(Channel parent) {

  final Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);

  final Future<Http2StreamChannel> future = bootstrap.open().syncUninterruptibly();

  if (!future.isSuccess()) {

    throw new IllegalStateException("Create remote stream failed. channel:" + parent);

  }

  final Http2StreamChannel channel = future.getNow();

  channel.pipeline()

    .addLast(new TripleCommandOutBoundHandler())

    .addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));

  channel.closeFuture()

    .addListener(f -> transportException(f.cause()));

  return new WriteQueue(channel);

}           

此處代碼邏輯如下:

  • 通過 TCP Channel 構造出 Http2StreamChannelBootstrap
  • 通過調用 Http2StreamChannelBootstrap 的 open 方法得到 Future<Http2StreamChannel>
  • 通過調用 syncUninterruptibly 阻塞方法等待 Http2StreamChannel 建構完成
  • 得到 Http2StreamChannel 後再構造其對應的 ChannelPipeline

而在前置知識中我們提到了 Netty 中大部分的任務都是在 EventLoop 線程中以單線程的方式執行的,同樣的當使用者線程調用 open 時将會把建立 HTTP2 Stream Channel 的任務送出到 EventLoop中,并在調用 syncUninterruptibly 方法時阻塞使用者線程直到任務完成。

而送出後的任務隻是送出到一個任務隊列中并非立即執行,因為此時的 EventLoop 可能還在執行 Socket 讀寫任務或其他任務,那麼送出後很可能因為其他任務占用的時間較多,進而導緻遲遲沒有執行建立 Http2StreamChannel 這個任務,那麼阻塞使用者線程的時間就會變大。

而從一個請求的整體的流程分析來看,Stream Channel 還沒建立完成使用者線程就被阻塞了,在真正發起請求後還需要再次進行阻塞等待響應,一次 UNARY 請求中就出現了兩次明顯的阻塞行為,這将會極大的制約了 Triple 協定的性能,那麼我們可以大膽假設:此處的阻塞是不必要的。為了證明我們的推斷,我們可以使用 VisualVM 對其進行采樣,分析熱點中阻塞建立 Stream Channel 的耗時。以下是 Triple Consumer Side 的采樣結果。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

從圖中我們可以看到 HttpStreamChannelBootstrap$1.run 建立 StreamChannel 方法在整個 EventLoop 的耗時裡有着不小的占比,展開後可以看到這些耗時基本上消耗在了 notifyAll 上,即喚醒使用者線程。

優化方案

至此我們已經了解到了性能的阻礙點之一是建立 StreamChannel,那麼優化方案便是将建立 StreamChannel 異步化,以便消除 syncUninterruptibly 方法的調用。改造後的代碼如下所示,将建立 StreamChannel 的任務抽象成了 CreateStreamQueueCommand 并送出到了 WriteQueue 中,後續發起請求的 sendHeader、sendMessage 也是将其送出到 WriteQueue 中,這樣便可以輕松保證在建立 Stream 後才會執行發送請求的任務。

private TripleStreamChannelFuture initHttp2StreamChannel(Channel parent) {

    TripleStreamChannelFuture streamChannelFuture = new TripleStreamChannelFuture(parent);

    Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);

    bootstrap.handler(new ChannelInboundHandlerAdapter() {

            @Override

            public void handlerAdded(ChannelHandlerContext ctx) throws Exception {

                Channel channel = ctx.channel();

                channel.pipeline().addLast(new TripleCommandOutBoundHandler());

                channel.pipeline().addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));

                channel.closeFuture().addListener(f -> transportException(f.cause()));

            }

        });

    CreateStreamQueueCommand cmd = CreateStreamQueueCommand.create(bootstrap, streamChannelFuture);

    this.writeQueue.enqueue(cmd);

    return streamChannelFuture;

}           

其中 CreateStreamQueueCommand 的核心邏輯如下,通過保證在 EventLoop 中執行以消除不合理的阻塞方法調用。

public class CreateStreamQueueCommand extends QueuedCommand {

    ......

    @Override

    public void run(Channel channel) {

        //此處的邏輯可以保證在EventLoop下執行,是以open後可以直接擷取結果而不需要阻塞

        Future<Http2StreamChannel> future = bootstrap.open();

        if (future.isSuccess()) {

            streamChannelFuture.complete(future.getNow());

        } else {

            streamChannelFuture.completeExceptionally(future.cause());

        }

    }

}           

不恰當的 synchronized 鎖競争

此時簡單的看源碼已經不能發現明顯的性能瓶頸了,接下來我們需要借助 Visual VM 工具來找到性能瓶頸。

打開工具後我們可以選中需要采集的程序,這裡我們采集的是 Triple Consumer 的程序,并選中頁籤中的 Sampler,點選 CPU 開始采樣 CPU 的耗時熱點方法。以下是我們采樣 CPU 熱點方法的結果,我們展開了耗時最為明顯的 EventLoop 線程的調用堆棧。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

經過層層展開,我們可以從圖中發現一個非常的不合理耗時方法——ensureWriteOpen,這個方法名看上去不就是一個判斷 Socket 是否可寫的方法嗎,為什麼耗時的占比會這麼大?我們帶着疑問打開了 JDK8 中 sun.nio.ch.SocketChannelImpl 的 isConnected 方法,其代碼如下。

public boolean isConnected() {

  synchronized (stateLock) {

    return (state == ST_CONNECTED);

  }

}           

可以看到這個方法中沒有任何邏輯,但是有着關鍵字眼 synchronized,是以我們可以斷定:EventLoop 線程出現了大量的同步鎖競争!那麼我們下一步思路便是找到在同一時刻競争該鎖的方法。我們的方法也比較簡單粗暴,那就是通過 DEBUG 條件斷點的方式找出該方法。如下圖所示我們給 isConnected 這個方法裡打上條件斷點,進入斷點的條件是:目前線程不是 EventLoop 線程。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

斷點打上後我們啟動并發起請求,可以很清晰的看到我們的方法調用堆棧中出現了 TripleInvoker.isAvailable 的方法調用,最終會調用到 sun.nio.ch.SocketChannelImpl 的 isConnected,進而出現了 EventLoop 線程的鎖競争耗時的現象。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

優化方案

通過以上的分析,我們接下來的修改思路就很清晰了,那就是修改 isAvailable 的判斷邏輯,自身維護一個 boolean 值表示是否可以用,以便消除鎖競争,提升 Triple 協定的性能。

不可忽視的開銷:線程上下文切換

Aliware

我們繼續觀察 VisualVM 采樣的快照,檢視整體線程的耗時情況,如下圖:

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

從圖中我們可以提取到以下資訊:

  • 耗時最大的線程為 NettyClientWorker-2-1
  • 壓測期間有大量非消費者線程即 tri-protocol-214783647-thread-xxx
  • 消費者線程的整體耗時較高且線程數多
  • 使用者線程的耗時非常低

我們任意展開其中一個消費者線程後也能看到消費者線程主要是做反序列化以及傳遞反序列化結果(DeadlineFuture.received),如下圖所示:

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

從以上資訊來看似乎并不能看到瓶頸點,接下來我們嘗試使用 JFR(Java Flight Recorder)監控程序資訊。下圖是 JFR 的日志分析。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

1. Monitor Blocked 事件

其中我們可以先檢視 JFR 的簡要分析,點選 Java Blocking 檢視可能存在的阻塞點,該事件表示有線程進入了 synchronized 代碼塊,其中結果如下圖所示。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

可以看到這裡有一個總阻塞耗時達 39 秒的 Class,點選後可以看到圖中 Thread 一欄,被阻塞的線程全都是 benchmark 發請求的線程。再往下看火焰圖 Flame View 中展示的方法堆棧,可以分析出這隻是在等待響應結果,該阻塞是必要的,該阻塞點可以忽略。

接着點選左側菜單的 Event Browser 檢視 JFR 收集到的事件日志,并過濾出名為 java 的事件類型清單,我們首先檢視 Java Monitor Blocked 事件,結果如下圖所示。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

可以看到被阻塞的線程全都是 benchmark 發起請求的線程,阻塞的點也隻是等待響應,可以排除該事件。

2. Monitor Wait 事件

繼續檢視 Java Monitor Wait 事件,Monitor Wait 表示有代碼調用了 Object.wait 方法,結果如下圖所示。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

從上圖我們可以得到這些資訊:benchmark 請求線程均被阻塞,平均等待耗時約為 87ms,阻塞對象均是同一個 DefaultPromise,阻塞的切入方法為 Connection.isAvailable。接着我們檢視該方法的源碼,其源碼如下所示。很顯然,這個阻塞的耗時隻是首次建立連接配接的耗時,對整體性能不會有太大的影響。是以這裡的 Java Monitor Wait 事件也可以排除。

public boolean isAvailable() {

  if (isClosed()) {

    return false;

  }

  Channel channel = getChannel();

  if (channel != null && channel.isActive()) {

    return true;

  }

  if (init.compareAndSet(false, true)) {

    connect();

  }




  this.createConnectingPromise();

  //87ms左右的耗時來自這裡

  this.connectingPromise.awaitUninterruptibly(this.connectTimeout, TimeUnit.MILLISECONDS);

  // destroy connectingPromise after used

  synchronized (this) {

    this.connectingPromise = null;

  }




  channel = getChannel();

  return channel != null && channel.isActive();

}           

3. Thread Sleep 事件

接下來我們再檢視 Java Thread Sleep 事件,該事件表示代碼中存在手動調用 Thread.sleep,檢查是否存在阻塞工作線程的行為。從下圖可以看到,很顯然并沒有阻塞消費者線程或 benchmark 請求線程,這個主動調用 sleep 的線程主要用于請求逾時場景,對整體性能沒有影響,同樣也可以排除 Java Thread Sleep 事件。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

4. Thread Park 事件

最後我們再檢視 Java Thread Park 事件,park 事件表示線程被挂起。下圖是 park 事件清單。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

可以看到 park 事件有 1877 個,并且大多都是消費者線程池裡的線程,從火焰圖中的方法堆棧可以得知這些線程都在等待任務,并且沒有取到任務的持續時間偏長。由此可以說明一個問題:消費者線程池中大部分線程都是沒有執行任務的,消費者線程池的使用率非常低。

而要提高線程池的使用率則可以減少消費者線程池的線程數,但是在 dubbo 中消費者線程池并不能直接減少,我們嘗試在 UNARY 場景下将消費者線程池包裝成 SerializingExecutor,該 Executor 可以使得送出的任務被串行化執行,變相将線程池大小縮小。我們再檢視縮減後的結果如下。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論
通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

從以上結果中可以看到已經減少了大量的消費者線程,線程使用率大幅度提高,并且 Java Thread Park 事件也是大幅度減少,性能卻提高了約 13%。

由此可見多線程切換對程式性能影響較大,但也帶來了另一個問題,我們通過 SerializingExecutor 将大部分的邏輯集中到了少量的消費者線程上是否合理?帶着這個疑問我們展開其中一條消費者線程的調用堆棧進行分析。通過展開方法調用堆棧可以看到 deserialize 的字樣(如下圖所示)。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

很顯然我們雖然提高了性能,但卻把不同請求的響應體反序列化行為都集中在了少量的消費者線程上處理,會導緻反序列化被”串行”執行了,當反序列化大封包時耗時會明顯上漲。

是以能不能想辦法把反序列化的邏輯再次派發到多個線程上并行處理呢?帶着這個疑問我們首先梳理出目前的線程互動模型,如下圖所示。

根據以上的線程互動圖,以及 UNARY SYNC “一個請求對應一個響應”的特點,我們可以大膽推斷—— ConsumerThread 不是必要的!我們可以直接将所有非 I/O 類型的任務都交給使用者線程執行,可以有效利用多線程資源并行處理,并且也能大幅度減少不必要的線程上下文的切換。是以此處最佳的線程互動模型應如下圖所示。

5. 優化方案

梳理出該線程互動模型後,我們的改動思路就比較簡單了。根據 TripleClientStream 的源碼得知,每當接收到響應後,I/O 線程均會把任務送出到與TripleClientStream綁定的 Callback Executor 中,該 Callback Executor 預設即消費者線程池,那麼我們隻需要替換為 ThreadlessExecutor 即可。其改動如下:

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

減少 I/O 的利器:批量

Aliware

我們前面介紹到 triple 協定是一個基于 HTTP/2 協定實作的,并且完全相容 gRPC,由此可見 gRPC 是一個不錯的參照對象。于是我們将 triple 與 gRPC 做對比,環境一緻僅協定不同,最終結果發現 triple 與 gRPC 的性能有一定的差距,那麼差異點在哪裡呢?帶着這個問題,我們對這兩者繼續壓測,同時嘗試使用 tcpdump 對兩者進行抓包,其結果如下。

triple

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

gRPC

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

從以上的結果我們可以看到 gRPC 與 triple 的抓包差異非常大,gRPC 中一個時間點發送了一大批不同 Stream 的資料,而 triple 則是非正常矩的請求“一來一回”。是以我們可以大膽猜測 gRPC 的代碼實作中一定會有批量發送的行為,一組資料包被當作一個整體進行發送,大幅度的減少了 I/O 次數。為了驗證我們的猜想,我們需要對 gRPC 的源碼深入了解。最終發現 gRPC 中批量的實作位于 WriteQueue 中,其核心源碼片段如下:

private void flush() {

  PerfMark.startTask("WriteQueue.periodicFlush");

  try {

    QueuedCommand cmd;

    int i = 0;

    boolean flushedOnce = false;

    while ((cmd = queue.poll()) != null) {

      cmd.run(channel);

      if (++i == DEQUE_CHUNK_SIZE) {

        i = 0;

        // Flush each chunk so we are releasing buffers periodically. In theory this loop

        // might never end as new events are continuously added to the queue, if we never

        // flushed in that case we would be guaranteed to OOM.

        PerfMark.startTask("WriteQueue.flush0");

        try {

          channel.flush();

        } finally {

          PerfMark.stopTask("WriteQueue.flush0");

        }

        flushedOnce = true;

      }

    }

    // Must flush at least once, even if there were no writes.

    if (i != 0 || !flushedOnce) {

      PerfMark.startTask("WriteQueue.flush1");

      try {

        channel.flush();

      } finally {

        PerfMark.stopTask("WriteQueue.flush1");

      }

    }

  } finally {

    PerfMark.stopTask("WriteQueue.periodicFlush");

    // Mark the write as done, if the queue is non-empty after marking trigger a new write.

    scheduled.set(false);

    if (!queue.isEmpty()) {

      scheduleFlush();

    }

  }

}           

可以看到 gRPC 的做法是将一個個資料包抽象為 QueueCommand,使用者線程發起請求時并非真的直接寫出,而是先送出到 WriteQueue 中,并手動排程 EventLoop 執行任務,EventLoop 需要執行的邏輯便是從 QueueCommand 的隊列中取出并執行,當寫入資料達到 DEQUE_CHUNK_SIZE (預設 128)時,才會調用一次 channel.flush,将緩沖區的内容刷寫到對端。當隊列的 Command 都消費完畢後,還會按需執行一次兜底的 flush 防止消息丢失。以上便是 gRPC 的批量寫入邏輯。

同樣的,我們檢查了 triple 子產品的源碼發現也有一個名為 WriteQueue 的類,其目的同樣是批量寫入消息,減少 I/O 次數。但從 tcpdump 的結果來看,該類的邏輯似乎并沒有達到預期,消息仍舊是一個個按序發送并沒有批量。

我們可以将斷點打在 triple 的 WriteQueue 構造器中,檢查 triple 的 WriteQueue 為什麼沒有達到批量寫的預期。如下圖所示。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

可以看到 WriteQueue 會在 TripleClientStream 構造器中執行個體化,而 TripleClientStream 則是與 HTTP/2 中的 Stream 對應,每次發起一個新的請求都需要建構一個新的 Stream,也就意味着每個 Stream 都使用了不同的 WriteQueue 執行個體,多個 Stream 送出 Command 時并沒有送出到一塊去,使得不同的 Stream 發起請求在結束時都會直接 flush,導緻 I/O 過高,嚴重的影響了 triple 協定的性能。

分析出原因後,優化改動就比較清晰了,那便是将 WriteQueue 作為連接配接級共享,而不是一個連接配接下不同的 Stream 各自持有一個 WriteQueue 執行個體。當 WriteQueue 連接配接級别單例後,可以充分利用其持有的 ConcurrentLinkedQueue 隊列作為緩沖,實作一次 flush 即可将多個不同 Stream 的資料刷寫到對端,大幅度 triple 協定的性能。

調優成果

Aliware

最後我們來看一下 triple 本次優化後成果吧。可以看到小封包場景下性能提高明顯,最高提升率達 45%!而遺憾的是較大封包的場景提升率有限,同時較大封包場景也是 triple 協定未來的優化目标之一。

通過 HTTP/2 協定案例學習 Java &amp; Netty 性能調優:工具、技巧與方法論

總結

Aliware

性能解密之外,在下一篇文章中我們将會帶來 Triple 易用性、互聯互通等方面的設計與使用案例,将主要圍繞以下兩點展開,敬請期待。

  • 在 Dubbo 架構中使用 Triple 協定,可以直接使用 Dubbo 用戶端、gRPC 用戶端、curl、浏覽器等通路你釋出的服務,不需要任何額外元件與配置。
  • Dubbo 目前已經提供了 Java、Go、Rust 等語言實作,目前正在推進 Node.js 等語言的協定實作,我們計劃通過多語言和 Triple 協定打通移動端、浏覽器、後端微服務體系。

繼續閱讀