經過FD耗盡實驗談談使用HttpClient的正確姿式

一段問題代碼實驗

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

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個問題:linux

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

(2)在未使用鏈接池複用鏈接的狀況下,代碼沒有正確調用releaseConnection。catch塊中的標記②是惟一調用了releaseConnection方法的代碼,而這段代碼僅在發生異常時纔會走到,大部分狀況下都走不到這裏,因此即便咱們前面用正確的方式初始化了HttpClient,因爲沒有手動釋放鏈接,也仍是會出現鏈接堆積的問題。編程

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

什麼是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()方法較長,下圖進行了簡化。

能夠看到若是遠端返回結果標識須要重定向(響應頭部是30一、30二、30三、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做爲你們經常使用的工具,看似簡單,可是其中卻有不少隱藏的細節值得探索。



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

閱讀原文

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

相關文章
相關標籤/搜索