数据库连接池设置

6,144 阅读20分钟

数据库连接池

几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解为什么需要连接池,连接池的实现原理,系统架构和性能目标对于写出正确、高效的程序很有帮助。这些概念可用于系统运行参数的配置,同时对于理解并发和分布式处理也很有帮助。

通用一点来说,一个有经验的工程师面对任何问题都会试着回答三个问题:为什么,是什么,怎么做。了解为什么可以明白问题的真正目的,有助于开放思路和避免无用功。是什么则回答问题的本质概念,是正确答案的保障。怎么做则给出可重复的问题解决思路,使得问题总是以正确、高效的方式得到解决。本篇文章按这个思路来解决数据库连接池如何配置的问题。

1 为什么需要连接池

任何数据库的访问都需要首先建立数据库连接。这是一个复杂、缓慢的处理。牵涉到通信建立(包括 TCP 的三次握手)、认证、授权、资源的初始化和分配等一系列任务。而且数据库服务器通常和应用服务器是分开的,所有的操作都是分布式网络请求和处理。建立数据库连接时间通常在 100ms 或更长。而通常小数据的 CRUD 数据库操作是 ms 级或更短,加上网络延迟一般 10 到 50 个 ms 就可以完成多数数据库处理结果。在应用启动时预先建立一些数据库连接,应用程序使用已有的连接可以极大提高响应速度。另外,Web 服务应用当客户很多时,有很多线程,连接数目过多以及频繁创建/删除连接也会影响数据库的性能。

总结起来,采用数据库连接有如下好处:

  • 节省了创建数据库连接的时间,通常这个时间大大超过处理数据访问请求的时间。
  • 统一管理数据库请求连接,避免了过多连接或频繁创建/删除连接带来的性能问题。
  • 监控了数据库连接的运行状态和错误报告,减少了应用服务的这部分代码。
  • 可以检查和报告不关闭数据库连接的错误,帮助运维监测数据库访问阻塞和帮助程序员写出正确数据库访问代码。

2 数据库连接池是什么

2.1 实现原理

如同多数分布式基础构件,连接池的原理比较简单,但是牵涉到数据库,操作系统,编程语言,运维以及应用场景的不同特点,具体实现比较复杂。从数据库诞生就有的广泛需求,半个世纪后还有不断改进提高的余地。

原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的资源数据结构,称为连接池。这是典型的生产者-消费者并发模型。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。

连接池的实际应用中,最担心的问题就是借了不还的这种让其他人无资源可用的人品问题。编码逻辑错误或者释放连接放代码没有放到 finally 部分都会导致连接池资源枯竭从而造成系统变慢甚至完全阻塞的情况。这种情况类似于内存泄露,因而也叫连接泄露,是常常发生而且难以发现的问题。因此检测连接泄露并报警是线程池实现的基本需要。

连接在被使用时运行在借用它的线程里面,并不是运行在新的线程里面。但是因为每个连接在使用中要实现超时 timeout 机制,官方的 Java.sql.Connection.setNetworkTimeout API的接口定义是 setNetworkTimeoutExecutor executor, int milliseconds)。此处需要指定一个线程池来处理超时的错误报告。也就是每一个连接运行数据库访问时,都会有一个后台线程监控响应超时状态。很多连接池实现会使用 Cached Thread Pool 或 Fixed Thread Pool。Chached Thread Pool 没有线程数目限制,动态创建和回收,适合很多动态的短小请求应用。Fixed Thread Pool 则适合比较固定的连接请求。

另外,网络故障和具体数据库实现的限制会使得连接池的连接失效。比如,MySQL 允许一个连接,无论状态正常与否,都不能超过 8 个小时的生命。因此,虽然连接在被使用时运行在调用的线程里面,但是连接池的管理通常需要一个或多个后台线程来管理、维护、和检测连接池的连接状态,保证有指定数目的连接可用。

可以看的,虽然数据库连接在执行数据库访问使用调用者的线程,但是连接池的实现通常需要二个或更多的线程池做管理和超时处理。当然连接池的具体实现还要考虑很多细节,但是不直接影响应用接口,放在文章结尾再讨论。

2.2 数据库连接池的系统架构

连接池的本质是属于一个操作系统进程(process)的计数信号量(counting Semaphore),用于控制可以并行使用数据库连接的线程数量。在 Java SDK 有一个Semaphore Class可以用来管理各种有限数量的资源。连接池的核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。

连接池的使用者是业务应用程序。通常有二种:一种是基于用户/服务请求的 HTTP 服务线程,通常采用线程池。特点是线程数目动态变化很大,数据库的访问模式比较多样,处理时间也有长有短,可能有很大差别。另一种是后台服务,其线程数目比较固定,数据库访问模式和处理时间也比较稳定。

连接池只是给业务应用提供已建立的连接,所有的访问请求都通过连接转发到后台数据库服务器。数据库服务器通常也采用线程(PostgreSQL 每个连对应一个进程)池处理所有的访问请求。

具体来说,连接池是两个线程池的中间通道。可以看成下面的结构:

一个或多个应用服务进程里面(线程池 <-> 数据库连接池) <===============> 一个数据库服务器线程(或进程)池

上图中,连接池和应用服务线线程池在同一个进程里面。每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。

3 如何配置数据库连接池

3.1 配置目标

当提到数据库连接池的配置,一个常见也是严重的错误是把连接池和线程池的概念混淆了。如上面系统架构所示,数据库连接池并不控制应用端和数据库端的线程池的大小。而且每个数据库连接池的配置只是针对自己所在的应用服务进程,限制的是同一个进程内可以访问数据库的并行线程数目。应用服务进程单独管理自己的线程池,除了数据库访问还有处理其他业务逻辑,并行的线程数目基本取决于服务的负载。当应用服务线程需要访问数据库时,其并发度和阻塞数目才受到连接池尺寸的影响。

做为应用服务和数据库的桥梁,连接池参数配置的目标是全局优化。具体的优化目的有四个:尽可能满足应用服务的并发数据库访问,不让数据库服务器过载,能发现用了不还造成的死锁,不浪费系统资源。

尽可能满足所有的应用服务并发数据库访问的意思很简单:所有需要访问数据库的线程都可以得到需要的数据库连接。如果一个线程用到多个连接,那么需要的连接数目也会成倍增加。这时,需要的连接池最大尺寸应该是最大的并发数据库访问线程数目乘以每个线程需要的连接数目。

不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。按照 PostgreSQL V11 文档18.4.3. Resource Limits,每个连接都由一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,信号(semaphore), 文件/网络句柄(handler),队列等各种系统资源。这篇文章Number Of Database Connections 讨论了 PostgreSQL V9.2 的连接数目。给出的建议公式是 ((core_count * 2) + effective_spindle_count),也就是 CPU 核数的二倍加上硬盘轴数。MySQL 采用了不同的服务架构,MySQL Too many connections给出的缺省连接数目为 151。这二个系统从具体实现机理、计算办法和建议数值都有很大差别,做为应用程序员应该有基本的理解。

这个OLTP performance -- Concurrent Mid-tier connections视频用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。

能发现用了不还造成的阻塞也是选择连接池实现的基本需求。应用程序错误会造成借了不还的情况,反复出现会造成连接池用完应用长期等待甚至死锁的状态。需要有连接借用的超时报错机制,而这个超时时间取决于具体应用。

不浪费系统资源是指配置过大的连接池会浪费应用服务器的系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端连接池的开销不是很大,资源的浪费通常不是太大问题。

3.2 配置方法

概念清楚,目标明确之后,配置方法就比较容易了。连接池需要考虑二种约束:二端线程(进程)池尺寸约束和应用吞吐量约束。综合考虑二种方法的结果会是比较合理的。

二端约束: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。

应用负载约束: 考虑应用服务的负载性质。应用服务可以分成二类。一类是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。另一类是像邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。这二类应用都可以按照数据库访问的复杂度和响应时间进行估算。这里用到Little's Law并发量 = 每秒请求数量 * 数据库请求响应时间。注意:这里的请求响应时间包括网络时间+数据库访问时间。很多时候网络时间大于数据库访问时间。如果一个应用线程有多个数据库访问请求,尤其是有事物处理的时候,这个数据库请求响应时间其实是持有连接的时间,公式变为:并发量(连接数): 每秒请求数 (QPS)* 数据库连接持有时间

如果每秒有 100 个数据库访问请求,每个数据库访问请求需要 20ms,那么并行量是 100 * 0.02 = 2,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。

仅仅考虑二端线程(进程)池的尺寸会配置过大的连接池,因为这是系统的上限。由于数据库访问仅仅是应用线程的一部分工作。 原因是在线程数目计算公式里:线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间), 数据库的等待时间只是线程所有操作的等待时间的一部分。

仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的使用文档。

3.3 一个表面相关,其实无关的计算公式

因为连接池和线程池经常被混淆,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自每个 Java 程序员都应该阅读的Java Concurrency in Practice。在原著 8.2 节, 第 171 页作者给出了著名的线程数目计算公式:线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。计算进程的 CPU 使用率给出了具体的技术方式和 Script。可是这个公式可以用于应用服务线程池或任何线程池的尺寸估算,但是与数据库连接池的大小估算无关。因为进程池并不能控制应用服务的线程数目,它控制的是可并发的数据库访问线程数目。这些线程使用数据库连接完成网络服务和远程数据库的异步操作,此时基本没有使用本机的 CPU 计算时间。套用公式会得出非常大的数字,没有实际意义。

4 Spring + MySQL 的应用的连接池配置

如上所述,配置 Spring 连接池首先要考虑到其使用的 HTTP 服务的线程池配置和后端数据库服务器的连接数配置。其次是应用的特点。

4.1 应用服务的线程数

Spring 的 server.tomcat.max-threads 参数给出了最大的并行线程数目,缺省值是 200. 由于才有特殊处理,这些线程可以处理的更大的 HTTP 连接数目 server.tomcat.max-connections,缺省值是 10000. spring.task.execution.pool.max-threads则控制使用@Async的最大线程数目, 缺省值没有限制。最好按应用特点配置一个范围。

4.2 数据库方面的连接数

MySQL 数据库用max_connections环境变量设置最大连接数,缺省值是 151. 多数建议都是根据内存大小或应用负载来设置这个值。

4.3 基本参数设置

Spring 缺省使用HikariCP

需要配置的基本参数如下。

  • maximumPoolSize: 最大的连接数目。超过这个数目,新的数据库访问线程会被阻塞。缺省值是 10。
  • minimumIdle: 最小的连接数目。缺省值是最大连接数目。
  • leakDetectionThreshold: 未返回连接报警时间。缺省值是 0,不启用。这个值如果大于 0,如果一个连接被使用的时间超过这个值则会日志报警(warn 级别的 log 信息)。考虑到网络负载情况,可以设置为最大数据库请求时长的 3 倍或 5 倍。如果没有这个报警,程序的正确性很难保证。
  • maxLifetime:最大的连接生命时间。缺省值是 30 分钟。官方文档建议设置这个值为稍小于数据库的最大连接生命时间。MySQL 的缺省值为 8 小时。可以设置为 7 小时 59 分钟以避免每半个小时重建一次连接。

4.4 针对数据库的优化设置

HikariCP 建议的 MySQL 配置参数和建议值如下,这些配置有助于提高数据库访问的性能.这些参数的缺省值在MySQL JDBC 文档

  • prepStmtCacheSize: 250-500. Default: 25.
  • prepStmtCacheSqlLimit: 2048. Default: 256.
  • cachePrepStmts: true. Default: false.
  • useServerPrepStmts: true. Default: false.

4.5 针对业务未来发展预测的调整

目前为止,我们的所有计算、配置都是根据现有软硬件配置、业务规模来进行计算的,然后业务是不断发展的,所以,对于业务的未来发展,我们也应该有个相应的考虑。如,假设一年之后,我们的业务量增长了一倍,那么我们相应的,可以将我们的固定计算扩大一倍(前提是未到机器上限)。这部分与业务预估息息相关,最好的建议是一开始就按照机器的上限来进行配置,并实时监控各方面的业务性能,待现有机器支撑不住时,可及时水平扩展。(这里如果使用了动态伸缩方面技术的话,便大可放心,不用手动调整了)。

5 其他

5.1 连接池其他实现细节

具体的连接池实现需要考虑很多应用细节。

  • 数据库连接的使用还牵涉到事物处理,Spring 的同步数据库访问采用 ThreadLocal 保存事物处理相关状态。所以连接池执行数据库访问时必须在调用者的线程,不能运行在新的线程。Spring 异步数据库访问则可以跨线程。
  • 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。
  • 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。
  • 连接池需要定期检查数据库的可用状态甚至响应时间,及时报告健康状态。HikariCP Dropwizard HealthChecks是一个例子。
  • 当需要为新线程访问创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档Welcome to the Jungle 描述了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。Hikari 用 5 个连接处理了 50 个突发的数据库短时访问请求,即提高了响应速度,也避免了创建额外的连接。
  • 数据库各种异常的处理。Bad Behavior: Handling Database Down 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多连接池构件不能正确处理。
  • 线程池的性能监视。HikariCP Dropwizard Metrics 给出了监视的性能指标。
  • 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。Down the Rabbit Hole给出了 Java 里的优化方法。坏处是里面有些优化过于琐碎,使得代码晦涩难懂而且需要额外维护工作。

5.2 一些参考缺省配置

HikariCP: DEFAULT_POOL_SIZE = 10

DBCP: Max pool size : 8

c3p0: MIN_POOL_SIZE = 3, MAX_POOL_SIZE = 15

JIRA Tuning database connections:pool-max-size = 20. 和前三个不同,这是一个数据库应用程序。里面讨论了数据库的连接数目,提到一方面数据库可以支持数百并行连接,另一方面应用服务端的连接还是比较耗费资源,建议在允许的情况下尽可能设成小的数字。

5.3 题外话

网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的开销主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了Runtime.getRuntime().availableProcessors()数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费(在连接数小于 CPU 核数时),也有最好的性能(在连接数超过 CPU 核数时)。参考这个 Issue:Change the thread pool size to minimumIdle on blocked initialization。 这种错误并不奇怪,因为 HikariCP 的代码风格比较糟糕。很多广泛使用的开源软件其实代码质量并不高,每个人都应该搞清楚概念和问题的本质,多理解其他人的想法,但是保持怀疑态度和独立思考能力。