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

摘要

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

當你在 Dubbo 框架中使用 Triple 協議,然後你就可以直接使用 Dubbo 客戶端、gRPC 客戶端、curl、瀏覽器等訪問你發佈的服務,不需要任何額外組件與配置。

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

爲什麼要優化 Triple 協議的性能?

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

前置知識

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,由它們來完成處理邏輯。

工具準備

爲了對代碼進行調優,我們需要藉助一些工具來找到 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() 觸發,表示代碼中存在手動調用該方法的情況

調優思路

1. 非阻塞

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

2. 異步

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

3. 分治

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

4. 批量

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

高性能的基石:非阻塞

不合理的 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 的採樣結果。

從圖中我們可以看到 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 線程的調用堆棧。

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

public boolean isConnected() {
  synchronized (stateLock) {
    return (state == ST_CONNECTED);
  }
}

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

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

優化方案

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

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

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

從圖中我們可以提取到以下信息:

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

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

從以上信息來看似乎並不能看到瓶頸點,接下來我們嘗試使用 JFR(Java Flight Recorder)監控進程信息。下圖是 JFR 的日誌分析。

1. Monitor Blocked 事件

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

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

接着點擊左側菜單的 Event Browser 查看 JFR 收集到的事件日誌,並過濾出名爲 java 的事件類型列表,我們首先查看 Java Monitor Blocked 事件,結果如下圖所示。

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

2. Monitor Wait 事件

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

從上圖我們可以得到這些信息: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 事件。

4. Thread Park 事件

最後我們再查看 Java Thread Park 事件,park 事件表示線程被掛起。下圖是 park 事件列表。

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

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

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

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

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

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

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

5. 優化方案

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

減少 I/O 的利器:批量

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

triple

gRPC

從以上的結果我們可以看到 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 爲什麼沒有達到批量寫的預期。如下圖所示。

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

分析出原因後,優化改動就比較清晰了,那便是將 WriteQueue 作爲連接級共享,而不是一個連接下不同的 Stream 各自持有一個 WriteQueue 實例。當 WriteQueue 連接級別單例後,可以充分利用其持有的 ConcurrentLinkedQueue 隊列作爲緩衝,實現一次 flush 即可將多個不同 Stream 的數據刷寫到對端,大幅度 triple 協議的性能。

調優成果

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

總結

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

  • 在 Dubbo 框架中使用 Triple 協議,可以直接使用 Dubbo 客戶端、gRPC 客戶端、curl、瀏覽器等訪問你發佈的服務,不需要任何額外組件與配置。
  • Dubbo 當前已經提供了 Java、Go、Rust 等語言實現,目前正在推進 Node.js 等語言的協議實現,我們計劃通過多語言和 Triple 協議打通移動端、瀏覽器、後端微服務體系。

作者:梁倍寧 Apache Dubbo Contributor、陳有爲 Apache Dubbo PMC

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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