连接池:别让连接池帮了倒忙

image

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

注意鉴别客户端 SDK 是否基于连接池

在使用三方客户端进行网络通信时,我们首先要确定客户端 SDK 是否是基于连接池技术实现的。我们知道,TCP 是面向连接的基于字节流的协议:

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

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

我们先看一下涉及 TCP 连接的客户端 SDK,对外提供 API 的三种方式。在面对各种三方客户端的时候,只有先识别出其属于哪一种,才能理清楚使用方式。

  • 连接池和连接分离的 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 都会支持连接池),那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自己封装一个连接池。

分析 Jedis

接下来,我就以 Java 中用于操作 Redis 最常见的库 Jedis 为例,从源码角度分析下 Jedis 类到底属于哪种类型的 API,直接在多线程环境下复用一个连接会产生什么问题,以及如何用最佳实践来修复这个问题。

首先,向 Redis 初始化 2 组数据,Key=a、Value=1,Key=b、Value=2,然后,启动两个线程,共享操作同一个 Jedis 实例,每一个线程循环 1000 次,分别读取 Key 为 a 和 b 的 Value,判断是否分别为 1 和 2,执行程序多次,可以看到日志中出现了各种奇怪的异常信息,有的是读取 Key 为 b 的 Value 读取到了 1,有的是流非正常结束,还有的是连接关闭异常;

可以看到,Jedis 继承了 BinaryJedis,BinaryJedis 中保存了单个 Client 的实例,Client 最终继承了 Connection,Connection 中保存了单个 Socket 的实例,和 Socket 对应的两个读写流。因此,一个 Jedis 对应一个 Socket 连接。类图如下:

image

BinaryClient 封装了各种 Redis 命令,其最终会调用基类 Connection 的方法,使用 Protocol 类发送命令。看一下 Protocol 类的 sendCommand 方法的源码,可以发现其发送命令时是直接操作 RedisOutputStream 写入字节。

我们在多线程环境下复用 Jedis 对象,其实就是在复用 RedisOutputStream。如果多个线程在执行操作,那么既无法确保整条命令以一个原子操作写入 Socket,也无法确保写入后、读取前没有其他数据写到远端:

看到这里我们也可以理解了,为啥多线程情况下使用 Jedis 对象操作 Redis 会出现各种奇怪的问题。

比如,写操作互相干扰,多条命令相互穿插的话,必然不是合法的 Redis 命令,那么 Redis 会关闭客户端连接,导致连接断开;又比如,线程 1 和 2 先后写入了 get a 和 get b 操作的请求,Redis 也返回了值 1 和 2,但是线程 2 先读取了数据 1 就会出现数据错乱的问题。

修复方式是,使用 Jedis 提供的另一个线程安全的类 JedisPool 来获得 Jedis 的实例。

JedisPool 可以声明为 static 在多个线程之间共享,扮演连接池的角色。使用时,按需使用 try-with-resources 模式从 JedisPool 获得和归还 Jedis 实例。

JedisPool 的 getResource 方法在拿到 Jedis 对象后,将自己设置为了连接池。连接池 JedisPool,继承了 JedisPoolAbstract,而后者继承了抽象类 Pool,Pool 内部维护了 Apache Common 的通用池 GenericObjectPool。JedisPool 的连接池就是基于 GenericObjectPool 的。

看到这里我们了解了,Jedis 的 API 实现是我们说的三种类型中的第一种,也就是连接池和连接分离的 API,JedisPool 是线程安全的连接池,Jedis 是非线程安全的单一连接。知道了原理之后,我们再使用 Jedis 就胸有成竹了。

使用连接池务必确保复用

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

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

连接池的配置不是一成不变的

为方便根据容量规划设置连接处的属性,连接池提供了许多参数,包括最小(闲置)连接、最大连接、闲置连接生存时间、连接生存时间等。其中,最重要的参数是最大连接数,它决定了连接池能使用的连接数量上限,达到上限后,新来的请求需要等待其他请求释放连接。

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

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

其实,看到错误日志后再调整已经有点儿晚了。更合适的做法是,对类似数据库连接池的重要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容。

这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、是否合理。之所以要“强调”,是因为这里有坑。

我之前就遇到过这样一个事故。应用准备针对大促活动进行扩容,把数据库配置文件中 Druid 连接池最大连接数 maxActive 从 50 提高到了 100,修改后并没有通过监控验证,结果大促当天应用因为连接池连接数不够爆了。

经排查发现,当时修改的连接数并没有生效。原因是,应用虽然一开始使用的是 Druid 连接池,但后来框架升级了,把连接池替换为了 Hikari 实现,原来的那些配置其实都是无效的,修改后的参数配置当然也不会生效。

所以说,对连接池进行调参,一定要眼见为实。

重点回顾

今天,我以三种业务代码最常用的 Redis 连接池、HTTP 连接池、数据库连接池为例,和你探讨了有关连接池实现方式、使用姿势和参数配置的三大问题。

客户端 SDK 实现连接池的方式,包括池和连接分离、内部带有连接池和非连接池三种。要正确使用连接池,就必须首先鉴别连接池的实现方式。比如,Jedis 的 API 实现的是池和连接分离的方式,而 Apache HttpClient 是内置连接池的 API。

对于使用姿势其实就是两点,一是确保连接池是复用的,二是尽可能在程序退出之前显式关闭连接池释放资源。连接池设计的初衷就是为了保持一定量的连接,这样连接可以随取随用。从连接池获取连接虽然很快,但连接池的初始化会比较慢,需要做一些管理模块的初始化以及初始最小闲置连接。一旦连接池不是复用的,那么其性能会比随时创建单一连接更差。

最后,连接池参数配置中,最重要的是最大连接数,许多高并发应用往往因为最大连接数不够导致性能问题。但,最大连接数不是设置得越大越好,够用就好。需要注意的是,针对数据库连接池、HTTP 连接池、Redis 连接池等重要连接池,务必建立完善的监控和报警机制,根据容量规划及时调整参数配置。

HTTP 和 TCP 协议

HTTP 连接和 TCP 连接不是同一个东西,但它们密切相关。

TCP(传输控制协议)是一种网络传输层协议,它负责在两台计算机之间提供可靠、有序的数据流服务。TCP 通过建立连接、数据分段、确认、重传机制以及流量控制来确保数据的完整性和顺序到达。

而 HTTP(超文本传输协议)是位于应用层上的协议,主要用于 Web 浏览器和 Web 服务器之间的通信,以传输网页内容和其他相关资源。HTTP 协议定义了客户端如何向服务器请求网页以及服务器如何响应这些请求的方式。

当我们在谈论“HTTP 连接”时,通常是指为了进行 HTTP 通信而使用的一个或多个 TCP 连接。具体来说,每次浏览器发起一个 HTTP 请求时,都会先建立一个 TCP 连接到服务器,然后通过这个 TCP 连接发送 HTTP 请求报文,并接收 HTTP 响应报文。在 HTTP/1.0 中,每个 HTTP 请求默认都会创建一个新的 TCP 连接,且在请求完成后关闭连接;而在 HTTP/1.1 中引入了持久连接(Keep-Alive),允许在一个 TCP 连接上处理多个连续的 HTTP 请求,从而提高了效率。

总结一下:HTTP 连接是在 TCP 连接的基础上实现的应用层逻辑概念,用于交换 HTTP 消息;而 TCP 连接则是实际的底层网络连接,为上层的 HTTP 协议提供传输服务。

  • 连接池
    7 引用 • 15 回帖
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3168 引用 • 8207 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...