连接池剖析及连接泄漏定位思路

背景

最近连续解决两起连接泄漏的问题,期间阅读了大量开源源码,发现开源软件中设计的连接池,用的也都是一些常规手段,本文为大家揭开这层神秘的面纱。

概述

大多数应用中应该都是用的TCP协议,TCP连接在建立阶段会经过3次握手,销毁阶段会经过4次握手。标准网络是分7层的,每一层都有各自的协议头,应用层拿到的有效数据,在整个报文中占比没这么大。TCP握手阶段的报文,对应用层来讲都是额外的负担。所以很多客户端都会设计连接池,来复用已建立的连接。减少创建连接带来的消耗。
连接池本身不复杂,如果不想做的通用,少量的代码就能够实现。下图是一个连接池中必要的组件。
在这里插入图片描述

上图各组件含义:

  • Client-客户端,屏蔽底层连接细节,使用它可以方便的与远端交互;比如RedisTemplate、HttpClient、KafkaProducer等。
  • Connection Manager-连接管理器,Client内部在与远端交互时需要获得连接,连接既然是被复用的肯定是需要被管理起来,因此衍生出连接管理器的概念。
  • Pool-缓冲池,用于存储连接的;里面分idle、in use。
  • Validator-校验器,当应用负载较低的时候,连接池中的连接可能很久后才会被重新使用。有时候服务端会配置超时时间,连接空闲一段时间后会被服务端主动关闭,因此连接池中的连接可能已经无效了。获取空闲连接后,需要使用校验器进行校验,防止无效的空闲连接返回到上层。
  • Cleaner-清理器,连接池一般会配置两个参数,分别为MinConnectionCount和MaxConnectionCount,类似java线程池中的核心线程数和最大线程数,用于应对闲时和忙时,减少资源浪费。经历过忙时后,不需要这么多连接资源了,因此需要有机制去清理闲置资源。
  • Connection Factory-连接工厂,Connection Manager仅仅只负责管理职责,为了方便扩展,需要抽象出连接工厂这个概念,当连接池的资源不足,需要创建实际的连接时,由这个对象负责。

重点对象分析

connection wrapper

连接池的设计目标就是让使用者无感,如果底层的connection不进行包装,直接返回给上层使用,上层使用完后调用close方法就会销毁这个对象,使之无法得到复用。因此需要使用wrapper包装connection,并覆写close等方法进行拦截,让框架有机会去进行资源回收。
下面以Jedis的源码为例。
Jedis基于Apache的commons pool来构建连接池。
JedisConnectionFactory担任的是Connection Manager的职责。
JedisFactory同时担任Connection Factory和Validator的职责。
从源码中可以看到,创建出的Jedis会被JedisConnection 包装。不过严格来讲,JedisConnection并不是为了去拦截close操作而对Jedis进行包装的。Jedis自己就持有Pool,自己就对close方法做了拦截,并进行连接回收。细节部分大家可以自己顺着代码往下看。
org.springframework.data.redis.connection.jedis.JedisConnectionFactory#getConnection

	public RedisConnection getConnection() {
		Jedis jedis = fetchJedisConnector();
		# 对Jedis进行包装
		JedisConnection connection = (getUsePool() ? new JedisConnection(jedis, pool, getDatabase(), getClientName())
				: new JedisConnection(jedis, null, getDatabase(), getClientName()));
		connection.setConvertPipelineAndTxResults(convertPipelineAndTxResults);
		return postProcessConnection(connection);
	}

Cleaner

本着“粒粒皆辛苦的原则”,大多数应用在设计时,肯定会考虑资源闲置的问题。系统忙时和闲时对资源要求不一样,因此连接数也有两个配置,最大数量和最小数量。资源一旦创建出来,不主动回收,就会一直闲置在这里。因此需要有个cleaner的角色对闲置资源进行回收。
回收一般也只有两种方法:

  • 设置异步线程,定时去扫描闲置资源,并进行释放
  • 在获取连接时,先执行一次清理逻辑。
异步线程回收资源

目前大部分应该都是采用这种方案,采用这种方案时要小心,清理逻辑里面要考虑掉各种异常并捕获。不然有未捕获的异常导致线程终止,那就永远失去了回收资源的能力。以AWS-S3的客户端组件为例:
AWS-S3存储和获取对象时使用的是HTTP协议,该客户端基于httpcomponents。
跟着代码最终可以定位到ApacheHttpClientFactory的create方法。
com.amazonaws.AmazonWebServiceClient#client(字段)
 com.amazonaws.http.apache.client.impl.ApacheHttpClientFactory#create

    # 创建HttpClient
    public ConnectionManagerAwareHttpClient create(HttpClientSettings settings) {
        HttpClientBuilder builder = HttpClients.custom();
        HttpClientConnectionManager cm = (HttpClientConnectionManager)this.cmFactory.create(settings);
        builder.setRequestExecutor(new SdkHttpRequestExecutor()).setKeepAliveStrategy(this.buildKeepAliveStrategy(settings)).disableRedirectHandling().disableAutomaticRetries().setConnectionManager(ClientConnectionManagerFactory.wrap(cm));
        ConnectionManagerAwareHttpClient httpClient = new SdkHttpClient(builder.build(), cm);
        # 注册连接池清理器
        if (settings.useReaper()) {
            IdleConnectionReaper.registerConnectionManager(cm, settings.getMaxIdleConnectionTime());
        }
        return httpClient;
    }

下面两个为IdleConnectionReaper的源码

    public static boolean registerConnectionManager(HttpClientConnectionManager connectionManager, long maxIdleInMs) {
        if (instance == null) {
            synchronized (IdleConnectionReaper.class) {
                if (instance == null) {
                    instance = new IdleConnectionReaper();
                    instance.start();
                }
            }
        }
        return connectionManagers.put(connectionManager, maxIdleInMs) == null;
    }

IdleConnectionReaper是Thread子类,调用其start方法就会启动一个线程去执行run方法。从run方法逻辑可以看到,就是调用HttpClientConnectionManager去清理闲置链接。清理方法就是判断线程的闲置时间是否超过设定的阈值。

    public void run() {
        while (!shuttingDown) {
            for (Map.Entry<HttpClientConnectionManager, Long> entry : connectionManagers.entrySet()) 
                entry.getKey().closeIdleConnections(entry.getValue(), TimeUnit.MILLISECONDS);
            }
            Thread.sleep(PERIOD_MILLISECONDS);
        }
    }
获取连接时同步进行回收

目前没有看到有那个组件采用这种方式,像Jedis甚至没有把清理机制加进去。
common pool是有设计去清理空闲连接的-org.apache.commons.pool2.impl.GenericObjectPool#evict。只是Jedis封装pool后,没有去调用这个逻辑。

validator

如上所述,validator用于验证从连接池获取的连接是否有效,因为连接池长期闲置的连接因为服务端主动关闭导致不可用。一般validator的功能是放在连接工厂里面的,common pool给PooledObjectFactory定义了一下方法。

public interface PooledObjectFactory<T> {
  # 创建连接
  PooledObject<T> makeObject() throws Exception;
  # 销毁连接
  void destroyObject(PooledObject<T> p) throws Exception;
  # 校验连接是否有效
  boolean validateObject(PooledObject<T> p);
  # 激活连接,应该只是修改一下状态
  void activateObject(PooledObject<T> p) throws Exception;
  # 无效连接,应该只是修改一下状态
  void passivateObject(PooledObject<T> p) throws Exception;
}

来看一下Jedis如何实现有效性校验的redis.clients.jedis.JedisFactory#validateObject
Jedis主要校验主机名和端口是否一致(这个主要是考虑哨兵模式,哨兵模式会发生主从切换,老连接清理没那么及时),然后使用“Ping”命令判断连接是否仍然联通。

    public boolean validateObject(PooledObject<Jedis> pooledJedis) {
        BinaryJedis jedis = (BinaryJedis)pooledJedis.getObject();

        try {
            HostAndPort hostAndPort = (HostAndPort)this.hostAndPort.get();
            String connectionHost = jedis.getClient().getHost();
            int connectionPort = jedis.getClient().getPort();
            # 校验逻辑
            return hostAndPort.getHost().equals(connectionHost) && hostAndPort.getPort() == connectionPort && jedis.isConnected() && jedis.ping().equals("PONG");
        } catch (Exception var6) {
            return false;
        }
    }
重点:像Redis这种长连接,而且也设计了连通性检测命令,能够很方便的设计失效校验逻辑。像HTTP协议就得精心去设计这个逻辑了。因为HTTP是单次单向通信,后面为了提高性能是HTTP1.1中加入了keep alive特性。对于协议本身来说没有设计保活检测机制,只能依赖socket的检测机制。socket远端是否断开连接有2种方法:
  1. socket类中有一个方法sendUrgentData,它会往输出流发送一个字节的数据,只要对方Socket的SO_OOBINLINE属性没有打开,就会自动舍弃这个字节(在Java 中是抛出异常),而SO_OOBINLINE属性默认情况下就是关闭的。
    缺点:不可靠,得远端开启SO_OOBINLINE。
  2. 调用socket的read方法,看返回值是不是-1.

HttpClient采用的是方法2,方法2也有一个问题要解决,如果read方法没有返回-1,而且还有字节返回。返回的字节是有效数据,不能随意丢弃。而且此时的检测逻辑与业务无关,还没办法把读出来的字节返回给上层。
HttpClient采用了巧妙的设计,通过包装socket的inputstream。类似bufferedInputstream包装inputstream,为inputstream建立缓存机制,如果检测时读取到的状态是-1,他们认为连接已失效,进行销毁;如果读取到了有效字节,则先放到缓存区中,待上层应用使用。
追踪连接获取的过程,
org.apache.http.impl.conn.PoolingHttpClientConnectionManager#requestConnection
 org.apache.http.impl.conn.PoolingHttpClientConnectionManager#leaseConnection
  org.apache.http.pool.AbstractConnPool#lease(T, java.lang.Object, org.apache.http.concurrent.FutureCallback)
代码有删减

            public E get(final long timeout, final TimeUnit timeUnit) throws InterruptedException, ExecutionException, TimeoutException {
                for (;;) {
                    synchronized (this) {
                        try {
                            final E entry = entryRef.get();
                            final E leasedEntry = getPoolEntryBlocking(route, state, timeout, timeUnit, this);
                            if (validateAfterInactivity > 0)  {
                                if (leasedEntry.getUpdated() + validateAfterInactivity <= System.currentTimeMillis()) {
                                    # 校验连接的有效性
                                    if (!validate(leasedEntry)) {
                                        leasedEntry.close();
                                        release(leasedEntry, false);
                                        continue;
                                    }
                                }
                            }
                        } 
                    }
                }
            }

连接有效性校验最终追溯到org.apache.http.impl.BHttpConnectionBase#isStale,可以看到是通过读取socket,并看返回值是否-1来判断连接是否失效。

    public boolean isStale() {
        try {
            # 从socket里面读取字节并存储到buffer中并返回实际读取的字节数,如果返回-1说明socket已经被远端关闭
            final int bytesRead = fillInputBuffer(1);
            return bytesRead < 0;
        } 
    }
    private int fillInputBuffer(final int timeout) throws IOException {
        try {
            return this.inBuffer.fillBuffer();
        }
    }
        public int fillBuffer() throws IOException {
        # 类似java NIO中的buffer,有pos和limit。需要把未读的字节移到buffer的起始位置,方便空出位置来容纳新的字节
        if (this.bufferPos > 0) {
            final int len = this.bufferLen - this.bufferPos;
            if (len > 0) {
                System.arraycopy(this.buffer, this.bufferPos, this.buffer, 0, len);
            }
            this.bufferPos = 0;
            this.bufferLen = len;
        }
        final int readLen;
        final int off = this.bufferLen;
        final int len = this.buffer.length - off;
        # 读取socket里面的字节并,存储到buffer。buffer作为socket的缓存提供给上层使用。
        readLen = streamRead(this.buffer, off, len);
        if (readLen == -1) {
            return -1;
        }
        this.bufferLen = off + readLen;
        return readLen;
    }

附-AWS-S3连接泄漏定位

接到一个case,某个功能从S3上传下载文件时抛了异常,com.amazonaws.SdkClientException: Unable to execute HTTP request: Timeout waiting for connection from pool。
之前使用HttpClient,在并发量较大的情况下也报了这个错,后面发现是HttpClient开启连接池的情况下,默认最大连接数是5,当达到上限后,需要等待其他线程释放连接,而且这个等待时间也有上限,默认好像是60s。超时仍然无法获得连接就会报上面这个错。
因此第一反应进后台看看连接情况。
从图中看到连接数已经达到上线50(AWS客户端默认配置上线是50)。当看到状态全部为close_wait状态第一反应就是有泄漏。因为连接池有清理机制。

  1. 如果这些是正在使用的连接,那正在读取数据,状态不可能为close_wait.
  2. 如果这些连接已经被回收,而且空闲时间又较长,理论上应该会被清理掉。
    在这里插入图片描述

基于上面判断,等待一段时间后发现查出来的数据不变,而且也咨询过项目组的人,目前没有人测试这个功能,因此断定是连接泄漏。
下一步就是像之前定位Redis连接数不断增长的问题一样;DUMP内存,然后看连接池中的连接是否状态是in use,并且除了连接池本身没有其他对象持有他们。如下图印证自己的猜测,剩的就是排查该功能的代码,发现调用s3时没有关闭资源的地方。
在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值