Apache HttpClient使用不当导致的请求超时问题排查

Apache HttpClient使用不当导致的请求超时问题排查

一、背景

近期负责的线上应用出现调用失败的问题,排查后发现是HttpClient相关的问题,特此记录一下。

因为涉及线上数据安全隐私,模拟生产的应用写了demo,模拟一定的并发请求复现该问题。

1 问题介绍

收到线上告警通知:外部服务调用某应用A大量报错,平均响应时常很长,经常有请求超时的情况。

二、问题排查

1 初步排查

1.1 查连接情况

外部请求是http请求,所以可以查一下连接的情况。对应 java 进程的连接数:

具体的连接情况:


202207311129431.png
[root@sky ~]# netstat -tanp | grep 8433 | grep 8080 | wc -l
400

应用的 8080 端口有400个连接。

1.2 查线程情况

平均响应时常比较长,一般是线程长时间在等待某个步骤完成。查一下应用的线程执行情况:

执行命令 jstack 8433 | less

202207311117556.png

jstack的日志中有很多类似上面图中的线程,处于 TIME_WAITING状态,而下面的堆栈中,HttpClientUtils.httpGet()是使用 Apache HttpClient 工具封装的 http 请求函数,PoolHttpClientConnectionManager.leaseConnection() 看上去是在等待获取连接。大概分析得出:有很多线程在执行过程中,等待HttpClient获取连接。

1.3 查应用异常日志

查一下应用的日志,寻找线索,在日志中看到许多下面的异常日志:

2022-07-24 21:47:44.765 ERROR 82381 --- [pool-2-thread-7] com.skyme.TestController                 : error:

org.apache.http.conn.ConnectionPoolTimeoutException: Timeout waiting for connection from pool
    at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.leaseConnection(PoolingHttpClientConnectionManager.java:286) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.conn.PoolingHttpClientConnectionManager$1.get(PoolingHttpClientConnectionManager.java:263) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:190) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:184) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:88) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:184) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:82) ~[httpclient-4.5.2.jar:4.5.2]
    at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:107) ~[httpclient-4.5.2.jar:4.5.2]
    at com.skyme.HttpClientUtils.httpGet(HttpClientUtils.java:31) ~[classes/:na]
    at com.skyme.TestController.lambda$batchHttpCall$0(TestController.java:47) ~[classes/:na]
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) ~[na:1.8.0_73]
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) ~[na:1.8.0_73]
    at java.lang.Thread.run(Thread.java:745) ~[na:1.8.0_73]

1.4 初步结论

外部有大量请求,很多请求的处理线程在等待 HttpClient 获取连接,问题应该在 HttpClient 。

2 进一步排查

2.1 分析 HttpClient

日志里两个关键信息:

1.是HttpClient内部报错

org.apache.http.conn.ConnectionPoolTimeoutException: Timeout waiting for connection from pool
    at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.leaseConnection(PoolingHttpClientConnectionManager.java:286) ~[httpclient-4.5.2.jar:4.5.2]

对应的源码org.apache.http.impl.conn.PoolingHttpClientConnectionManager.leaseConnection(PoolingHttpClientConnectionManager.java:286)

protected HttpClientConnection leaseConnection(
        final Future<CPoolEntry> future,
        final long timeout,
        final TimeUnit tunit) throws InterruptedException, ExecutionException, ConnectionPoolTimeoutException {
    final CPoolEntry entry;
    try {
        entry = future.get(timeout, tunit); // 该处会抛出TimeoutException
        if (entry == null || future.isCancelled()) {
            throw new InterruptedException();
        }
        Asserts.check(entry.getConnection() != null, "Pool entry with no connection");
        if (this.log.isDebugEnabled()) {
            this.log.debug("Connection leased: " + format(entry) + formatStats(entry.getRoute()));
        }
        return CPoolProxy.newProxy(entry);
    } catch (final TimeoutException ex) {
        // 捕获TimeoutException异常,再抛出ConnectionPoolTimeoutException
        throw new ConnectionPoolTimeoutException("Timeout waiting for connection from pool");
    }
}    

抛出ConnectionPoolTimeoutException是因为触发了TimeoutException异常,而TimeoutException异常对应于entry = future.get(timeout, tunit);

PoolingHttpClientConnectionManager.leaseConnection 是为了获取连接,分析一下其执行流程,摘录一下网上博客(https://segmentfault.com/a/1190000012343705) 获取connection时序图:

f3927e5b5ad94a949331a28870d49a95_tplv-k3u1fbpfcp-watermark.png

重点的步骤:

1.如果route下有空闲连接,则直接返回空闲连接

2.如果没有空闲连接,且该connPerRoute及池中conn数均没有达到最大值,的conn数没有达到最大值,则创建连接,并放入池中,并返回该连接

3.如果没有空闲连接,且达到了maxRoute或maxPoolsize,则阻塞等待,等待的时常,便是entry = future.get(timeout, tunit);中的 timeout 时长。如果指定的时间没有 lease 到 connection ,则 entry = future.get(timeout, tunit); 会抛出 TimeoutException 异常。

一般 pool 设计都是和上面的类似,我们继续分析一下 httpclient pool 的结构 :

图片摘录自
6d5ff0d006a2437fa2703d0909cf4198_tplv-k3u1fbpfcp-watermark.png

PoolEntry<HttpRoute, OperatedClientConnection>: 路由和连接的对应关系。

routeToPool: 可以多个(图中仅示例两个),图中各队列大小动态变化,并不相等。

maxTotal: 限制的是外层 httpConnPoolleased 集合和 available 队列的总和的大小,httpConnPoolleasedavailable 的大小没有单独限制。

maxPerRoute: 限制的是每个 routeToPoolleased 集合和 available 队列的总和的大小。

一步步跟踪源码entry = future.get(timeout, tunit);,定位到核心调用处 org.apache.http.pool.AbstractConnPool#getPoolEntryBlocking() ,对应源码

private E getPoolEntryBlocking(
        final T route, final Object state,
        final long timeout, final TimeUnit tunit,
        final PoolEntryFuture<E> future)
            throws IOException, InterruptedException, TimeoutException {

    Date deadline = null; // 阻塞等待的截止时间
    if (timeout > 0) { 
        deadline = new Date
            (System.currentTimeMillis() + tunit.toMillis(timeout));
    }
    // 首先对当前连接池加锁,当前锁是可重入锁ReentrantLockthis.lock.lock();
    this.lock.lock();
    try {
        // 获得一个当前HttpRoute对应的连接池,即routeToPool
        final RouteSpecificPool<T, C, E> pool = getPool(route);
        E entry = null;
        while (entry == null) {
            Asserts.check(!this.isShutDown, "Connection pool shut down");
            for (;;) {
                entry = pool.getFree(state);
                if (entry == null) {
                    break;
                }
                // 如果拿到的是过期连接或者已关闭连接,就释放资源,继续循环获取
                if (entry.isExpired(System.currentTimeMillis())) {
                    entry.close();
                } else if (this.validateAfterInactivity > 0) {
                    if (entry.getUpdated() + this.validateAfterInactivity <= System.currentTimeMillis()) {
                        if (!validate(entry)) {
                            entry.close();
                        }
                    }
                }
                if (entry.isClosed()) {
                    this.available.remove(entry);
                    pool.free(entry, false);
                } else {
                    break;
                }
            }
            // 拿到未在使用中的(available)有效连接就退出循环,直接return对应连接
            if (entry != null) {
                this.available.remove(entry);
                this.leased.add(entry);
                onReuse(entry);
                return entry;
            }

            // New connection is needed。执行到这说明没有拿到连接,需要新建连接
            final int maxPerRoute = getMax(route);
            // Shrink the pool prior to allocating a new connection
            // pool.getAllocatedCount()=pool.available.size() + pool.leased.size(),可用的加正在租借的即当前池中已有的连接
            // 如果route对应的连接池的已有连接数超过了支持的最大连接数量(加1的目的?),则通过LRU清理掉一些连接。
            final int excess = Math.max(0, pool.getAllocatedCount() + 1 - maxPerRoute);
            if (excess > 0) {
                for (int i = 0; i < excess; i++) {
                    final E lastUsed = pool.getLastUsed();
                    if (lastUsed == null) {
                        break;
                    }
                    lastUsed.close();
                    this.available.remove(lastUsed);
                    pool.remove(lastUsed);
                }
            }

            // 如果route对应的连接池的已有连接数未达到最大最大限制
            if (pool.getAllocatedCount() < maxPerRoute) {
                final int totalUsed = this.leased.size();
                // freeCapacity指大池子里除了已用连接,最多能容纳的空闲连接数(包括已经创建的available连接和后续还允许创建的available连接)
                final int freeCapacity = Math.max(this.maxTotal - totalUsed, 0);
                if (freeCapacity > 0) {
                    final int totalAvailable = this.available.size();
                    // 此时当前route肯定leased已满,无available,若大池子里已达到上限,且大池子里还有available的空闲连接,那可以清理其他route下的无available连接,为新连接挪出位置。所以close的是otherpool中lastUsed的available连接。若大池子没有达到上限,则没必要做清理。
                    if (totalAvailable > freeCapacity - 1) {
                        if (!this.available.isEmpty()) {
                            final E lastUsed = this.available.removeLast();
                            lastUsed.close();
                            final RouteSpecificPool<T, C, E> otherpool = getPool(lastUsed.getRoute());
                            otherpool.remove(lastUsed);
                        }
                    }
                    // 创建一个新连接
                    final C conn = this.connFactory.create(route);
                    // 放入route对应的池子的leased集合
                    entry = pool.add(conn);
                    // 放入大池子的leased集合
                    this.leased.add(entry);
                    // reutrn该连接
                    return entry;
                }
            }

            // 到这里证明没有从获得route池中获得有效连接,并且想要自己建立连接时当前route连接池已经到达最大值,即已经有连接在使用,但是对当前线程不可用
            boolean success = false;
            try {
                // 将future放入route池中等待,实际就是加入route池的pending集合
                pool.queue(future);
                // 放入大池子的pending集合
                this.pending.add(future);
                // 如果等待到了信号量的通知,success为true。如果到截止时间还未等到,则success为false
                success = future.await(deadline);
            } finally {
                // In case of 'success', we were woken up by the
                // connection pool and should now have a connection
                // waiting for us, or else we're shutting down.
                // Just continue in the loop, both cases are checked.
                // fanally,从等待队列中移除
                pool.unqueue(future);
                this.pending.remove(future);
            }
            // check for spurious wakeup vs. timeout
            // 如果没有等到信号量通知并且当前时间已经超时,则退出循环
            if (!success && (deadline != null) &&
                (deadline.getTime() <= System.currentTimeMillis())) {
                break;
            }
        }
        // 最终也没有等到信号量通知,没有拿到可用连接,则抛异常
        throw new TimeoutException("Timeout waiting for connection");
    } finally {
        // 释放对大池子的锁
        this.lock.unlock();
    }
}

综合分析:

  • 通过线程池获取连接要通过 ReetrantLock 加锁,保证线程安全
  • 不论是大连接池还是小连接池,当超过数量的时候,都要通过LRU释放一些连接
  • 如果拿到了可用连接,则返回给上层使用
  • 如果没有拿到可用连接,HttpClient 会判断当前 route 连接池是否已经超过了最大数量,没有到上限就会新建一个连接,并放入池中
  • 如果到达了上限,就排队等待,等到了信号量,就重新获得一次,等待不到就抛 TimeoutException

思考

池类的设计,不管是连接池,线程池,一般不够用的场景,都是最大数设置的不够大,不足以应对并发获取连接、线程等?

2.2 确认HttpClient 实际情况

上面的都是猜想,最终问题定位还是要摸清实际情况。

应用里是如何创建 HttpClient 实例

下面是应用中创建 HttpClient 实例的地方,我们从变量 HTTP_CLIENT开始定位。

    private static final CloseableHttpClient HTTP_CLIENT = HttpClients
            .custom()
            .setDefaultRequestConfig(
                    RequestConfig.custom()
                    .setCookieSpec(CookieSpecs.IGNORE_COOKIES)
                    .setConnectionRequestTimeout(30000)
                    .build()
            ).build();

上面代码可知:

1.没有设置 maxPerRoute、maxTotal这类的参数。

2.从连接池获取连接的超时时间的参数 ConnectionRequestTimeout ,设置为30秒。

arthas 定位实际数据

因为不是本地环境,不能方便地 Debug,这时可以用 arthas 来查进行内存中 HttpClient 的实际数据。

在arthas中执行:getstatic com.skyme.HttpClientUtils HTTP_CLIENT -x 4,结果如下:

202207311205785.png

routeToPool中,route [https//github.com] 的使用情况是:leased为2,avaibable为0,pending为198。

leased + avaibable 结果为2,怎么这么少?难道是 maxPerRoute 参数设置的过小?

在arthas结果中我们找到了 maxPerRoute 相关的配置,maxPerRoute 默认值只有2,因为我们创建 HttpClient 实例时,没有设置maxPerRoute的数值,所以每个route 的 maxPerRoute 为2, 这和上面的 leased + avaibable 的结果对应上了。此外,还有一个重要参数 maxToal 为20。

202207311359531.png

大致结论:默认参数太小

到这里,问题基本定位清楚了。因为应用没有显示地设置 maxPerRoute 和 maxToal ,所以取了默认值,默认值比较小,maxPerRoute 为2, maxToal 为20, 导致并发获取连接时,连接不够用,导致了获取连接的线程一直在等待,等待时间为我们创建 HttpClien 实例时自定义的 30 秒,等待时间过长,导致了外部请求超时。

三、解决办法

1 临时办法

可以使用 arthas 的 vmtool 命令将内存中的 defaultMaxPerRoute 、maxTotal 参数修改大一些,该操作过于风骚,一般不符合生产环境的运维操作规范。但是测试环境或者紧急情况可以尝试使用。

[arthas@8433]$ vmtool --action getInstances --className org.apache.http.impl.client.CloseableHttpClient --express 'instances[0].connManager.pool.defaultMaxPerRoute=100'
@Integer[100]
[arthas@8433]$ vmtool --action getInstances --className org.apache.http.impl.client.CloseableHttpClient --express 'instances[0].connManager.pool.maxTotal=500'

修改后,连接池中 leased 明显增多,pending 减少。

202207311358937.png

一段时间后,pending为0,说明没有阻塞,连接数已足够使用。

202207311359531.png

注意:

1.该修改不需要应用重启,但应用重启后,内存的设置将销毁,依然还会存在问题。

2.非常规操作,谨慎执行。

3.vmtool 命令在 arthas 3.5.1 版本之后才有。

2 长期办法

修改代码,配置合理的最大连接数maxPerRoute、maxTotal、ConnectionRequestTimeout。

当前 HttpClient 版本为 4.5.2,其他版本可能代码有所差异。

    private final static PoolingHttpClientConnectionManager POOLING_HTTP_CLIENT_CONNECTION_MANAGER
            = new PoolingHttpClientConnectionManager();

    static {
        // 每个route默认的最大连接数
        POOLING_HTTP_CLIENT_CONNECTION_MANAGER.setDefaultMaxPerRoute(100);
        // 总的连接池最大连接数
        POOLING_HTTP_CLIENT_CONNECTION_MANAGER.setMaxTotal(500);
    }

    private static final CloseableHttpClient HTTP_CLIENT = HttpClients
        .custom()
        .setConnectionManager(POOLING_HTTP_CLIENT_CONNECTION_MANAGER)
        .setDefaultRequestConfig(
                RequestConfig.custom()
                .setCookieSpec(CookieSpecs.IGNORE_COOKIES)//
                // 从连接池获取连接的超时时间
                .setConnectionRequestTimeout(10000)
                .build()
        ).build();

defaultMaxPerRoute 设置为100,maxTotal 设置为500,并将 connectionRequestTimeout 从30秒减少到10秒,没有拿到连接快速失败,防止过多的线程阻塞导致挤压。

四、总结

1.问题原因是 HttpClient 使用时没有对参数足够了解,对于连接池的框架,最好设置最大数,获取连接的等待超时时间,以及回收的周期,连接是否可用的校验等等参数。其他池类的框架同理,例如线程池。

2.通过源码、日志、线程、连接等多方面进行分析,结论会更准确。

3.arthas 是神器,可以多尝试。

参考:

[Http持久连接与HttpClient连接池]https://juejin.cn/post/6844903986705727495

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351

推荐阅读更多精彩内容