實現微服務預熱呼叫之後再開始服務(上)

乾貨滿滿張雜湊發表於2022-01-01

最近線上發現一個現象,應用例項剛剛啟動的時候,開始接收請求之後發生了一小段時間的請求阻塞,從 HTTP Servlet 請求佇列監控上可以看出(基於 spring-web 的普通阻塞的 HTTP 伺服器是有 HTTP 執行緒池的,當執行緒是滿了之後,請求在阻塞佇列中等待處理。基於 spring-webflux 的沒有這個現象,但是考慮的背壓問題其實和這個場景類似):
image

然後阻塞數量很快就下去了,通過 JFR 發現和 Spring 各種懶載入的 Bean,各種資源或者連線池初始化等有關。這些資源可以理解為是懶載入的,是在請求真正用到的時候才會初始化。這些資源初始化之前,微服務就已經註冊到註冊中心並開始接受請求了。這樣在平常業務低峰釋出的時候,是沒有問題的,因為這些資源的初始化耗時也就在幾十到幾百毫秒之間。但是在業務高峰需要動態擴容的時候,就會受一些影響,因為請求壓力會立刻大量打到這些新啟動的例項上,這種情況下,初始化耗時的影響就比較大了

所以,我們希望在微服務開始真正提供服務之前,將這些比較耗時的需要初始化的資源提前初始化完成之後,再告訴註冊中心我們可以開始接受處理請求了。

Spring Boot 中的 MVC Servlet 與 Web Service Servlet 的提前初始化

在微服務例項啟動後,我們傳送第一個請求的時候,會看到類似於下面的日誌:

INFO: Initializing Servlet 'dispatcherServlet'
INFO: Initializing Spring DispatcherServlet 'dispatcherServlet'

這是在初始化 Servlet。

MVC Servlet 指的就是 Spring-MVC 的 Servlet,其實就是提供 HTTP 服務的一些核心 Servlet,例如最核心的 org.springframework.web.servlet.DispatcherServlet。這些預設是懶載入的,需要通過下面的配置開啟:

spring.mvc.servlet.load-on-startup: 1

除了 MVC Servlet,另一些 Servlet 可能是提供除了 HTTP 以外的其他應用協議的 Servlet,這些 Servlet 預設也是懶載入的,需要通過下面的配置開啟:

spring.webservices.servlet.load-on-startup: 1

例如 WebSocket 的 org.springframework.ws.transport.http.MessageDispatcherServlet 就是通過這個配置進行初始化的。

spring-data-redis 連線池的初始化

我們專案中使用的是 spring-data-redis + lettuce。如果沒有使用 Pipeline 等需要獨佔連線的 redis 操作,其實不用使用連線池。但是我們在使用中為了效率,儘量都是用 Pipeline,所以我們都啟用了連線池配置。其連線池實現基於 common-pools2 庫。相關配置如下所示:

  • spring.redis.lettuce.pool.enabled: 是否啟用連線池,如果依賴中有 common-pools2 依賴自動會啟用。一般這個配置是用來關閉連線池的。
  • spring.redis.lettuce.pool.max-active: 連線池最大連線數量,預設是 8
  • spring.redis.lettuce.pool.max-idle:連線池中最多保留的空閒連線數量,預設是 8,這個配置需要和 spring.redis.lettuce.pool.time-between-eviction-runs 一起配置才可以生效。
  • spring.redis.lettuce.pool.max-wait:從連線池獲取連線最大的等待時間(毫秒),超過這個等待時間則會丟擲異常,預設是 -1,即不超時
  • spring.redis.lettuce.pool.min-idle:連線池中最小的空閒連線數量,預設是 0,這個配置需要和 spring.redis.lettuce.pool.time-between-eviction-runs 一起配置才可以生效。
  • spring.redis.lettuce.pool.time-between-eviction-runs:common-pools 中 Evictor 初始執行延遲以及執行間隔。不配置的話,就沒有啟用 Evictor 任務。

這個配置經常有人會誤用,只配置了 spring.redis.lettuce.pool.min-idle 但是沒有配置 spring.redis.lettuce.pool.time-between-eviction-runs,導致 Evictor 任務沒有啟動,導致並沒有初始化最小連線數量的連線。

Evictor 任務包括將池中空閒的超過 spring.redis.lettuce.pool.max-idle 配置數量的物件,進行過期,以及空閒物件不足 spring.redis.lettuce.pool.min-idle 時,建立物件補足:

 public void run() {
    final ClassLoader savedClassLoader =
            Thread.currentThread().getContextClassLoader();
    try {
        if (factoryClassLoader != null) {
            // Set the class loader for the factory
            final ClassLoader cl = factoryClassLoader.get();
            if (cl == null) {
                // The pool has been dereferenced and the class loader
                // GC'd. Cancel this timer so the pool can be GC'd as
                // well.
                cancel();
                return;
            }
            Thread.currentThread().setContextClassLoader(cl);
        }

        // Evict from the pool
        try {
            evict();
        } catch(final Exception e) {
            swallowException(e);
        } catch(final OutOfMemoryError oome) {
            // Log problem but give evictor thread a chance to continue
            // in case error is recoverable
            oome.printStackTrace(System.err);
        }
        // Re-create idle instances.
        try {
            ensureMinIdle();
        } catch (final Exception e) {
            swallowException(e);
        }
    } finally {
        // Restore the previous CCL
        Thread.currentThread().setContextClassLoader(savedClassLoader);
    }
}

對於這種連線池,最好初始化足夠的連線數量,即配置 spring.redis.lettuce.pool.min-idle 以及 spring.redis.lettuce.pool.time-between-eviction-runs。由於我們的專案中大量使用了 Pipeline,執行緒會獨佔一個連線進行操作。所以初始化的連線數量最好等於執行緒池的數量,在我們專案中即 Http Servlet 執行緒池的數量。

資料庫連線池的初始化

這裡以 druid 連線池為例子,druid 連線池底層其實也是類似於 common-pools 的實現,但是配置設計的更全面複雜一些。可以直接配置 initialSize:

DruidDataSource dataSource = new DruidDataSource();
dataSource.setInitialSize(50);
dataSource.setMaxActive(50);

我們設定初始連線池大小就是最大連線數

微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer

相關文章