通過FD耗盡實驗談談使用HttpClient的正確姿勢

一段問題代碼實驗

在進行網絡編程時,正確關閉資源是一件很重要的事。在高併發場景下,未正常關閉的資源數逐漸積累會導致系統資源耗盡,影響系統整體服務能力,但是這件重要的事情往往又容易被忽視。我們進行一個簡單的實驗,使用HttpClient-3.x編寫一個demo請求指定的url,看看如果不正確關閉資源會發生什麼事。

public String doGetAsString(String url) {
        GetMethod getMethod = null;
        String is = null;
        InputStreamReader inputStreamReader = null;
        BufferedReader br = null;
        try {
            HttpClient httpclient = new HttpClient();//問題標記①
            getMethod = new GetMethod(url);
            httpclient.executeMethod(getMethod);

            if (HttpStatus.SC_OK == getMethod.getStatusCode()) {
                ......//對返回結果進行消費,代碼省略
            }

            return is;

        } catch (Exception e) {
            if (getMethod != null) {
                getMethod.releaseConnection();  //問題標記②              
            }            
        } finally {
            inputStreamReader.close();
            br.close();
            ......//關閉流時的異常處理代碼省略

        }
        return null;
    }

這段代碼邏輯很簡單, 先創建一個HttpClient對象,用url構建一個GetMethod對象,然後發起請求。但是用這段代碼併發地以極高的QPS去訪問外部的url,很快就會在日誌中看到“打開文件太多,無法打開文件”的錯誤,後續的http請求都會失敗。這時我們用lsof -p ${javapid}命令去查看java進程打開的文件數,發現達到了655350這麼多。
分析上面的代碼片段,發現存在以下2個問題:

(1)初始化方式不對。標記①直接使用new HttpClient()的方式來創建HttpClient,沒有顯示指定HttpClient connection manager,則構造函數內部默認會使用SimpleHttpConnectionManager,而SimpleHttpConnectionManager的默認參數中alwaysClose的值爲false,意味着即使調用了releaseConnection方法,連接也不會真的關閉。

(2)在未使用連接池複用連接的情況下,代碼沒有正確調用releaseConnection。catch塊中的標記②是唯一調用了releaseConnection方法的代碼,而這段代碼僅在發生異常時纔會走到,大部分情況下都走不到這裏,所以即使我們前面用正確的方式初始化了HttpClient,由於沒有手動釋放連接,也還是會出現連接堆積的問題。

可能有同學會有以下疑問:
1、明明是發起Http請求,爲什麼會打開這麼多文件呢?爲什麼是655350這個上限呢?
2、正確的HttpClient使用姿勢是什麼樣的呢?
這就涉及到linux系統中fd的概念。

什麼是fd

在linux系統中有“一切皆文件”的概念。打開和創建普通文件、Socket(套接字)、Pipeline(管道)等,在linux內核層面都需要新建一個文件描述符來進行狀態跟蹤和使用。我們使用HttpClient發起請求,其底層需要首先通過系統內核創建一個Socket連接,相應地就需要打開一個fd。

爲什麼我們的應用最多隻能創建655350個fd呢?這個值是如何控制的,能否調整呢?事實上,linux系統對打開文件數有多個層面的限制:

1)限制單個Shell進程以及其派生子進程能打開的fd數量。用ulimit命令能查看到這個值。

2)限制每個user能打開的文件總數。具體調整方法是修改/etc/security/limits.conf文件,比如下圖中的紅框部分就是限制了userA用戶只能打開65535個文件,userB用戶只能打開655350個文件。由於我們的應用在服務器上是以userB身份運行的,自然就受到這裏的限制,不允許打開多於655350個文件。

# /etc/security/limits.conf
#
#<domain>      <type>  <item>     <value>
userA          -      nofile         65535
userB             -         nofile         655350

# End of file

3)系統層面允許打開的最大文件數限制,可以通過“cat /proc/sys/fs/file-max”查看。

前文demo代碼中錯誤的HttpClient使用方式導致連接使用完成後沒有成功斷開,連接長時間保持CLOSE_WAIT狀態,則fd需要繼續指向這個套接字信息,無法被回收,進而出現了本文開頭的故障。

再識HttpClient

我們的代碼中錯誤使用common-httpclient-3.x導致後續請求失敗,那這裏的common-httpclient-3.x到底是什麼東西呢?相信所有接觸過網絡編程的同學對HttpClient都不會陌生,由於java.net中對於http訪問只提供相對比較低級別的封裝,使用起來很不方便,所以HttpClient作爲Jakarta Commons的一個子項目出現在公衆面前,爲開發者提供了更友好的發起http連接的方式。然而目前進入Jakarta Commons HttpClient官網,會發現頁面最頂部的“End of life”欄目,提示此項目已經停止維護了,它的功能已經被Apache HttpComponents的HttpClient和HttpCore所取代。

同爲Apache基金會的項目,Apache HttpComponents提供了更多優秀特性,它總共由3個模塊構成:HttpComponents Core、HttpComponents Client、HttpComponents AsyncClient,分別提供底層核心網絡訪問能力、同步連接接口、異步連接接口。在大多數情況下我們使用的都是HttpComponents Client。爲了與舊版的Commons HttpClient做區分,新版的HttpComponents Client版本號從4.x開始命名。

從源碼上來看,Jakarta Commons HttpClient和Apache HttpComponents Client雖然有很多同名類,但是兩者之間沒有任何關係。以最常使用到的HttpClient類爲例,在commons-httpclient中它是一個類,可以直接發起請求;而在4.x版的httpClient中,它是一個接口,需要使用它的實現類。

既然3.x與4.x的HttpClient是兩個完全獨立的體系,那麼我們就分別討論它們的正確用法。

HttpClient 3.x用法

回顧引發故障的那段代碼,通過直接new HttpClient()的方式創建HttpClient對象,然後發起請求,問題出在了這個構造函數上。由於我們使用的是無參構造函數,查看三方包源碼,會發現內部會通過無參構造函數new一個SimpleHttpConnectionManager,它的成員變量alwaysClose在不特別指定的情況下默認爲false。

alwaysClose這個值是如何影響到我們關閉連接的動作呢?繼續跟蹤下去,發現HttpMethodBase(它的多個實現類分別對應HTTP中的幾種方法,我們最常用的是GetMethod和PostMethod)中的releaseConnection()方法首先會嘗試關閉響應輸入流(下圖中的①所指代碼),然後在finally中調用ensureConnectionRelease(),這個方法內部其實是調用了HttpConnection類的releaseConnection()方法,如下圖中的標記③所示,它又會調用到SimpleHttpConnectionManager的releaseConnection(conn)方法,來到了最關鍵的標記④和⑤。

標記④的代碼說明,如果alwaysClose=true,則會調用httpConnection.close()方法,它的內部會把輸入流、輸出流都關閉,然後把socket連接關閉,如標記⑥和⑦所示。

然後,如果標記④處的alwaysClose=false,則會走到⑤的邏輯中,調用finishLastResponse()方法,如標記⑧所示,這段邏輯實際上只是把請求響應的輸入流關閉了而已。我們的問題代碼就是走到了這段邏輯,導致沒能把之前使用過的連接斷開,而後續的請求又沒有複用這個httpClient,每次都是new一個新的,導致大量連接處於CLOSE_WAIT狀態佔用系統文件句柄。

通過以上分析,我們知道使用commons-httpclient-3.x之後如果想要正確關閉連接,就需要指定always=true且正確調用method.releaseConnection()方法。

上述提到的幾個類,他們的依賴關係如下圖(紅色箭頭標出的是我們剛纔討論到的幾個類):

其中SimpleHttpConnectionManager這個類的成員變量和方法列表如下圖所示:

事實上,通過對commons-httpclient-3.x其他部分源碼的分析,可以得知還有其他方法也可以正確關閉連接。

方法1:先調用method.releaseConnection(),然後獲取到httpClient對象的SimpleHttpConnectionManager成員變量,主動調用它的shutdown()方法即可。對應的三方包源碼如下圖所示,其內部會調用httpConnection.close()方法。

方法2:先調用method.releaseConnection(),然後獲取到httpClient對象的SimpleHttpConnectionManager成員變量,主動調用closeIdleConnections(0)即可,對應的三方包源碼如下。

方法3:由於我們使用的是HTTP/1.1協議,默認會使用長連接,所以會出現上面的連接不釋放的問題。如果客戶端與服務端雙方協商好不使用長連接,不就可以解決問題了嗎。commons-httpclient-3.x也確實提供了這個支持,從下面的註釋也可以看出來。具體這樣操作,我們在創建了method後使用method.setRequestHeader("Connection", "close")設置頭部信息,並在使用完成後調用一次method.releaseConnection()。Http服務端在看到此頭部後會在response的頭部中也帶上“Connection: close”,如此一來httpClient發現返回的頭部有這個信息,則會在處理完響應後自動關閉連接。

HttpClient 4.x用法

既然官方已經不再維護3.x,而是推薦所有使用者都升級到4.x上來,我們就順應時代潮流,重點看看4.x的用法。

(1)簡易用法

最簡單的用法類似於3.x,調用三方包提供的工具類靜態方法創建一個CloseableHttpClient對象,然後發起調用,如下圖。這種方式創建的CloseableHttpClient,默認使用的是PoolingHttpClientConnectionManager來管理連接。由於CloseableHttpClient是線程安全的,因此不需要每次調用時都重新生成一個,可以定義成static字段在多線程間複用。

如上圖,我們在獲取到response對象後,自己決定如何處理返回數據。HttpClient的三方包中已經爲我們提供了EntityUtils這個工具類,如果使用這個類的toString()或consume()方法,則上圖finally塊紅框中的respnose.close()就不是必須的了,因爲EntityUtils的方法內部會在處理完數據後把底層流關閉。

(2)簡易用法涉及到的核心類詳解

CloseableHttpClient是一個抽象類,我們通過HttpClients.createDefault()創建的實際是它的子類InternalHttpClient。

/**
 * Internal class.
 *
 * @since 4.3
 */
@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL)
@SuppressWarnings("deprecation")
class InternalHttpClient extends CloseableHttpClient implements Configurable {
    ... ...
}

繼續跟蹤httpclient.execute()方法,發現其內部會調用CloseableHttpClient.doExecute()方法,實際會調到InternalHttpClient類的doExecute()方法。通過對請求對象(HttpGet、HttpPost等)進行一番包裝後,最後實際由execChain.execute()來真正執行請求,這裏的execChain是接口ClientExecChain的一個實例。接口ClientExecChain有多個實現類,由於我們使用HttpClients.createDefault()這個默認方法構造了CloseableHttpClient,沒有指定ClientExecChain接口的具體實現類,所以系統默認會使用RedirectExec這個實現類。

/**
 * Base implementation of {@link HttpClient} that also implements {@link Closeable}.
 *
 * @since 4.3
 */
@Contract(threading = ThreadingBehavior.SAFE)
public abstract class CloseableHttpClient implements HttpClient, Closeable {

    private final Log log = LogFactory.getLog(getClass());

    protected abstract CloseableHttpResponse doExecute(HttpHost target, HttpRequest request,
            HttpContext context) throws IOException, ClientProtocolException;

    ... ...
}

RedirectExec類的execute()方法較長,下圖進行了簡化。

可以看到如果遠端返回結果標識需要重定向(響應頭部是301、302、303、307等重定向標識),則HttpClient默認會自動幫我們做重定向,且每次重定向的返回流都會自動關閉。如果中途發生了異常,也會幫我們把流關閉。直到拿到最終真正的業務返回結果後,直接把整個response向外返回,這一步沒有幫我們關閉流。因此,外層的業務代碼在使用完response後,需要自行關閉流。

執行execute()方法後返回的response是一個CloseableHttpResponse實例,它的實現是什麼?點開看看,這是一個接口,此接口唯一的實現類是HttpResponseProxy。


/**
 * Extended version of the {@link HttpResponse} interface that also extends {@link Closeable}.
 *
 * @since 4.3
 */
public interface CloseableHttpResponse extends HttpResponse, Closeable {
}

我們前面經常看到的response.close(),實際是調用了HttpResponseProxy的close()方法,其內部邏輯如下:

/**
 * A proxy class for {@link org.apache.http.HttpResponse} that can be used to release client connection
 * associated with the original response.
 *
 * @since 4.3
 */
 class HttpResponseProxy implements CloseableHttpResponse {    

    @Override
    public void close() throws IOException {
        if (this.connHolder != null) {
            this.connHolder.close();
        }
    }

    ... ...
}
/**
 * Internal connection holder.
 *
 * @since 4.3
 */
@Contract(threading = ThreadingBehavior.SAFE)
class ConnectionHolder implements ConnectionReleaseTrigger, Cancellable, Closeable {
    ... ...
    @Override
    public void close() throws IOException {
        releaseConnection(false);
    }

}

可以看到最終會調用到ConnectionHolder類的releaseConnection(reusable)方法,由於ConnectionHolder的close()方法調用releaseConnection()時默認傳入了false,因此會走到else的邏輯中。這段邏輯首先調用managedConn.close()方法,然後調用manager.releaseConnection()方法。

managedConn.close()方法實際是把連接池中已經建立的連接在socket層面斷開連接,斷開之前會把inbuffer清空,並把outbuffer數據全部傳送出去,然後把連接池中的連接記錄也刪除。manager.releaseConnection()對應的代碼是PoolingHttpClientConnectionManager.releaseConnection(),這段代碼代碼本來的作用是把處於open狀態的連接的socket超時時間設置爲0,然後把連接從leased集合中刪除,如果連接可複用則把此連接加入到available鏈表的頭部,如果不可複用則直接把連接關閉。由於前面傳入的reusable已經強制爲false,因此實際關閉連接的操作已經由managedConn.close()方法做完了,走到PoolingHttpClientConnectionManager.releaseConnection()中真正的工作基本就是清除連接池中的句柄而已。

如果想了解關閉socket的細節,可以通過HttpClientConnection.close()繼續往下跟蹤,最終會看到真正關閉socket的代碼在BHttpConnectionBase中。

/**
 * This class serves as a base for all {@link HttpConnection} implementations and provides
 * functionality common to both client and server HTTP connections.
 *
 * @since 4.0
 */
public class BHttpConnectionBase implements HttpConnection, HttpInetConnection {
    ... ...
    @Override
    public void close() throws IOException {
        final Socket socket = this.socketHolder.getAndSet(null);
        if (socket != null) {
            try {
                this.inbuffer.clear();
                this.outbuffer.flush();
                try {
                    try {
                        socket.shutdownOutput();
                    } catch (final IOException ignore) {
                    }
                    try {
                        socket.shutdownInput();
                    } catch (final IOException ignore) {
                    }
                } catch (final UnsupportedOperationException ignore) {
                    // if one isn't supported, the other one isn't either
                }
            } finally {
                socket.close();
            }
        }
    }
    ... ...
}

爲什麼說調用了EntityUtils的部分方法後,就不需要再顯示地關閉流呢?看下它的源碼就明白了。

/**
 * Static helpers for dealing with {@link HttpEntity}s.
 *
 * @since 4.0
 */
public final class EntityUtils {
    /**
     * Ensures that the entity content is fully consumed and the content stream, if exists,
     * is closed.
     *
     * @param entity the entity to consume.
     * @throws IOException if an error occurs reading the input stream
     *
     * @since 4.1
     */
    public static void consume(final HttpEntity entity) throws IOException {
        if (entity == null) {
            return;
        }
        if (entity.isStreaming()) {
            final InputStream instream = entity.getContent();
            if (instream != null) {
                instream.close();
            }
        }
    }

    ... ...
}

(3)HttpClient進階用法

在高併發場景下,使用連接池有效複用已經建立的連接是非常必要的。如果每次http請求都重新建立連接,那麼底層的socket連接每次通過3次握手創建和4次握手斷開連接將是一筆非常大的時間開銷。
要合理使用連接池,首先就要做好PoolingHttpClientConnectionManager的初始化。如下圖,我們設置maxTotal=200且defaultMaxPerRoute=20。maxTotal=200指整個連接池中連接數上限爲200個;defaultMaxPerRoute用來指定每個路由的最大併發數,比如我們設置成20,意味着雖然我們整個池子中有200個連接,但是連接到"http://www.taobao.com"時同一時間最多隻能使用20個連接,其他的180個就算全閒着也不能給發到"http://www.taobao.com"的請求使用。因此,對於高併發的場景,需要合理分配這2個參數,一方面能夠防止全局連接數過多耗盡系統資源,另一方面通過限制單路由的併發上限能夠避免單一業務故障影響其他業務。

private static volatile CloseableHttpClient instance;

    static {
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        // Increase max total connection to 200
        cm.setMaxTotal(200);
        // Increase default max connection per route to 20
        cm.setDefaultMaxPerRoute(20);
        RequestConfig requestConfig = RequestConfig.custom()
            .setConnectTimeout(1000)
            .setSocketTimeout(1000)
            .setConnectionRequestTimeout(1000)
            .build();
        instance = HttpClients.custom()
            .setConnectionManager(cm)
            .setDefaultRequestConfig(requestConfig)
            .build();

    }

官方同時建議我們在後臺起一個定時清理無效連接的線程,因爲某些連接建立後可能由於服務端單方面斷開連接導致一個不可用的連接一直佔用着資源,而HttpClient框架又不能百分之百保證檢測到這種異常連接並做清理,因此需要自給自足,按照如下方式寫一個空閒連接清理線程在後臺運行。

public class IdleConnectionMonitorThread extends Thread {
    private final HttpClientConnectionManager connMgr;
    private volatile boolean shutdown;
    Logger logger = LoggerFactory.getLogger(IdleConnectionMonitorThread.class);

    public IdleConnectionMonitorThread(HttpClientConnectionManager connMgr) {
        super();
        this.connMgr = connMgr;
    }

    @Override
    public void run() {
        try {
            while (!shutdown) {
                synchronized (this) {
                    wait(5000);
                    // Close expired connections
                    connMgr.closeExpiredConnections();
                    // Optionally, close connections
                    // that have been idle longer than 30 sec
                    connMgr.closeIdleConnections(30, TimeUnit.SECONDS);
                } }
        } catch (InterruptedException ex) {
            logger.error("unknown exception", ex);
            // terminate
        }
    }

    public void shutdown() {
        shutdown = true;
        synchronized (this) {
            notifyAll();
        }
    }
}

我們討論到的幾個核心類的依賴關係如下:

HttpClient作爲大家常用的工具,看似簡單,但是其中卻有很多隱藏的細節值得探索。



本文作者:閒魚技術-峯明

閱讀原文

本文爲雲棲社區原創內容,未經允許不得轉載。

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