HttpClient遭遇Connection Reset異常,如何正確配置?

最近工作中使用的HttpClient工具遇到的Connection Reset異常。在客戶端和服務端配置不對的時候容易出現問題,下面就是記錄一下如何解決這個問題的過程。

出現Connection Reset的原因

1.客戶端在讀取數據,服務端不再發送新數據(伺服器主動關閉了連接)

為什麼會出現服務端主動關閉連接?

經過排查線上伺服器配置,發現當一個連接空閑時間超過60s,伺服器就會將其關閉。如果剛好客戶端在使用該連接則客戶端就會收到來自服務端的連接複位標誌通知

既然明白了服務端關閉的連接的原因,那為什麼客戶端會使用空閑時間為60s的連接呢?

排查了HttpClient的配置後發現,項目中的HttpClient使用連接池,雖然設置了池的最大連接數,但是沒有配置空閑連接驅逐器(IdleConnectionEvictor)。到這裡原因就已經很明朗了,就是httpClient的配置有問題。

解決思路:

如果說服務端會吧空閑時間超過60s的空閑連接關閉掉,導致了connection reset 異常。要解決這個問題,那隻要客戶端在伺服器關閉連接之前把連接關閉掉那就不會出現了。所以按著這個思路我對httpClient的配置進行了修改。

解決方案1:

為HttpClient添加空閑連接驅逐器配置

新加了evictIdleConnections(40, TimeUnit.SECONDS)配置

HttpClients
  .custom()
  // 默認請求配置
  .setDefaultRequestConfig(customRequestConfig())
  // 自定義連接管理器
  .setConnectionManager(poolingHttpClientConnectionManager())
  // 刪除空閑連接時間
  .evictIdleConnections(40, TimeUnit.SECONDS)
  .disableAutomaticRetries(); // 關閉自動重試

正常情況下到這裡問題就解決了,但是現實是線上再次出現了Connection Reset異常。繼續排查…

思考:雖然更新配置後再次出現「連接重置」異常,不過出現頻率相較於沒改之前還是要低不少。所以改的配置還有用的,肯定是什麼地方沒有配好。為了一探究竟,查了HttpClient關於IdleConnectionEvictor驅逐器的源碼發現了問題所在。

源碼解讀:

源碼1:

// org.apache.http.impl.client.HttpClientBuilder
public class HttpClientBuilder {
  // .....省略無關程式碼....
  // 關注build方法,這這個方法裡面啟動了空閑連接驅逐器
  public CloseableHttpClient build() {
    // 。。。。省略程式碼。。。。
       if (!this.connManagerShared) {
            if (closeablesCopy == null) {
                closeablesCopy = new ArrayList<Closeable>(1);
            }
            final HttpClientConnectionManager cm = connManagerCopy;

            if (evictExpiredConnections || evictIdleConnections) {
              // 在這裡實例化了IdleConnectionEvictor。maxIdleTime和maxIdleTimeUnit就是我們在配置httpclient時
              // 傳入的 40 和 TimeUnit.SECONDS
                final IdleConnectionEvictor connectionEvictor = new IdleConnectionEvictor(cm,
                        maxIdleTime > 0 ? maxIdleTime : 10, maxIdleTimeUnit != null ? maxIdleTimeUnit : TimeUnit.SECONDS,
                        maxIdleTime, maxIdleTimeUnit);
                closeablesCopy.add(new Closeable() {

                    @Override
                    public void close() throws IOException {
                        connectionEvictor.shutdown();
                        try {
                            connectionEvictor.awaitTermination(1L, TimeUnit.SECONDS);
                        } catch (final InterruptedException interrupted) {
                            Thread.currentThread().interrupt();
                        }
                    }

                });
              // 調用start()發放啟動了執行緒驅逐器
                connectionEvictor.start();
            }
            closeablesCopy.add(new Closeable() {

                @Override
                public void close() throws IOException {
                    cm.shutdown();
                }

            });
        }
        // 。。。。省略無關程式碼。。。。。
  }
}
  1. evictIdleConnections(40, TimeUnit.SECONDS)配置的參數在HttpClientBuilder.builder方法中用於實例化IdleConnectionEvictor對象的構造參數

  2. 調用了connectionEvictor.start()方法啟動了執行緒驅逐器

源碼2:

// org.apache.http.impl.client.IdleConnectionEvictor
public final class IdleConnectionEvictor {
  // 。。。。省略無關程式碼。。。。
  // HttpClientBuilder.build()內實例化IdleConnectionEvictor調用了該構造方法
    public IdleConnectionEvictor(
            final HttpClientConnectionManager connectionManager,
            final long sleepTime, final TimeUnit sleepTimeUnit,
            final long maxIdleTime, final TimeUnit maxIdleTimeUnit) {
        this(connectionManager, null, sleepTime, sleepTimeUnit, maxIdleTime, maxIdleTimeUnit);
    }
  // 。。。。省略無關程式碼。。。。
}
關鍵的參數列表
  1. sleepTime:延時檢查時間
  2. maxIdleTime:最多空閑時間

結合源碼1和源碼2,可以看到在構造IdleConnectionEvictorsleepTimemaxIdleTime為同一個值40秒,在這裡還看不出什麼問題,繼續。

源碼3:

// org.apache.http.impl.client.IdleConnectionEvictor
public final class IdleConnectionEvictor {
  // 省略無關程式碼
  // 重載的構造方法
    public IdleConnectionEvictor(
            final HttpClientConnectionManager connectionManager,
            final ThreadFactory threadFactory,
            final long sleepTime, final TimeUnit sleepTimeUnit,
            final long maxIdleTime, final TimeUnit maxIdleTimeUnit) {
        this.connectionManager = Args.notNull(connectionManager, "Connection manager");
        this.threadFactory = threadFactory != null ? threadFactory : new DefaultThreadFactory();
        this.sleepTimeMs = sleepTimeUnit != null ? sleepTimeUnit.toMillis(sleepTime) : sleepTime;
        this.maxIdleTimeMs = maxIdleTimeUnit != null ? maxIdleTimeUnit.toMillis(maxIdleTime) : maxIdleTime;
      // 使用threadFactory執行緒構造器構造了一個守護執行緒
        this.thread = this.threadFactory.newThread(new Runnable() {
            @Override
            public void run() {
                try {
                    while (!Thread.currentThread().isInterrupted()) {
                      // 掛起執行緒時間是我們傳入的時間40秒
                        Thread.sleep(sleepTimeMs);
                      // 執行檢查程式碼,關閉過期連接
                        connectionManager.closeExpiredConnections();
                        if (maxIdleTimeMs > 0) {
                          // 關閉超過空閑時間的空閑連接,參數傳入我們配置的40秒
                            connectionManager.closeIdleConnections(maxIdleTimeMs, TimeUnit.MILLISECONDS);
                        }
                    }
                } catch (final Exception ex) {
                    exception = ex;
                }

            }
        });
    }
  
  // HttpClientBuilder中調用的start()方法
    public void start() {
        thread.start();
    }
}

通過源碼3我們可以看到,檢查執行緒的執行周期時間和最大過期時間都是我們傳入的40秒。在這裡停頓一下思考一下,伺服器的空閑連接關閉時間是60s,我們配置的時間是40s,那這樣配置會不有出現什麼問題?

執行緒相隔40s執行一下回收任務,相當於80秒的的周期內會做兩次回收動作。但是60s在其中最多只能回收掉一次,還是可能存在回收不掉的情況,在不執行回收任務停止的40秒裡面出了connection reset異常了怎麼吧?問題就明了了。

問題復現時序:
  1. 00:00:00 — 啟動IdleConnectionEvictor.start(),掛起檢查執行緒,不執行檢查程式碼
  2. 00:00:10 — 10秒後的連接池新建了一個連接
  3. 00:00:12 — 連接耗時2s,用完後返回執行緒池,假設之後都沒有再被使用了
  4. 00:00:40 — 第一次sleep掛起時間到期,執行檢查任務。發現沒有過期連接,下一次回收任務發生在 00:01:20
  5. 00:01:12 — 這時恰好客戶端使用那個空閑的連接,服務端關閉了該連接。在這裡爆發了connection reset 異常
  6. 00:01:20 — 第二次sleep掛起時間到期,執行檢查任務。

結論:

服務端空閑連接關閉時間是60s,我們客戶端配置的最大空閑時間值應該小於30s才能避免這個問題

解決方案2:

在解決方案1的基礎上,把40s時間改為20s,順利解決了該問題。