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

沐風之境發表於2021-10-09

最近工作中使用的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,順利解決了該問題。

相關文章