0%

高性能数据库连接池HiKariCP

业界数据库连接池:c3p0、DBCP、TomcatJDBC Connection Pool、Druid、HiKariCP。

HiKariCP号称是业界跑得最快的数据库连接池,Springboot2.0将其作为默认数据库连接池。

数据库连接池

本质上,数据库连接池和线程池一样,都属于池化资源,作用都是避免重量级资源的频繁创建和销毁,对于数据库连接池来说,也就是避免数据库连接频繁创建和销毁。

服务端会在运行期持有一定数量的数据库连接,当需要执行SQL时,并不是直接创建一个数据库连接,而是从连接池中获取一个;当SQL执行完,也并不是将数据库连接真的关掉,而是将其归还到连接池中。

执行数据库操作步骤(持久化框架封装了数据库连接细节):

  1. 通过数据源获取一个数据库连接;
  2. 创建Statement;
  3. 执行SQL;
  4. 通过ResultSet获取SQL执行结果;
  5. 释放ResultSet;
  6. 释放Statement;
  7. 释放数据库连接。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//数据库连接池配置
HikariConfig config = new HiKariConfig();
config.setMinimumIdle(1);
config.setMaximumPoolSize(2);
config,setConnectionTestQuery("select 1");
config.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource");
config.addDataSourceProperty("url", "jdbc:h2:mem:test");
//创建数据源
DataSource ds = new HikariDataSource(config);
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
//获取数据库连接
conn = ds.getConnection();
//创建Statement
stmt = conn.createStatement();
//执行SQL
rs = stmt.executeQuery("select * from abc");
//获取结果
while(rs.next()) {
int id = rs.getInt(1);
......
}
} catch(Exception e) {
e.printStackTrace();
} finally {
//关闭ResultSet
close(rs);
//关闭Statement
close(stmt);
//关闭Connection
close(conn);
}
//关闭资源
void close(AutoCloseable rs) {
if(rs != null) {
try {
rs.close();
} catch(SQLException e) {
e.printStackTrace();
}
}
}

通过ds.getConnection()获取一个数据库连接时,其实是向数据库连接池申请一个数据库连接,而不是创建一个新的数据库连接。同样,通过conn.close()释放一个数据库连接时,也不是直接将连接关闭,而是将连接归还给数据库连接池。

微观上HiKariCP程序编译出的字节码执行效率更高,站在字节码的角度去优化Java代码。宏观上主要是和两个数据结构有关,一个是FastList,另一个是ConcurrentBag。

FastList解决的性能问题

按照规范步骤,执行完数据库操作之后,需要依次关闭ResultSet、Statement、Connection。当关闭Connection时,能够自动关闭Statement最好。Connection需要跟踪创建的Statement,最简单的办法就是将创建的Statement保存在数组ArrayList里,这样当关闭Connection的时候,就可以依次将数组中的所有Statement关闭。

当通过conn.createStatement()创建一个Statement时,需要调用ArrayList的add()方法加入到ArrayList中;当通过stmt.close()关闭Statement的时候,需要调用ArrayList的remove()方法来将其从ArrayList中删除,这里有优化余地。

假设一个Connection一次创建6个Statement,分别是S1、S2、S3、S4、S5、S6,按照正常的编码习惯,关闭Statement的顺序一般是逆序的,关闭的顺序是S6、S5、S4、S3、S2、S1,而ArrayList的remove(Object o)方法是顺序遍历查找,逆序删除而顺序查找,效率太慢。

HiKariCP中的FastList相对于ArrayList的一个优化点就是将remove(Object element)方法的查找顺序变成了逆序查找。除此之外,FastList的另外一个优化点是,get(int index)方法没有对index参数进行越界检查,HiKariCP能保证不会越界,所以不用每次都进行越界检查。

ConcurrentBag解决的性能问题

自己实现一个数据库连接池,最简单的方法就是用两个阻塞队列来实现,一个用于保存空闲数据库连接的队列idle,另一个用于保存忙碌数据库连接的队列busy;获取连接时将空闲的数据库连接从idle队列移动到busy队列,而关闭连接时将数据库连接从busy移动到idle。这种方案将并发问题委托给了阻塞队列,实现简单,但是性能不理想,Java SDK中的阻塞队列是用锁实现的,高并发场景下锁的争用对性能影响很大。

1
2
3
4
//忙碌队列
BlockingQueue<Connection> busy;
//空闲队列
BlockingQueue<Connection> idle;

HiKariCP自己实现了一个叫做ConcurrentBag的并发容器。ConcurrentBag的核心设计是使用ThreadLocal避免部分并发问题。

ConcurrentBag中最关键的属性有4个:

  • 用于存储所有的数据库连接的共享队列sharedList
  • 线程本地存储threadList
  • 等待数据库连接的线程数waiters
  • 分配数据库连接的工具handoffQueue

    handoffQueue用的是Java SDK提供的SynchronousQueue,主要用于线程之间传递数据

    1
    2
    3
    4
    5
    6
    7
    8
    //用于存储所有的数据库连接
    CopyOnWriteArrayList<T> sharedList;
    //线程本地存储中的数据库连接
    ThreadLocal<List<Object>> threadList;
    //等待数据路连接的线程数
    AtomicInteger waiters;
    //分配数据库连接的工具
    SynchronousQueue<T> handoffQueue;

add()

当线程池创建了一个数据库连接时,通过调用ConcurrentBag的add()方法加入到ConcurrentBag中,逻辑是:
将这个连接加入到共享队列sharedList中,如果此时有线程在等待数据库连接,那么就通过handoffQueue将这个连接分配给等待的线程。

1
2
3
4
5
6
7
8
9
10
11
//将空闲连接添加到队列
void add(final T bagEntry) {
//加入共享队列
sharedList.add(bagEntry);
//如果有等待连接的线程,则通过handoffQueue直接分配给等待的线程
while(waiters.get() > 0
&& bagEntry.getState() == STATE_NOT_IN_USE
&& !handoffQueue.offer(bagEntry)) {
yield();
}
}

borrow()

通过ConcurrentBag提供的borrow()方法,可以获取一个空闲的数据库连接,逻辑是:

  1. 首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
  2. 如果线程本地存储中无空闲连接,则从共享队列中获取;
  3. 如果共享队列中也没有空闲的连接,则请求线程需要等待。

线程本地存储中的连接时可以被其它线程窃取的,所以需要用CAS方法防止重复分配。在共享队列中获取空闲连接,也采用了CAS方法防止重复分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
T borrow(long timeout, final TimeUnit timeUnit) {
//先查看线程本地存储是否有空闲连接
final List<Object> list = threadList.get();
for(int i = list.size()-1; i>=0; i--) {
final Object entry = list.remove(i);
final T bagEntry = wreakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
//线程本地存储中的连接池可以被窃取,需要用CAS方法防治重复分配
if(bagEntry != null && bafEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}

//线程本地存储中无空闲连接,则从共享队列中获取
final int waiting = waiters.incrementAndGet();
try {
for(T bagEntry : sharedList) {
//如果共享队列中有空闲连接,则返回
if(bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
//共享队列中没有连接,则需要等待
timeout = timeUnit.toNanos(timeout);
do {
final long start = currentTime();
final T bagEntry = handoffQueue.poll(timeout. NANOSECONDS);
if(bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
//重新计算等待时间
timeout -= elapsedNanos(start);
} while(timeout>10000);
//超时没有获取到连接,返回null
return null;
} finally {
waiters.decrementAndSet();
}
}

requite()

释放连接需要调用ConcurrentBad提供的requite()方法,逻辑是:
首先将数据库连接状态更改为STATE_NOT_IN_USE,之后查看是否存在等待线程,如果有,则分配给等待线程;如果没有,则将数据库连接保存到线程本地存储里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//释放连接
void requite(fianl T bagEntry) {
//更新连接状态
bagEntry.setState(STATE_NOT_IN_USE);
//如果有等待的线程,则直接分配给线程,无需进入任何队列
for(int i=0; waiters.get()>0; i++) {
if(bagEntry.getState() != STATE_NOT_IN_USE || handoffQueue.off(bagEntry)) {
return;
} else if((i & 0xff) == 0xff) {
parkNanos(MICROSECONDS.toNanos(10));
} else {
yield();
}
}
//如果没有等待的线程,则进入线程本地存储
final List<Object> threadLocalList = threadList.get();
if(threadLocalList.size() <50) {
threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
}
}

总结

HiKariCP中的FastList和ConcurrentBag这两个数据结构适用于数据库连接池这个特定的场景。FastList适用于逆序删除场景;而ConcurrentBag通过ThreadLocal做一次预分配,避免直接竞争共享资源,非常适合池化资源的分配。