通过FD耗尽实验谈谈使用HttpClient的正确姿势

89590599 2019-06-30

一段问题代码实验

在进行网络编程时,正确关闭资源是一件很重要的事。在高并发场景下,未正常关闭的资源数逐渐积累会导致系统资源耗尽,影响系统整体服务能力,但是这件重要的事情往往又容易被忽视。我们进行一个简单的实验,使用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中,它是一个接口,需要使用它的实现类。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过FD耗尽实验谈谈使用HttpClient的正确姿势

既然3.x与4.x的HttpClient是两个完全独立的体系,那么我们就分别讨论它们的正确用法。

HttpClient 3.x用法

回顾引发故障的那段代码,通过直接new HttpClient()的方式创建HttpClient对象,然后发起请求,问题出在了这个构造函数上。由于我们使用的是无参构造函数,查看三方包源码,会发现内部会通过无参构造函数new一个SimpleHttpConnectionManager,它的成员变量alwaysClose在不特别指定的情况下默认为false。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过FD耗尽实验谈谈使用HttpClient的正确姿势

alwaysClose这个值是如何影响到我们关闭连接的动作呢?继续跟踪下去,发现HttpMethodBase(它的多个实现类分别对应HTTP中的几种方法,我们最常用的是GetMethod和PostMethod)中的releaseConnection()方法首先会尝试关闭响应输入流(下图中的①所指代码),然后在finally中调用ensureConnectionRelease(),这个方法内部其实是调用了HttpConnection类的releaseConnection()方法,如下图中的标记③所示,它又会调用到SimpleHttpConnectionManager的releaseConnection(conn)方法,来到了最关键的标记④和⑤。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过FD耗尽实验谈谈使用HttpClient的正确姿势

标记④的代码说明,如果alwaysClose=true,则会调用httpConnection.close()方法,它的内部会把输入流、输出流都关闭,然后把socket连接关闭,如标记⑥和⑦所示。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过FD耗尽实验谈谈使用HttpClient的正确姿势

然后,如果标记④处的alwaysClose=false,则会走到⑤的逻辑中,调用finishLastResponse()方法,如标记⑧所示,这段逻辑实际上只是把请求响应的输入流关闭了而已。我们的问题代码就是走到了这段逻辑,导致没能把之前使用过的连接断开,而后续的请求又没有复用这个httpClient,每次都是new一个新的,导致大量连接处于CLOSE_WAIT状态占用系统文件句柄。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

通过以上分析,我们知道使用commons-httpclient-3.x之后如果想要正确关闭连接,就需要指定always=true且正确调用method.releaseConnection()方法。

上述提到的几个类,他们的依赖关系如下图(红色箭头标出的是我们刚才讨论到的几个类):

通过FD耗尽实验谈谈使用HttpClient的正确姿势

其中SimpleHttpConnectionManager这个类的成员变量和方法列表如下图所示:

通过FD耗尽实验谈谈使用HttpClient的正确姿势

事实上,通过对commons-httpclient-3.x其他部分源码的分析,可以得知还有其他方法也可以正确关闭连接。

方法1:先调用method.releaseConnection(),然后获取到httpClient对象的SimpleHttpConnectionManager成员变量,主动调用它的shutdown()方法即可。对应的三方包源码如下图所示,其内部会调用httpConnection.close()方法。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

方法2:先调用method.releaseConnection(),然后获取到httpClient对象的SimpleHttpConnectionManager成员变量,主动调用closeIdleConnections(0)即可,对应的三方包源码如下。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

方法3:由于我们使用的是HTTP/1.1协议,默认会使用长连接,所以会出现上面的连接不释放的问题。如果客户端与服务端双方协商好不使用长连接,不就可以解决问题了吗。commons-httpclient-3.x也确实提供了这个支持,从下面的注释也可以看出来。具体这样操作,我们在创建了method后使用method.setRequestHeader("Connection", "close")设置头部信息,并在使用完成后调用一次method.releaseConnection()。Http服务端在看到此头部后会在response的头部中也带上“Connection: close”,如此一来httpClient发现返回的头部有这个信息,则会在处理完响应后自动关闭连接。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

HttpClient 4.x用法

既然官方已经不再维护3.x,而是推荐所有使用者都升级到4.x上来,我们就顺应时代潮流,重点看看4.x的用法。

(1)简易用法

最简单的用法类似于3.x,调用三方包提供的工具类静态方法创建一个CloseableHttpClient对象,然后发起调用,如下图。这种方式创建的CloseableHttpClient,默认使用的是PoolingHttpClientConnectionManager来管理连接。由于CloseableHttpClient是线程安全的,因此不需要每次调用时都重新生成一个,可以定义成static字段在多线程间复用。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

如上图,我们在获取到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()方法较长,下图进行了简化。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

可以看到如果远端返回结果标识需要重定向(响应头部是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()方法。

通过FD耗尽实验谈谈使用HttpClient的正确姿势

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

我们讨论到的几个核心类的依赖关系如下:

通过FD耗尽实验谈谈使用HttpClient的正确姿势

HttpClient作为大家常用的工具,看似简单,但是其中却有很多隐藏的细节值得探索。



本文作者:闲鱼技术-峰明

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

相关推荐