Spring Cloud 升級之路 - 2020.0.x - 2. 使用 Undertow 作為我們的 Web 服務容器

乾貨滿滿張雜湊發表於2021-04-06

本專案程式碼地址:https://github.com/HashZhang/spring-cloud-scaffold/tree/master/spring-cloud-iiford

在我們的專案中,我們沒有采用預設的 Tomcat 容器,而是使用了 UnderTow 作為我們的容器。其實效能上的差異並沒有那麼明顯,但是使用 UnderTow 我們可以利用直接記憶體作為網路傳輸的 buffer,減少業務的 GC,優化業務的表現。

Undertow 的官網https://undertow.io/

但是,Undertow 有一些令人擔憂的地方:

  1. NIO 框架採用的是 XNIO,在官網 3.0 roadmap 宣告中提到了將會在 3.0 版本開始,從 XNIO 遷移到 netty, 參考:Undertow 3.0 Announcement。但是,目前已經過了快兩年了,3.0 還是沒有釋出,並且 github 上 3.0 的分支已經一年多沒有更新了。目前,還是在用 2.x 版本的 Undertow。不知道是 3.0 目前沒必要開發,還是胎死腹中了呢?目前國內的環境對於 netty 使用更加廣泛並且大部分人對於 netty 更加熟悉一些, XNIO 應用並不是很多。不過,XNIO 的設計與 netty 大同小異。
  2. 官方文件的更新比較慢,可能會慢 1~2 個小版本,導致 Spring Boot 粘合 Undertow 的時候,配置顯得不會那麼優雅。參考官方文件的同時,最好還是看一下原始碼,至少看一下配置類,才能搞懂究竟是怎麼設定的
  3. 仔細看 Undertow 的原始碼,會發現有很多防禦性程式設計的設計或者功能性設計 Undertow 的作者想到了,但是就是沒實現,有很多沒有實現的半成品程式碼。這也令人擔心 Underow 是否開發動力不足,哪一天會突然死掉?

使用 Undertow 要注意的問題

  1. 需要開啟 NIO DirectBuffer 的特性,理解並配置好相關的引數。
  2. access.log 中要包括必要的一些時間,呼叫鏈等資訊,並且預設配置下,有些只配置 access.log 引數還是顯示不出來我們想看的資訊,官網對於 access.log 中的引數的一些細節並沒有詳細說明。

使用 Undertow 作為我們的 Web 服務容器

對於 Servlet 容器,依賴如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

對於 Weflux 容器,依賴如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

Undertow 基本結構

Undertow 目前(2.x) 還是基於 Java XNIO,Java XNIO 是一個對於 JDK NIO 類的擴充套件,和 netty 的基本功能是一樣的,但是 netty 更像是對於 Java NIO 的封裝,Java XNIO 更像是擴充套件封裝。主要是 netty 中基本傳輸承載資料的並不是 Java NIO 中的 ByteBuffer,而是自己封裝的 ByteBuf,而 Java XNIO 各個介面設計還是基於 ByteBuffer 為傳輸處理單元。設計上也很相似,都是 Reactor 模型的設計。

Java XNIO 主要包括如下幾個概念:

  • Java NIO ByteBufferBuffer 是一個具有狀態的陣列,用來承載資料,可以追蹤記錄已經寫入或者已經讀取的內容。主要屬性包括:capacity(Buffer 的容量),position(下一個要讀取或者寫入的位置下標),limit(當前可以寫入或者讀取的極限位置)。程式必須通過將資料放入 Buffer,才能從 Channel 讀取或者寫入資料ByteBuffer是更加特殊的 Buffer,它可以以直接記憶體分配,這樣 JVM 可以直接利用這個 Bytebuffer 進行 IO 操作,省了一步複製(具體可以參考我的一篇文章:Java 堆外記憶體、零拷貝、直接記憶體以及針對於NIO中的FileChannel的思考)。也可以通過檔案對映記憶體直接分配,即 Java MMAP(具體可以參考我的一篇文章:JDK核心JAVA原始碼解析(5) - JAVA File MMAP原理解析)。所以,一般的 IO 操作都是通過 ByteBuffer 進行的。
  • Java NIO Channel:Channel 是 Java 中對於開啟和某一外部實體(例如硬體裝置,檔案,網路連線 socket 或者可以執行 IO 操作的某些元件)連線的抽象。Channel 主要是 IO 事件源,所有寫入或者讀取的資料都必須經過 Channel。對於 NIO 的 Channel,會通過 Selector 來通知事件的就緒(例如讀就緒和寫就緒),之後通過 Buffer 進行讀取或者寫入。
  • XNIO Worker: Worker 是 Java XNIO 框架中的基本網路處理單元,一個 Worker 包含兩個不同的執行緒池型別,分別是:
    • IO 執行緒池,主要呼叫Selector.start()處理對應事件的各種回撥,原則上不能處理任何阻塞的任務,因為這樣會導致其他連線無法處理。IO 執行緒池包括兩種執行緒(在 XNIO 框架中,通過設定 WORKER_IO_THREADS 來設定這個執行緒池大小,預設是一個 CPU 一個 IO 執行緒):
      • 讀執行緒:處理讀事件的回撥
      • 寫執行緒:處理寫事件的回撥
    • Worker 執行緒池,處理阻塞的任務,在 Web 伺服器的設計中,一般將呼叫 servlet 任務放到這個執行緒池執行(在 XNIO 框架中,通過設定 WORKER_TASK_CORE_THREADS 來設定這個執行緒池大小)
  • XNIO ChannelListener:ChannelListener 是用來監聽處理 Channel 事件的抽象,包括:channel readable, channel writable, channel opened, channel closed, channel bound, channel unbound

Undertow 是基於 XNIO 的 Web 服務容器。在 XNIO 的基礎上,增加:

  • Undertow BufferPool: 如果每次需要 ByteBuffer 的時候都去申請,對於堆記憶體的 ByteBuffer 需要走 JVM 記憶體分配流程(TLAB -> 堆),對於直接記憶體則需要走系統呼叫,這樣效率是很低下的。所以,一般都會引入記憶體池。在這裡就是 BufferPool。目前,UnderTow 中只有一種 DefaultByteBufferPool,其他的實現目前沒有用。這個 DefaultByteBufferPool 相對於 netty 的 ByteBufArena 來說,非常簡單,類似於 JVM TLAB 的機制(可以參考我的另一系列:全網最硬核 JVM TLAB 分析),但是簡化了很多。我們只需要配置 buffer size ,並開啟使用直接記憶體即可
  • Undertow Listener: 預設內建有 3 種 Listener ,分別是 HTTP/1.1、AJP 和 HTTP/2 分別對應的 Listener(HTTPS 通過對應的 HTTP Listner 開啟 SSL 實現),負責所有請求的解析,將請求解析後包裝成為 HttpServerExchange 並交給後續的 Handler 處理。
  • Undertow Handler: 通過 Handler 處理響應的業務,這樣組成一個完整的 Web 伺服器。

Undertow 的一些預設配置

Undertow 的 Builder 設定了一些預設的引數,參考原始碼:

Undertow

private Builder() {
    ioThreads = Math.max(Runtime.getRuntime().availableProcessors(), 2);
    workerThreads = ioThreads * 8;
    long maxMemory = Runtime.getRuntime().maxMemory();
    //smaller than 64mb of ram we use 512b buffers
    if (maxMemory < 64 * 1024 * 1024) {
        //use 512b buffers
        directBuffers = false;
        bufferSize = 512;
    } else if (maxMemory < 128 * 1024 * 1024) {
        //use 1k buffers
        directBuffers = true;
        bufferSize = 1024;
    } else {
        //use 16k buffers for best performance
        //as 16k is generally the max amount of data that can be sent in a single write() call
        directBuffers = true;
        bufferSize = 1024 * 16 - 20; //the 20 is to allow some space for protocol headers, see UNDERTOW-1209
    }

}
  • ioThreads 大小為可用 CPU 數量 * 2,即 Undertow 的 XNIO 的讀執行緒個數為可用 CPU 數量,寫執行緒個數也為可用 CPU 數量。
  • workerThreads 大小為 ioThreads 數量 * 8.
  • 如果記憶體大小小於 64 MB,則不使用直接記憶體,bufferSize 為 512 位元組
  • 如果記憶體大小大於 64 MB 小於 128 MB,則使用直接記憶體,bufferSize 為 1024 位元組
  • 如果記憶體大小大於 128 MB,則使用直接記憶體,bufferSize 為 16 KB 減去 20 位元組,這 20 位元組用於協議頭。

Undertow Buffer Pool 配置

DefaultByteBufferPool 構造器:

public DefaultByteBufferPool(boolean direct, int bufferSize, int maximumPoolSize, int threadLocalCacheSize, int leakDecetionPercent) {
    this.direct = direct;
    this.bufferSize = bufferSize;
    this.maximumPoolSize = maximumPoolSize;
    this.threadLocalCacheSize = threadLocalCacheSize;
    this.leakDectionPercent = leakDecetionPercent;
    if(direct) {
        arrayBackedPool = new DefaultByteBufferPool(false, bufferSize, maximumPoolSize, 0, leakDecetionPercent);
    } else {
        arrayBackedPool = this;
    }
}

其中:

  • direct:是否使用直接記憶體,我們需要設定為 true,來使用直接記憶體。
  • bufferSize:每次申請的 buffer 大小,我們主要要考慮這個大小
  • maximumPoolSize:buffer 池最大大小,一般不用修改
  • threadLocalCacheSize:執行緒本地 buffer 池大小,一般不用修改
  • leakDecetionPercent:記憶體洩漏檢查百分比,目前沒啥卵用

對於 bufferSize,最好和你係統的 TCP Socket Buffer 配置一樣。在我們的容器中,我們將微服務例項的容器內的 TCP Socket Buffer 的讀寫 buffer 大小成一模一樣的配置(因為微服務之間呼叫,傳送的請求也是另一個微服務接受,所以調整所有微服務容器的讀寫 buffer 大小一致,來優化效能,預設是根據系統記憶體來自動計算出來的)。

檢視 Linux 系統 TCP Socket Buffer 的大小:

  • /proc/sys/net/ipv4/tcp_rmem (對於讀取)
  • /proc/sys/net/ipv4/tcp_wmem (對於寫入)

在我們的容器中,分別是:

bash-4.2# cat /proc/sys/net/ipv4/tcp_rmem
4096    16384   4194304 
bash-4.2# cat /proc/sys/net/ipv4/tcp_wmem
4096    16384   4194304 

從左到右三個值分別為:每個 TCP Socket 的讀 Buffer 與寫 Buffer 的大小的 最小值,預設值和最大值,單位是位元組。

我們設定我們 Undertow 的 buffer size 為 TCP Socket Buffer 的預設值,即 16 KB。Undertow 的 Builder 裡面,如果記憶體大於 128 MB,buffer size 為 16 KB 減去 20 位元組(為協議頭預留)。所以,我們使用預設的即可

application.yml 配置:

server.undertow:
    # 是否分配的直接記憶體(NIO直接分配的堆外記憶體),這裡開啟,所以java啟動引數需要配置下直接記憶體大小,減少不必要的GC
    # 在記憶體大於 128 MB 時,預設就是使用直接記憶體的
    directBuffers: true
    # 以下的配置會影響buffer,這些buffer會用於伺服器連線的IO操作
    # 如果每次需要 ByteBuffer 的時候都去申請,對於堆記憶體的 ByteBuffer 需要走 JVM 記憶體分配流程(TLAB -> 堆),對於直接記憶體則需要走系統呼叫,這樣效率是很低下的。
    # 所以,一般都會引入記憶體池。在這裡就是 `BufferPool`。
    # 目前,UnderTow 中只有一種 `DefaultByteBufferPool`,其他的實現目前沒有用。
    # 這個 DefaultByteBufferPool 相對於 netty 的 ByteBufArena 來說,非常簡單,類似於 JVM TLAB 的機制
    # 對於 bufferSize,最好和你係統的 TCP Socket Buffer 配置一樣
    # `/proc/sys/net/ipv4/tcp_rmem` (對於讀取)
    # `/proc/sys/net/ipv4/tcp_wmem` (對於寫入)
    # 在記憶體大於 128 MB 時,bufferSize 為 16 KB 減去 20 位元組,這 20 位元組用於協議頭
    buffer-size: 16384 - 20

Undertow Worker 配置

Worker 配置其實就是 XNIO 的核心配置,主要需要配置的即 io 執行緒池以及 worker 執行緒池大小。

預設情況下,io 執行緒大小為可用 CPU 數量 * 2,即讀執行緒個數為可用 CPU 數量,寫執行緒個數也為可用 CPU 數量。worker 執行緒池大小為 io 執行緒大小 * 8.

微服務應用由於涉及的阻塞操作比較多,所以可以將 worker 執行緒池大小調大一些。我們的應用設定為 io 執行緒大小 * 32.

application.yml 配置:

server.undertow.threads:
    # 設定IO執行緒數, 它主要執行非阻塞的任務,它們會負責多個連線, 預設設定每個CPU核心一個讀執行緒和一個寫執行緒
    io: 16
    # 阻塞任務執行緒池, 當執行類似servlet請求阻塞IO操作, undertow會從這個執行緒池中取得執行緒
    # 它的值設定取決於系統執行緒執行任務的阻塞係數,預設值是IO執行緒數*8
    worker: 128

Spring Boot 中的 Undertow 配置

Spring Boot 中對於 Undertow 相關配置的抽象是 ServerProperties 這個類。目前 Undertow 涉及的所有配置以及說明如下(不包括 accesslog 相關的,accesslog 會在下一節詳細分析):

server:
  undertow:
    # 以下的配置會影響buffer,這些buffer會用於伺服器連線的IO操作
    # 如果每次需要 ByteBuffer 的時候都去申請,對於堆記憶體的 ByteBuffer 需要走 JVM 記憶體分配流程(TLAB -> 堆),對於直接記憶體則需要走系統呼叫,這樣效率是很低下的。
    # 所以,一般都會引入記憶體池。在這裡就是 `BufferPool`。
    # 目前,UnderTow 中只有一種 `DefaultByteBufferPool`,其他的實現目前沒有用。
    # 這個 DefaultByteBufferPool 相對於 netty 的 ByteBufArena 來說,非常簡單,類似於 JVM TLAB 的機制
    # 對於 bufferSize,最好和你係統的 TCP Socket Buffer 配置一樣
    # `/proc/sys/net/ipv4/tcp_rmem` (對於讀取)
    # `/proc/sys/net/ipv4/tcp_wmem` (對於寫入)
    # 在記憶體大於 128 MB 時,bufferSize 為 16 KB 減去 20 位元組,這 20 位元組用於協議頭
    buffer-size: 16364
    # 是否分配的直接記憶體(NIO直接分配的堆外記憶體),這裡開啟,所以java啟動引數需要配置下直接記憶體大小,減少不必要的GC
    # 在記憶體大於 128 MB 時,預設就是使用直接記憶體的
    directBuffers: true
    threads:
      # 設定IO執行緒數, 它主要執行非阻塞的任務,它們會負責多個連線, 預設設定每個CPU核心一個讀執行緒和一個寫執行緒
      io: 4
      # 阻塞任務執行緒池, 當執行類似servlet請求阻塞IO操作, undertow會從這個執行緒池中取得執行緒
      # 它的值設定取決於系統執行緒執行任務的阻塞係數,預設值是IO執行緒數*8
      worker: 128
    # http post body 大小,預設為 -1B ,即不限制
    max-http-post-size: -1B
    # 是否在啟動時建立 filter,預設為 true,不用修改
    eager-filter-init: true
    # 限制路徑引數數量,預設為 1000
    max-parameters: 1000
    # 限制 http header 數量,預設為 200
    max-headers: 200
    # 限制 http header 中 cookies 的鍵值對數量,預設為 200
    max-cookies: 200
    # 是否允許 / 與 %2F 轉義。/ 是 URL 保留字,除非你的應用明確需要,否則不要開啟這個轉義,預設為 false
    allow-encoded-slash: false
    # 是否允許 URL 解碼,預設為 true,除了 %2F 其他的都會處理
    decode-url: true
    # url 字元編碼集,預設是 utf-8
    url-charset: utf-8
    # 響應的 http header 是否會加上 'Connection: keep-alive',預設為 true
    always-set-keep-alive: true
    # 請求超時,預設是不超時,我們的微服務因為可能有長時間的定時任務,所以不做服務端超時,都用客戶端超時,所以我們保持這個預設配置
    no-request-timeout: -1
    # 是否在跳轉的時候保持 path,預設是關閉的,一般不用配置
    preserve-path-on-forward: false
    options:
      # spring boot 沒有抽象的 xnio 相關配置在這裡配置,對應 org.xnio.Options 類
      socket:
        SSL_ENABLED: false
      # spring boot 沒有抽象的 undertow 相關配置在這裡配置,對應 io.undertow.UndertowOptions 類
      server:
        ALLOW_UNKNOWN_PROTOCOLS: false

Spring Boot 並沒有將所有的 Undertow 與 XNIO 配置進行抽象,如果你想自定義一些相關配置,可以通過上面配置最後的 server.undertow.options 進行配置。server.undertow.options.socket 對應 XNIO 的相關配置,配置類是 org.xnio.Options;server.undertow.options.server 對應 Undertow 的相關配置,配置類是 io.undertow.UndertowOptions

相關文章