Day626.连接池使用注意事项 -Java业务开发常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day626.连接池使用注意事项 -Java业务开发常见错误相关的知识,希望对你有一定的参考价值。

连接池使用注意事项

Hi,今天阿昌又来了!

这次学习记录的课是池化技术,即连接池

首先是连接池的结构。连接池一般对外提供获得连接、归还连接的接口给客户端使用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。连接池的结构示意图,如下所示:

业务项目中经常会用到的连接池,主要是数据库连接池、Redis 连接池和 HTTP 连接池。所以,今天我就以这三种连接池为例,和你聊聊使用和配置连接池容易出错的地方。

在使用三方客户端进行网络通信时,我们首先要确定客户端 SDK 是否是基于连接池技术实现的。

TCP 是面向连接的基于字节流的协议:

  • 面向连接,意味着连接需要先创建再使用,创建连接的三次握手有一定开销;
  • 基于字节流,意味着字节是发送数据的最小单元,TCP 协议本身无法区分哪几个字节是完整的消息体,也无法感知是否有多个客户端在使用同一个 TCP 连接,TCP 只是一个读写数据的管道。

如果客户端 SDK 没有使用连接池,而直接是 TCP 连接,那么就需要考虑每次建立 TCP 连接的开销,并且因为 TCP 基于字节流,在多线程的情况下对同一连接进行复用,可能会产生线程安全问题。


一、甄别组件池化技术的类型

  • 连接池和连接分离的 API:有一个 XXXPool 类负责连接池实现,先从其获得连接 XXXConnection,然后用获得的连接进行服务端请求,完成后使用者需要归还连接。通常,XXXPool 是线程安全的,可以并发获取和归还连接,而 XXXConnection 是非线程安全的。对应到连接池的结构示意图中,XXXPool 就是右边连接池那个框,左边的客户端是我们自己的代码。
  • 内部带有连接池的 API:对外提供一个 XXXClient 类,通过这个类可以直接进行服务端请求;这个类内部维护了连接池,SDK 使用者无需考虑连接的获取和归还问题。一般而言,XXXClient 是线程安全的。对应到连接池的结构示意图中,整个 API 就是蓝色框包裹的部分。
  • 非连接池的 API:一般命名为 XXXConnection,以区分其是基于连接池还是单连接的,而不建议命名为 XXXClient 或直接是 XXX。直接连接方式的 API 基于单一连接,每次使用都需要创建和断开连接,性能一般,且通常不是线程安全的。对应到连接池的结构示意图中,这种形式相当于没有右边连接池那个框,客户端直接连接服务端创建连接。

虽然上面提到了 SDK 一般的命名习惯,但不排除有一些客户端特立独行,因此在使用三方 SDK 时,一定要先查看官方文档了解其最佳实践,或是在类似 Stackoverflow 的网站搜索 XXX threadsafe/singleton 字样看看大家的回复,也可以一层一层往下看源码,直到定位到原始 Socket 来判断 Socket 和客户端 API 的对应关系。


明确了 SDK 连接池的实现方式后,我们就大概知道了使用 SDK 的最佳实践:

  • 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接池获取连接,使用后归还,归还的工作由使用者负责。
  • 如果是内置连接池,SDK 会负责连接的获取和归还,使用的时候直接复用客户端。
  • 如果 SDK 没有实现连接池(大多数中间件、数据库的客户端 SDK 都会支持连接池),那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自己封装一个连接池。

二、连接池务必确保复用

在之前线程池的时候说过,池一定是用来复用的,否则其使用代价会比每次创建单一对象更大。对连接池来说更是如此,原因如下:

  • 创建连接池的时候很可能一次性创建了多个连接,大多数连接池考虑到性能,会在初始化的时候维护一定数量的最小连接(毕竟初始化连接池的过程一般是一次性的),可以直接使用。如果每次使用连接池都按需创建连接池,那么很可能你只用到一个连接,但是创建了 N 个连接。
  • 连接池一般会有一些管理模块,也就是连接池的结构示意图中的绿色部分。举个例子,大多数的连接池都有闲置超时的概念。连接池会检测连接的闲置时间,定期回收闲置的连接,把活跃连接数降到最低(闲置)连接的配置值,减轻服务端的压力。一般情况下,闲置连接由独立线程管理,启动了空闲检测的连接池相当于还会启动一个线程。此外,有些连接池还需要独立线程负责连接保活等功能。因此,启动一个连接池相当于启动了 N 个线程。

除了使用代价,连接池不释放,还可能会引起线程泄露。

接下来,我就以 Apache HttpClient 为例,和你说说连接池不复用的问题。

首先,创建一个 CloseableHttpClient,设置使用 PoolingHttpClientConnectionManager 连接池并启用空闲连接驱逐策略,最大空闲时间为 60 秒,然后使用这个连接来请求一个会返回 OK 字符串的服务端接口:

@GetMapping("wrong1")
public String wrong1() 
    CloseableHttpClient client = HttpClients.custom()
            .setConnectionManager(new PoolingHttpClientConnectionManager())
            .evictIdleConnections(60, TimeUnit.SECONDS).build();
    try (CloseableHttpResponse response = client.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) 
        return EntityUtils.toString(response.getEntity());
     catch (Exception ex) 
        ex.printStackTrace();
    
    return null;

访问这个接口几次后查看应用线程情况,可以看到有大量叫作 Connection evictor 的线程,且这些线程不会销毁:

对这个接口进行几秒的压测(压测使用 wrk,1 个并发 1 个连接)可以看到,已经建立了三千多个 TCP 连接到 45678 端口(其中有 1 个是压测客户端到 Tomcat 的连接,大部分都是 HttpClient 到 Tomcat 的连接):


好在有了空闲连接回收的策略,60 秒之后连接处于 CLOSE_WAIT 状态,最终彻底关闭。

这 2 点证明,CloseableHttpClient 属于第二种模式,即内部带有连接池的 API,其背后是连接池,最佳实践一定是复用。

复用方式很简单,你可以把 CloseableHttpClient 声明为 static,只创建一次,并且在 JVM 关闭之前通过 addShutdownHook 钩子关闭连接池,在使用的时候直接使用 CloseableHttpClient 即可,无需每次都创建。

首先,定义一个 right 接口来实现服务端接口调用:

private static CloseableHttpClient httpClient = null;
static 
    //当然,也可以把CloseableHttpClient定义为Bean,然后在@PreDestroy标记的方法内close这个HttpClient
    httpClient = HttpClients.custom().setMaxConnPerRoute(1).setMaxConnTotal(1).evictIdleConnections(60, TimeUnit.SECONDS).build();
    Runtime.getRuntime().addShutdownHook(new Thread(() -> 
        try 
            httpClient.close();
         catch (IOException ignored) 
        
    ));


@GetMapping("right")
public String right() 
    try (CloseableHttpResponse response = httpClient.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) 
        return EntityUtils.toString(response.getEntity());
     catch (Exception ex) 
        ex.printStackTrace();
    
    return null;

然后,重新定义一个 wrong2 接口,修复之前按需创建 CloseableHttpClient 的代码,每次用完之后确保连接池可以关闭:

@GetMapping("wrong2")
public String wrong2() 
    try (CloseableHttpClient client = HttpClients.custom()
            .setConnectionManager(new PoolingHttpClientConnectionManager())
            .evictIdleConnections(60, TimeUnit.SECONDS).build();
         CloseableHttpResponse response = client.execute(new HttpGet("http://127.0.0.1:45678/httpclientnotreuse/test"))) 
            return EntityUtils.toString(response.getEntity());
         catch (Exception ex) 
        ex.printStackTrace();
    
    return null;

使用 wrk 对 wrong2 和 right 两个接口分别压测 60 秒,可以看到两种使用方式性能上的差异,每次创建连接池的 QPS 是 337,而复用连接池的 QPS 是 2022:

如此大的性能差异显然是因为 TCP 连接的复用。你可能注意到了,刚才定义连接池时,我将最大连接数设置为 1。

所以,复用连接池方式复用的始终应该是同一个连接,而新建连接池方式应该是每次都会创建新的 TCP 连接。

接下来,我们通过网络抓包工具 Wireshark 来证实这一点。如果调用 wrong2 接口每次创建新的连接池来发起 HTTP 请求,从 Wireshark 可以看到,每次请求服务端 45678 的客户端端口都是新的。这里我发起了三次请求,程序通过 HttpClient 访问服务端 45678 的客户端端口号,分别是 51677、51679 和 51681:

也就是说,每次都是新的 TCP 连接,放开 HTTP 这个过滤条件也可以看到完整的 TCP 握手、挥手的过程:

而复用连接池方式的接口 right 的表现就完全不同了。可以看到,第二次 HTTP 请求 #41 的客户端端口 61468 和第一次连接 #23 的端口是一样的,Wireshark 也提示了整个 TCP 会话中,当前 #41 请求是第二次请求,前一次是 #23,后面一次是 #75:

只有 TCP 连接闲置超过 60 秒后才会断开,连接池会新建连接。


三、合理配置连接池参数

为方便根据容量规划设置连接处的属性,连接池提供了许多参数,包括最小(闲置)连接、最大连接、闲置连接生存时间、连接生存时间等。

其中,最重要的参数是最大连接数,它决定了连接池能使用的连接数量上限,达到上限后,新来的请求需要等待其他请求释放连接。

  • 但,最大连接数不是设置得越大越好。如果设置得太大,不仅仅是客户端需要耗费过多的资源维护连接,更重要的是由于服务端对应的是多个客户端,每一个客户端都保持大量的连接,会给服务端带来更大的压力。
    这个压力又不仅仅是内存压力,可以想一下如果服务端的网络模型是一个 TCP 连接一个线程,那么几千个连接意味着几千个线程,如此多的线程会造成大量的线程切换开销。

  • 当然,连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量低下,甚至超时无法获取连接

接下来,我们就模拟下压力增大导致数据库连接池打满的情况,来实践下如何确认连接池的使用情况,以及有针对性地进行参数优化。首先,定义一个用户注册方法,通过 @Transactional 注解为方法开启事务。其中包含了 500 毫秒的休眠,一个数据库事务对应一个 TCP 连接,所以 500 多毫秒的时间都会占用数据库连接:

@Transactional
public User register()
    User user=new User();
    user.setName("new-user-"+System.currentTimeMillis());
    userRepository.save(user);
    try 
        TimeUnit.MILLISECONDS.sleep(500);
     catch (InterruptedException e) 
        e.printStackTrace();
    
    return user;

随后,修改配置文件启用 register-mbeans,使 Hikari 连接池能通过 JMX MBean 注册连接池相关统计信息,方便观察连接池:

spring.datasource.hikari.register-mbeans=true

启动程序并通过 JConsole 连接进程后,可以看到默认情况下最大连接数为 10:

使用 wrk 对应用进行压测,可以看到连接数一下子从 0 到了 10,有 20 个线程在等待获取连接:

不久就出现了无法获取数据库连接的异常,如下所示:

[15:37:56.156] [http-nio-45678-exec-15] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DataAccessResourceFailureException: unable to obtain isolated JDBC connection; nested exception is org.hibernate.exception.JDBCConnectionException: unable to obtain isolated JDBC connection] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.

从异常信息中可以看到,数据库连接池是 HikariPool,解决方式很简单,修改一下配置文件,调整数据库连接池最大连接参数到 50 即可。

spring.datasource.hikari.maximum-pool-size=50

然后,再观察一下这个参数是否适合当前压力,满足需求的同时也不占用过多资源。

从监控来看这个调整是合理的,有一半的富余资源,再也没有线程需要等待连接了:

在这个 Demo 里,我知道压测大概能对应使用 25 左右的并发连接,所以直接把连接池最大连接设置为了 50。

在真实情况下,只要数据库可以承受,你可以选择在遇到连接超限的时候先设置一个足够大的连接数,然后观察最终应用的并发,再按照实际并发数留出一半的余量来设置最终的最大连接。其实,看到错误日志后再调整已经有点儿晚了。

更合适的做法是,对类似数据库连接池的重要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容。

在这里我是为了演示,才通过 JConsole 查看参数配置后的效果,生产上需要把相关数据对接到指标监控体系中持续监测。


四、总结

  • 客户端 SDK 实现连接池的方式,包括池和连接分离、内部带有连接池和非连接池三种。要正确使用连接池,就必须首先鉴别连接池的实现方式。比如,Jedis 的 API 实现的是池和连接分离的方式,而 Apache HttpClient 是内置连接池的 API。
  • 对于使用姿势其实就是两点,一是确保连接池是复用的,二是尽可能在程序退出之前显式关闭连接池释放资源。连接池设计的初衷就是为了保持一定量的连接,这样连接可以随取随用。从连接池获取连接虽然很快,但连接池的初始化会比较慢,需要做一些管理模块的初始化以及初始最小闲置连接。一旦连接池不是复用的,那么其性能会比随时创建单一连接更差。
  • 连接池参数配置中,最重要的是最大连接数,许多高并发应用往往因为最大连接数不够导致性能问题。但,最大连接数不是设置得越大越好,够用就好。
  • 针对数据库连接池、HTTP 连接池、Redis 连接池等重要连接池,务必建立完善的监控和报警机制,根据容量规划及时调整参数配置。

以上是关于Day626.连接池使用注意事项 -Java业务开发常见错误的主要内容,如果未能解决你的问题,请参考以下文章

day18-事务与连接池 1.复习

day16 事务 - 数据库连接池 - 编写自己的jdbc框架

day13_Mysql事物与数据库连接池学习笔记

day24——NoSQL简介redis服务搭建redis连接池redis管道

使用HikariCP连接池常用配置讲解及注意事项

如何处理django的数据库连接池