1. 程式人生 > >深度解讀 | 通過FD耗盡實驗談談使用HttpClient的正確姿勢

深度解讀 | 通過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中,它是一個介面,需要使用它的實現類。

image.png | left | 478x290

image.png | left | 526x251

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

HttpClient 3.x用法

回顧引發故障的那段程式碼,通過直接new HttpClient()的方式建立HttpClient物件,然後發起請求,問題出在了這個建構函式上。由於我們使用的是無參建構函式,檢視三方包原始碼,會發現內部會通過無參建構函式new一個SimpleHttpConnectionManager,它的成員變數alwaysClose在不特別指定的情況下預設為false。

apache HttpClient | left | 542x478

image.png | left | 550x291

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

image.png | left | 518x270

image.png | left | 524x168

image.png | left | 524x207

image.png | left | 524x207

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

image.png | left | 528x146

image.png | left | 532x581

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

image.png | left | 642x297

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

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

image.png | left | 719x456

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

image.png | left | 442x374

事實上,通過對commons-httpclient-3.x其他部分原始碼的分析,可以得知還有其他方法也可以正確關閉連線。
方法1:先呼叫method.releaseConnection(),然後獲取到httpClient物件的SimpleHttpConnectionManager成員變數,主動呼叫它的shutdown()方法即可。對應的三方包原始碼如下圖所示,其內部會呼叫httpConnection.close()方法。

image.png | left | 271x118

方法2:先呼叫method.releaseConnection(),然後獲取到httpClient物件的SimpleHttpConnectionManager成員變數,主動呼叫closeIdleConnections(0)即可,對應的三方包原始碼如下。

image.png | left | 559x168

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

image.png | left | 544x223

HttpClient 4.x用法

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

(1)簡易用法

最簡單的用法類似於3.x,呼叫三方包提供的工具類靜態方法建立一個CloseableHttpClient物件,然後發起呼叫,如下圖。這種方式建立的CloseableHttpClient,預設使用的是PoolingHttpClientConnectionManager來管理連線。由於CloseableHttpClient是執行緒安全的,因此不需要每次呼叫時都重新生成一個,可以定義成static欄位在多執行緒間複用。

image.png | left | 478x334

如上圖,我們在獲取到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()方法較長,下圖進行了簡化。

image.png | left | 516x447

可以看到如果遠端返回結果標識需要重定向(響應頭部是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()方法。

image.png | left | 515x251

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();
        }
    }
}

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

image.png | left | 826x305

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