池化技术(二)HikariCP是如何管理数据库连接的?
基于依赖程序的版本信息:
HikariCP v3.3.1
mysql-connector-java v8.0.17
姊妹篇:Druid是如何管理数据库连接的
零、类图和流程图
开始前先来了解下HikariCP获取一个连接时类间的交互流程,方便下面详细流程的阅读。
获取连接时的类间交互:
一、主流程1:获取连接流程
HikariCP获取连接时的入口是HikariDataSource
里的getConnection
方法,现在来看下该方法的具体流程:
上述为HikariCP获取连接时的流程图,由图1可知,每个datasource
对象里都会持有一个HikariPool
对象,记为pool,初始化后的datasource对象pool是空的,所以第一次getConnection
的时候会进行实例化pool
属性(参考主流程1
),初始化的时候需要将当前datasource里的config属性
传过去,用于pool的初始化,最终标记sealed
,然后根据pool对象调用getConnection
方法(参考流程1.1
),获取成功后返回连接对象。
二、主流程2:初始化池对象
该流程用于初始化整个连接池
,这个流程会给连接池内所有的属性做初始化的工作,其中比较主要的几个流程上图已经指出,简单概括一下:
- 利用
config
初始化各种连接池属性,并且产生一个用于生产物理连接
的数据源DriverDataSource
- 初始化存放连接对象的核心类
connectionBag
- 初始化一个延时任务线程池类型的对象
houseKeepingExecutorService
,用于后续执行一些延时/定时类任务(比如连接泄漏检查延时任务,参考流程2.2
以及主流程4
,除此之外maxLifeTime
后主动回收关闭连接也是交由该对象来执行的,这个过程可以参考主流程3
) - 预热连接池,HikariCP会在该流程的
checkFailFast
里初始化好一个连接对象放进池子内,当然触发该流程得保证initializationTimeout > 0
时(默认值1),这个配置属性表示留给预热操作的时间(默认值1在预热失败时不会发生重试)。与Druid
通过initialSize
控制预热连接对象数不一样的是,HikariCP仅预热进池一个连接对象。 - 初始化一个线程池对象
addConnectionExecutor
,用于后续扩充连接对象 - 初始化一个线程池对象
closeConnectionExecutor
,用于关闭一些连接对象,怎么触发关闭任务呢?可以参考流程1.1.2
三、流程1.1:通过HikariPool获取连接对象
从最开始的结构图可知,每个HikariPool
里都维护一个ConcurrentBag
对象,用于存放连接对象,由上图可以看到,实际上HikariPool
的getConnection
就是从ConcurrentBag
里获取连接的(调用其borrow
方法获得,对应ConnectionBag主流程
),在长连接检查这块,与之前说的Druid
不同,这里的长连接判活检查在连接对象没有被标记为“已丢弃
”时,只要距离上次使用超过500ms
每次取出都会进行检查(500ms是默认值,可通过配置com.zaxxer.hikari.aliveBypassWindowMs
的系统参数来控制),emmmm,也就是说HikariCP
对长连接的活性检查很频繁,但是其并发性能依旧优于Druid
,说明频繁的长连接检查并不是导致连接池性能高低的关键所在。
这个其实是由于HikariCP的无锁
实现,在高并发时对CPU的负载没有其他连接池那么高而产生的并发性能差异,后面会说HikariCP的具体做法,即使是Druid
,在获取连接
、生成连接
、归还连接
时都进行了锁控制
,因为通过上篇解析Druid
的文章可以知道,Druid
里的连接池资源是多线程共享的,不可避免的会有锁竞争,有锁竞争意味着线程状态的变化会很频繁,线程状态变化频繁意味着CPU上下文切换也将会很频繁。
回到流程1.1
,如果拿到的连接为空,直接报错,不为空则进行相应的检查,如果检查通过,则包装成ConnectionProxy
对象返回给业务方,不通过则调用closeConnection
方法关闭连接(对应流程1.1.2
,该流程会触发ConcurrentBag
的remove
方法丢弃该连接,然后把实际的驱动连接交给closeConnectionExecutor
线程池,异步关闭驱动连接)。
四、流程1.1.1:连接判活
承接上面的流程1.1
里的判活流程,来看下判活是如何做的,首先说验证方法(注意这里该方法接受的这个connection
对象不是poolEntry
,而是poolEntry
持有的实际驱动的连接对象),在之前介绍Druid的时候就知道,Druid是根据驱动程序里是否存在ping方法
来判断是否启用ping的方式判断连接是否存活,但是到了HikariCP则更加简单粗暴,仅根据是否配置了connectionTestQuery
觉定是否启用ping:
1 | this.isUseJdbc4Validation = config.getConnectionTestQuery() == null; |
所以一般驱动如果不是特别低的版本,不建议配置该项,否则便会走createStatement+excute
的方式,相比ping
简单发送心跳数据,这种方式显然更低效。
此外,这里在刚进来还会通过驱动的连接对象重新给它设置一遍networkTimeout
的值,使之变成validationTimeout
,表示一次验证的超时时间,为啥这里要重新设置这个属性呢?因为在使用ping方法校验时,是没办法通过类似statement
那样可以setQueryTimeout
的,所以只能由网络通信的超时时间来控制,这个时间可以通过jdbc
的连接参数socketTimeout
来控制:
1 | jdbc:mysql://127.0.0.1:3306/xxx?socketTimeout=250 |
这个值最终会被赋值给HikariCP的networkTimeout
字段,这就是为什么最后那一步使用这个字段来还原驱动连接超时属性的原因;说到这里,最后那里为啥要再次还原呢?这就很容易理解了,因为验证结束了,连接对象还存活的情况下,它的networkTimeout
的值这时仍然等于validationTimeout
(不合预期),显然在拿出去用之前,需要恢复成本来的值,也就是HikariCP里的networkTimeout
属性。
五、流程1.1.2:关闭连接对象
这个流程简单来说就是把流程1.1.1
中验证不通过的死连接,主动关闭的一个流程,首先会把这个连接对象从ConnectionBag
里移除
,然后把实际的物理连接交给一个线程池去异步执行,这个线程池就是在主流程2
里初始化池的时候初始化的线程池closeConnectionExecutor
,然后异步任务内开始实际的关连接操作,因为主动关闭了一个连接相当于少了一个连接,所以还会触发一次扩充连接池(参考主流程5
)操作。
六、流程2.1:HikariCP监控设置
不同于Druid那样监控指标那么多,HikariCP会把我们非常关心的几项指标暴露给我们,比如当前连接池内闲置连接数、总连接数、一个连接被用了多久归还、创建一个物理连接花费多久等,HikariCP的连接池的监控我们这一节专门详细的分解一下,首先找到HikariCP下面的metrics
文件夹,这下面放置了一些规范实现的监控接口等,还有一些现成的实现(比如HikariCP自带对prometheus
、micrometer
、dropwizard
的支持,不太了解后面两个,prometheus
下文直接称为普罗米修斯
):
下面,来着重看下接口的定义:
1 | //这个接口的实现主要负责收集一些动作的耗时 |
触发点都了解清楚后,再来看看MetricsTrackerFactory
的接口定义:
1 | //用于创建IMetricsTracker实例,并且按需记录PoolStats对象里的属性(这个对象里的属性就是类似连接池当前闲置连接数之类的线程池状态类指标) |
上面的接口用法见注释,针对新出现的PoolStats
类,我们来看看它做了什么:
1 | public abstract class PoolStats { |
实际上这里就是这些属性获取和触发刷新的地方,那么这个对象是在哪里被生成并且丢给MetricsTrackerFactory
的create
方法的呢?这就是本节所需要讲述的要点:主流程2
里的设置监控器的流程,来看看那里发生了什么事吧:
1 | //监控器设置方法(此方法在HikariPool中,metricsTracker属性就是HikariPool用来触发IMetricsTracker里方法调用的) |
到这里HikariCP的监控器就算是注册进去了,所以要想实现自己的监控器拿到上面的指标,要经过如下步骤:
- 新建一个类实现
IMetricsTracker
接口,我们这里将该类记为IMetricsTrackerImpl
- 新建一个类实现
MetricsTrackerFactory
接口,我们这里将该类记为MetricsTrackerFactoryImpl
,并且将上面的IMetricsTrackerImpl
在其create方法
内实例化 - 将
MetricsTrackerFactoryImpl
实例化后调用HikariPool的setMetricsTrackerFactory
方法注册到Hikari连接池。
上面没有提到PoolStats
里的属性怎么监控,这里来说下,由于create方法
是调用一次就没了,create方法
只是接收了PoolStats
对象的实例,如果不处理,那么随着create调用的结束,这个实例针对监控模块来说就失去持有了,所以这里如果想要拿到PoolStats
里的属性,就需要开启一个守护线程
,让其持有PoolStats
对象实例,并且定时获取其内部属性值,然后push
给监控系统,如果是普罗米修斯等使用pull方式
获取监控数据的监控系统,可以效仿HikariCP原生普罗米修斯监控的实现,自定义一个Collector
对象来接收PoolStats
实例,这样普罗米修斯就可以定期拉取了,比如HikariCP根据普罗米修斯监控系统自己定义的MetricsTrackerFactory
实现(对应图2
里的PrometheusMetricsTrackerFactory
类):
1 |
|
通过上面的解释可以知道在HikariCP中如何自定义一个自己的监控器,以及相比Druid的监控,有什么区别。
工作中很多时候都是需要自定义的,我司虽然也是用的普罗米修斯监控,但是因为HikariCP原生的普罗米修斯收集器里面对监控指标的命名并不符合我司的规范
,所以就自定义
了一个,有类似问题的不妨也试一试。
🍁 这一节没有画图,纯代码,因为画图不太好解释,这部分内容与连接池整体流程关系不大,充其量获取了连接池本身的一些属性,触发点也在上述代码注释里说清楚了,看代码定义可能更好理解一些。
七、流程2.2:连接泄漏的检测与告警
本节对应主流程2
里的子流程2.2
,在初始化池对象时,初始化了一个叫做leakTaskFactory
的属性,本节来看下它具体是用来做什么的。
7.1:它是做什么的?
一个连接被拿出去使用时间超过leakDetectionThreshold
(可配置,默认0)未归还的,会触发一个连接泄漏警告,通知业务方目前存在连接泄漏的问题。
7.2:过程详解
该属性是ProxyLeakTaskFactory
类型对象,且它还会持有houseKeepingExecutorService
这个线程池对象,用于生产ProxyLeakTask
对象,然后利用上面的houseKeepingExecutorService
延时运行该对象里的run
方法。该流程的触发点在上面的流程1.1
最后包装成ProxyConnection
对象的那一步,来看看具体的流程图:
每次在流程1.1
那里生成ProxyConnection
对象时,都会触发上面的流程,由流程图可以知道,ProxyConnection
对象持有PoolEntry
和ProxyLeakTask
的对象,其中初始化ProxyLeakTask
对象时就用到了leakTaskFactory
对象,通过其schedule
方法可以进行ProxyLeakTask
的初始化,并将其实例传递给ProxyConnection
进行初始化赋值(ps:由图知ProxyConnection
在触发回收事件时,会主动取消这个泄漏检查任务,这也是ProxyConnection
需要持有ProxyLeakTask
对象的原因)。
在上面的流程图中可以知道,只有在leakDetectionThreshold
不等于0的时候才会生成一个带有实际延时任务的ProxyLeakTask
对象,否则返回无实际意义的空对象。所以要想启用连接泄漏检查,首先要把leakDetectionThreshold
配置设置上,这个属性表示经过该时间后借出去的连接仍未归还,则触发连接泄漏告警。
ProxyConnection
之所以要持有ProxyLeakTask
对象,是因为它可以监听到连接是否触发归还操作,如果触发,则调用cancel
方法取消延时任务,防止误告。
由此流程可以知道,跟Druid一样,HikariCP也有连接对象泄漏检查,与Druid主动回收连接相比,HikariCP实现更加简单,仅仅是在触发时打印警告日志,不会采取具体的强制回收的措施。
与Druid一样,默认也是关闭这个流程的,因为实际开发中一般使用第三方框架,框架本身会保证及时的close连接,防止连接对象泄漏,开启与否还是取决于业务是否需要,如果一定要开启,如何设置leakDetectionThreshold
的大小也是需要考虑的一件事。
八、主流程3:生成连接对象
本节来讲下主流程2
里的createEntry
方法,这个方法利用PoolBase里的DriverDataSource
对象生成一个实际的连接对象(如果忘记DriverDatasource
是哪里初始化的了,可以看下主流程2
里PoolBase
的initializeDataSource
方法的作用),然后用PoolEntry
类包装成PoolEntry
对象,现在来看下这个包装类有哪些主要属性:
1 | final class PoolEntry implements IConcurrentBagEntry { |
上面就是整个PoolEntry
对象里所有的属性,这里再说下endOfLife
对象,它是一个利用houseKeepingExecutorService
这个线程池对象做的延时任务,这个延时任务一般在创建好连接对象后maxLifeTime
左右的时间触发,具体来看下createEntry
代码:
1 | private PoolEntry createPoolEntry() { |
通过上面的流程,可以知道,HikariCP一般通过createEntry
方法来新增一个连接入池,每个连接被包装成PoolEntry对象,在创建好对象时,同时会提交一个延时任务来关闭废弃该连接,这个时间就是我们配置的maxLifeTime
,为了保证不在同一时间失效,HikariCP还会利用maxLifeTime
减去一个随机数作为最终的延时任务延迟时间,然后在触发废弃任务时,还会触发addBagItem
,进行连接添加任务(因为废弃了一个连接,需要往池子里补充一个),该任务则交给由主流程2
里定义好的addConnectionExecutor
线程池执行,那么,现在来看下这个异步添加连接对象的任务流程:
这个流程就是往连接池里加连接用的,跟createEntry
结合起来说是因为这俩流程是紧密相关的,除此之外,主流程5
(fillPool
,扩充连接池)也会触发该任务。
九、主流程4:连接池缩容
HikariCP会按照minIdle
定时清理闲置过久的连接,这个定时任务在主流程2
初始化连接池对象时被启用,跟上面的流程一样,也是利用houseKeepingExecutorService
这个线程池对象做该定时任务的执行器。
来看下主流程2
里是怎么启用该任务的:
1 | //housekeepingPeriodMs的默认值是30s,所以定时任务的间隔为30s |
那么本节主要来说下HouseKeeper
这个类,该类实现了Runnable
接口,回收逻辑主要在其run
方法内,来看看run
方法的逻辑流程图:
上面的流程就是HouseKeeper
的run方法里具体做的事情,由于系统时间回拨会导致该定时任务回收一些连接时产生误差,因此存在如下判断:
1 | //now就是当前系统时间,previous就是上次触发该任务时的时间,housekeepingPeriodMs就是隔多久触发该任务一次 |
这是hikariCP在解决系统时钟被回拨时做出的一种措施,通过流程图可以看到,它是直接把池子里所有的连接对象取出来挨个儿的标记成废弃,并且尝试把状态值修改为STATE_RESERVED
(后面会说明这些状态,这里先不深究)。如果系统时钟没有发生改变(绝大多数情况会命中这一块的逻辑),由图知,会把当前池内所有处于闲置状态(STATE_NOT_IN_USE
)的连接拿出来,然后计算需要检查的范围,然后循环着修改连接的状态:
1 | //拿到所有处于闲置状态的连接 |
上面的代码就是流程图里对应的没有回拨系统时间时的流程逻辑。该流程在idleTimeout
大于0(默认等于0)并且minIdle
小于maxPoolSize
的时候才会启用,默认是不启用的,若需要启用,可以按照条件来配置。
十、主流程5:扩充连接池
这个流程主要依附HikariPool里的fillPool
方法,这个方法已经在上面很多流程里出现过了,它的作用就是在触发连接废弃、连接池连接不够用时,发起扩充连接数的操作,这是个很简单的过程,下面看下源码(为了使代码结构更加清晰,对源码做了细微改动):
1 | // PoolEntryCreator关于call方法的实现流程在主流程3里已经看过了,但是这里却有俩PoolEntryCreator对象, |
由该过程可以知道,最终这个新增连接的任务也是交由addConnectionExecutor
线程池来处理的,而任务的主题也是PoolEntryCreator
,这个流程可以参考主流程3.
然后needAdd
的推算:
1 | Math.min(最大连接数 - 池内当前连接总数, 最小连接数 - 池内闲置的连接数) |
根据这个方式判断,可以保证池内的连接数永远不会超过maxPoolSize
,也永远不会低于minIdle
。在连接吃紧的时候,可以保证每次触发都以minIdle
的数量扩容。因此如果在maxPoolSize
跟minIdle
配置的值一样的话,在池内连接吃紧的时候,就不会发生任何扩容了。
十一、主流程6:连接回收
最开始说过,最终真实的物理连接对象会被包装成PoolEntry
对象,存放进ConcurrentBag
,然后获取时,PoolEntry对象又会被再次包装成ProxyConnection
对象暴露给使用方的,那么触发连接回收,实际上就是触发ProxyConnection里的close方法:
1 | public final void close() throws SQLException { |
这个就是ProxyConnection里的close方法,可以看到它最终会调用PoolEntry的recycle方法进行回收,除此之外,连接对象的最后一次使用时间也是在这个时候刷新的,该时间是个很重要的属性,可以用来判断一个连接对象的闲置时间,来看下PoolEntry的recycle
方法:
1 | void recycle(final long lastAccessed) { |
之前有说过,每个PoolEntry对象都持有HikariPool的对象,方便触发连接池的一些操作,由上述代码可以看到,最终还是会触发HikariPool里的recycle方法,再来看下HikariPool的recycle方法:
1 | void recycle(final PoolEntry poolEntry) { |
以上就是连接回收部分的逻辑,相比其他流程,还是比较简洁的。
十二、ConcurrentBag主流程
这个类用来存放最终的PoolEntry类型的连接对象,提供了基本的增删查的功能,被HikariPool持有,上面那么多的操作,几乎都是在HikariPool中完成的,HikariPool用来管理实际的连接生产动作和回收动作,实际操作的却是ConcurrentBag类,梳理下上面所有流程的触发点:
- 主流程2:初始化HikariPool时初始化
ConcurrentBag(构造方法)
,预热时通过createEntry
拿到连接对象,调用ConcurrentBag.add
添加连接到ConcurrentBag。 - 流程1.1:通过HikariPool获取连接时,通过调用
ConcurrentBag.borrow
拿到一个连接对象。 - 主流程6:通过
ConcurrentBag.requite
归还一个连接。 - 流程1.1.2:触发关闭连接时,会通过
ConcurrentBag.remove
移除连接对象,由前面的流程可知关闭连接触发点为:连接超过最大生命周期maxLifeTime主动废弃、健康检查不通过主动废弃、连接池缩容。 - 主流程3:通过异步添加连接时,通过调用
ConcurrentBag.add
添加连接到ConcurrentBag,由前面的流程可知添加连接触发点为:连接超过最大生命周期maxLifeTime主动废弃连接后、连接池扩容。 - 主流程4:连接池缩容任务,通过调用
ConcurrentBag.values
筛选出需要的做操作的连接对象,然后再通过ConcurrentBag.reserve
完成对连接对象状态的修改,然后会通过流程1.1.2
触发关闭和移除连接操作。
通过触发点整理,可以知道该结构里的主要方法,就是上面触发点里标记为标签色
的部分,然后来具体看下该类的基本定义和主要方法:
1 | public class ConcurrentBag<T extends IConcurrentBagEntry> implements AutoCloseable { |
从这个基本结构就可以稍微看出HikariCP是如何优化传统连接池实现的了,相比Druid来说,HikariCP更加偏向无锁实现,尽量避免锁竞争的发生。
12.1:borrow
这个方法用来获取一个可用的连接对象,触发点为流程1.1
,HikariPool就是利用该方法获取连接的,下面来看下该方法做了什么:
1 | public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException { |
仔细看下注释,该过程大致分成三个主要步骤:
- 从线程缓存获取连接
- 获取不到再从
sharedList
里获取 - 都获取不到则触发添加连接逻辑,并尝试从队列里获取新生成的连接对象
12.2:add
这个流程会添加一个连接对象进入bag,通常由主流程3
里的addBagItem
方法通过addConnectionExecutor
异步任务触发添加操作,该方法主流程如下:
1 | public void add(final T bagEntry) { |
代码块18
结合borrow
来理解的话,这里在存在等待线程时会添加一个连接对象入队列,可以让borrow
里发生等待的地方更容易poll到这个连接对象。
12.3:requite
这个流程会回收一个连接,该方法的触发点在主流程6
,具体代码如下:
1 | public void requite(final T bagEntry) { |
12.4:remove
这个负责从池子里移除一个连接对象,触发点在流程1.1.2
,代码如下:
1 | public boolean remove(final T bagEntry) { |
这里需要注意的是,移除时仅仅移除了sharedList
里的对象,各个线程内缓存的那一份集合里对应的对象并没有被移除,这个时候会不会存在该连接再次从缓存里拿到呢?会的,但是不会返回出去,而是直接remove
掉了,仔细看borrow
的代码发现状态不是闲置状态的时候,取出来时就会remove
掉,然后也拿不出去,自然也不会触发回收方法。
12.5:values
该方法存在重载方法,用于返回当前池子内连接对象的集合,触发点在主流程4
,代码如下:
1 | public List values(final int state) { |
12.6:reserve
该方法单纯将连接对象的状态值由STATE_NOT_IN_USE
修改为STATE_RESERVED
,触发点仍然是主流程4
,缩容时使用,代码如下:
1 | public boolean reserve(final T bagEntry){ |
12.7:getCount
该方法用于返回池内符合某个状态值的连接的总数量,触发点为主流程5
,扩充连接池时用于获取闲置连接总数,代码如下:
1 | public int getCount(final int state){ |
以上就是ConcurrentBag
的主要方法和处理连接对象的主要流程。
十三、总结
到这里基本上一个连接的生产到获取到回收到废弃一整个生命周期在HikariCP内是如何管理的就说完了,相比之前的Druid的实现,有很大的不同,主要是HikariCP的无锁
获取连接,本篇没有涉及FastList
的说明,因为从连接管理这个角度确实很少用到该结构,用到FastList
的地方主要在存储连接对象生成的statement对象
以及用于存储线程内缓存起来的连接对象;
除此之外HikariCP还利用javassist
技术编译期生成了ProxyConnection
的初始化,这里也没有相关说明,网上有关HikariCP的优化有很多文章,大多数都提到了字节码优化
、fastList
、concurrentBag
的实现,本篇主要通过深入解析HikariPool
和ConcurrentBag
的实现,来说明HikariCP相比Druid具体做了哪些不一样的操作。