池化技术:如何减少频繁创建数据库连接的性能损耗?

一天,公司 CEO 将你叫到会议室,向你展示了一个新出现的商业机会,希望你能带领一位同事,快速推出一款面向某垂直领域的电商系统。面对有限的人手和紧迫的时间,你毫不犹豫地决定采用最简化的架构方案:前端用一台 Web 服务器负责处理所有业务逻辑,后端用一台数据库服务器来存储业务数据。这种极简架构虽然不具备太多扩展性,但在现有资源条件下,能够以最快速度实现核心业务功能,为公司抢占市场窗口期提供支持。

你带领小团队迅速上线了电商系统,凭借简洁的架构,系统运行稳定,虽然用户量不大,但能支持核心业务功能,整体表现良好。你为此感到颇有成就。然而,公司 CEO 对用户增长的速度并不满意,决定加大推广力度。他紧急调集运营团队,在全网开展了一次强有力的流量推广活动,力求迅速吸引大量用户。

这一推广很快带来了一大波流量,但这时,系统的访问速度开始变慢。

分析程序的日志之后,你发现系统慢的原因出现在和数据库的交互上。因为你们数据库的调用方式是先获取数据库的连接,然后依靠这条连接从数据库中查询数据,最后关闭连接释放数据库资源。这种调用方式下,每次执行 SQL 都需要重新建立连接,所以你怀疑,是不是频繁地建立数据库连接耗费时间长导致了访问慢的问题。

那么为什么频繁创建连接会造成响应时间慢呢?来看一个实际的测试。

我使用了命令 tcpdump -i bond0 -nn -tttt port 4490 来抓取线上 MySQL 连接的网络数据包,以分析其建立连接的过程。根据抓包结果,整个连接过程可以分为两部分:

第一部分是 TCP 的三次握手过程,包括前三个数据包。第一个数据包是客户端向服务端发送的“SYN”包,接着服务端返回“ACK”和“SYN”包,最后客户端再发送“ACK”包来确认。熟悉 TCP 协议的人可以看出,这就是标准的三次握手过程。

第二部分是 MySQL 服务端对客户端进行身份验证的过程。首先,服务端发送一个要求认证的报文,然后客户端回传加密后的密码,服务端接收并返回一个“认证 OK”的报文确认。

从抓包数据来看,整个连接过程大约耗时 4 毫秒(969012 – 964904)。相比之下,单条 SQL 的平均执行时间仅为 1 毫秒。也就是说,MySQL 建立连接的时间在 SQL 执行时间上占据了相对较大的比重。虽然在请求量较小的情况下,这种差距并不会带来太大影响,因为建立连接和执行 SQL 都在毫秒级别,但在请求量上升时就会成为瓶颈。如果每次连接只执行一条 SQL,那么每秒只能执行约 200 次查询,其中 4/5 的时间都被消耗在建立连接上。

那这时你要怎么做呢?

一番谷歌搜索之后,你发现解决方案也很简单,只要使用连接池将数据库连接预先建立好,这样在使用的时候就不需要频繁地创建连接了。调整之后,你发现 1s 就可以执行 1000 次的数据库查询,查询性能大大提升了。

用连接池预先建立数据库连接

虽然暂时解决了问题,但你仍然想彻底理解核心原理,于是开始深入研究。其实,在开发中我们经常会用到各种连接池,比如数据库连接池、HTTP 连接池、Redis 连接池等。而连接池的核心就在于如何管理连接。我以数据库连接池为例来说明连接池管理的关键点。

数据库连接池的两个最重要的配置参数是最小连接数最大连接数,它们决定了获取连接的流程:

  1. 如果当前连接数小于最小连接数,则会创建新的连接来处理数据库请求;
  2. 如果连接池中有空闲连接,则直接复用这些空闲连接;
  3. 若空闲池中没有连接且当前连接数小于最大连接数,则创建新的连接;
  4. 如果当前连接数已达到最大值,就会按照配置中的等待时间(例如 C3P0 连接池的 checkoutTimeout 配置)等待现有连接释放;
  5. 如果超过等待时间依然没有可用连接,则抛出超时错误。

这个流程不需要死记,逻辑简单易懂。你可以思考一下,如果你是连接池的设计者,会如何设计这个流程?有哪些关键点?这种设计思路在后续的架构设计中也会频繁用到。

为了方便你理解记忆这个流程,我来举个例子。

假设你在机场经营一家按摩椅小店,店里摆放了 10 台按摩椅(类似于数据库连接池的最大连接数)。为了节省成本,你通常只开 4 台按摩椅(相当于最小连接数),其余 6 台平时关着。

当顾客到来时,如果这 4 台中的某一台空着,你直接安排顾客使用这台空椅。如果 4 台都在使用,你就会启动另外一台,直到全部 10 台椅子都被占用。

那么,当所有按摩椅都被占满,而新的顾客到来时怎么办?你会请顾客稍等,承诺 5 分钟内(类似连接池的等待时间)会有一台椅子空出。于是,这位顾客开始等待。接下来可能有两种情况:如果 5 分钟内有椅子空出,顾客就可以直接使用。但如果等了 5 分钟仍没有空椅,只能抱歉地请顾客到别处看看。

至于数据库连接池设置,根据我的经验,线上环境下建议最小连接数设为 10 左右,最大连接数设在 20~30 左右即可。

在这里,你还需要关注连接池中的连接维护问题,即前面提到的“按摩椅”状况。有些椅子虽然开着,但可能会出现故障。一般来说,“按摩椅故障”的原因有以下几种:

  1. 数据库的域名对应的 IP 发生了变更,而连接池中仍然使用旧的 IP 地址。当旧 IP 下的数据库服务关闭后,再次使用这些连接就会出错。
  2. MySQL 中的参数 wait_timeout 控制了数据库连接闲置的最大时长。一旦连接超过该时间未被使用,数据库会主动关闭连接。而连接池对这个关闭动作是无感知的,所以再次使用这个连接时会发生错误。

作为按摩椅店的老板,你如何确保每台开着的椅子都可以正常工作呢?

  1. 你可以启动一个线程定期检测连接池中的连接是否可用。比如,向数据库发送 select 1 命令测试连接是否抛出异常。如果发现异常,就从连接池中移除该连接并尝试关闭它。C3P0 连接池支持这种检测方式,我也比较推荐这种方案。
  2. 另外一种方法是在获取连接后,先验证其是否可用,然后再执行 SQL 操作。比如在 DBCP 连接池中,可以通过 testOnBorrow 配置项来控制是否启用这种验证方式。不过,这会在获取连接时引入一些额外开销,因此在线上系统中不建议开启,但在测试环境中可以使用。

至此,你终于彻底了解了连接池的工作原理。然而,当你刚松一口气,CEO 又提出了新的需求。你分析需求后发现,在一个关键接口中需要访问数据库三次。根据经验判断,这里很可能会成为系统的瓶颈。

进一步考虑后,你决定创建多个线程来并行处理与数据库的交互,这样可以提升速度。然而,回顾上次的数据库连接问题,你想到高并发情况下频繁创建线程的开销同样不小。于是,你顺理成章地想到了线程池。

用线程池预先创建线程

果然,JDK 1.5 引入的 ThreadPoolExecutor 就是线程池的实现之一,它的两个关键参数 coreThreadCount maxThreadCount 控制着线程池的执行流程。它的工作原理类似于前面说的按摩椅店模式,下面具体说明下,帮助加深理解:

当线程池中的线程数少于 coreThreadCount 时,系统会为每个新任务创建一个线程;当线程数达到 coreThreadCount 时,新的任务会被放入一个队列,由空闲线程逐步处理;如果任务队列堆满了,而线程数仍然低于 maxThreadCount ,则继续创建新的线程来处理任务,直到达到 maxThreadCount 。一旦线程数已达到 maxThreadCount 且有新任务提交,则不得不丢弃这些任务。

这个任务处理流程看似简单,实际上有许多细节需要关注,使用时需格外小心。

首先,JDK 提供的线程池优先将任务放入队列,而不是立即创建新线程。这样的设计更适合 CPU 密集型任务,即主要消耗 CPU 资源的任务。这是因为在 CPU 密集型任务中,CPU 会比较繁忙,通常只需创建与 CPU 核数相当的线程。过多线程反而会导致频繁的上下文切换,降低执行效率。因此,在线程数超过核心线程数时,JDK 线程池会将新任务放入队列中等待核心线程空闲,而不是增加线程数。

但在实际开发中,Web 系统通常包含大量 I/O 操作,比如数据库查询和缓存查询等。当任务执行 I/O 操作时,CPU 会处于空闲状态,这时如果增加线程数而不是让任务在队列中等待,就能在单位时间内完成更多任务,从而提升系统吞吐量。因此,Tomcat 的线程池并未使用 JDK 原生的实现,而是经过改造,当线程数超过 coreThreadCount 后优先增加线程数,直到达到 maxThreadCount ,这种方式更适合 Web 系统中大量 I/O 操作的场景。实际使用时,可以参考这种设计思路。

另外,线程池中的任务队列积压情况也是需要监控的关键指标,尤其对实时性要求高的任务来说更为重要。曾经我在项目中遇到过任务丢给线程池后长时间未执行的问题,起初以为是代码的 bug,但最终发现是因为 coreThreadCount maxThreadCount 设定过小,导致大量任务积压在队列中。调整了这两个参数后问题得到解决。从此,我把重要线程池的任务堆积量作为系统监控中的一项重要指标。

最后,使用线程池时一定要避免无界队列(即不设置队列大小)。尽管无界队列看似可以防止任务丢弃,但堆积的任务会占用大量内存资源,一旦内存被大量占用,就可能触发频繁的 Full GC,导致服务不可用。我曾排查过的一次因 GC 引起的系统宕机,正是因为系统中某线程池使用了无界队列所导致的。

回顾这两种技术,你会发现它们都有一个共同点:无论是连接还是线程,其创建过程都相对耗时,并且消耗系统资源。因此,将这些对象放在一个池中进行统一管理,可以提升性能并实现资源复用。这种方法是一种常见的软件设计思路,称为池化技术。它的核心思想是“空间换时间”,通过使用预先创建的对象来减少频繁创建带来的性能开销,还能对对象进行集中管理,降低使用成本,带来了诸多好处。

不过,池化技术也存在一些缺点。首先,池中的对象会占用额外的内存资源,若对象使用不频繁,可能导致内存浪费。此外,池中的对象需要在系统启动时预先创建,这在一定程度上会延长系统的启动时间。不过,与池化技术的优势相比,这些缺点通常可以忽略不计。只要我们确定对象的创建确实耗时或资源密集,并且对象会频繁创建和销毁,就可以采用池化技术来优化系统性能。

6