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

38次阅读

共计 10894 个字符,预计需要花费 28 分钟才能阅读完成。

一段问题代码实验
在进行网络编程时,正确关闭资源是一件很重要的事。在高并发场景下,未正常关闭的资源数逐渐积累会导致系统资源耗尽,影响系统整体服务能力,但是这件重要的事情往往又容易被忽视。我们进行一个简单的实验,使用 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 作为大家常用的工具,看似简单,但是其中却有很多隐藏的细节值得探索。

本文作者:闲鱼技术 - 峰明阅读原文
本文为云栖社区原创内容,未经允许不得转载。

正文完
 0