41 | 案例分析(四):高性能数据库连接池HiKariCP
下载APP
关闭
渠道合作
推荐作者
41 | 案例分析(四):高性能数据库连接池HiKariCP
2019-06-01 王宝令 来自北京
《Java并发编程实战》
课程介绍
讲述:王宝令
时长09:25大小8.60M
实际工作中,我们总会难免和数据库打交道;只要和数据库打交道,就免不了使用数据库连接池。业界知名的数据库连接池有不少,例如 c3p0、DBCP、Tomcat JDBC Connection Pool、Druid 等,不过最近最火的是 HiKariCP。
HiKariCP 号称是业界跑得最快的数据库连接池,这两年发展得顺风顺水,尤其是 Springboot 2.0 将其作为默认数据库连接池后,江湖一哥的地位已是毋庸置疑了。那它为什么那么快呢?今天咱们就重点聊聊这个话题。
什么是数据库连接池
在详细分析 HiKariCP 高性能之前,我们有必要先简单介绍一下什么是数据库连接池。本质上,数据库连接池和线程池一样,都属于池化资源,作用都是避免重量级资源的频繁创建和销毁,对于数据库连接池来说,也就是避免数据库连接频繁创建和销毁。如下图所示,服务端会在运行期持有一定数量的数据库连接,当需要执行 SQL 时,并不是直接创建一个数据库连接,而是从连接池中获取一个;当 SQL 执行完,也并不是将数据库连接真的关掉,而是将其归还到连接池中。
数据库连接池示意图
在实际工作中,我们都是使用各种持久化框架来完成数据库的增删改查,基本上不会直接和数据库连接池打交道,为了能让你更好地理解数据库连接池的工作原理,下面的示例代码并没有使用任何框架,而是原生地使用 HiKariCP。执行数据库操作基本上是一系列规范化的步骤:
通过数据源获取一个数据库连接;
创建 Statement;
执行 SQL;
通过 ResultSet 获取 SQL 执行结果;
释放 ResultSet;
释放 Statement;
释放数据库连接。
下面的示例代码,通过 ds.getConnection() 获取一个数据库连接时,其实是向数据库连接池申请一个数据库连接,而不是创建一个新的数据库连接。同样,通过 conn.close() 释放一个数据库连接时,也不是直接将连接关闭,而是将连接归还给数据库连接池。
HiKariCP 官方网站解释了其性能之所以如此之高的秘密。微观上 HiKariCP 程序编译出的字节码执行效率更高,站在字节码的角度去优化 Java 代码,HiKariCP 的作者对性能的执着可见一斑,不过遗憾的是他并没有详细解释都做了哪些优化。而宏观上主要是和两个数据结构有关,一个是 FastList,另一个是 ConcurrentBag。下面我们来看看它们是如何提升 HiKariCP 的性能的。
FastList 解决了哪些性能问题
按照规范步骤,执行完数据库操作之后,需要依次关闭 ResultSet、Statement、Connection,但是总有粗心的同学只是关闭了 Connection,而忘了关闭 ResultSet 和 Statement。为了解决这种问题,最好的办法是当关闭 Connection 时,能够自动关闭 Statement。为了达到这个目标,Connection 就需要跟踪创建的 Statement,最简单的办法就是将创建的 Statement 保存在数组 ArrayList 里,这样当关闭 Connection 的时候,就可以依次将数组中的所有 Statement 关闭。
HiKariCP 觉得用 ArrayList 还是太慢,当通过 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 能保证不会越界,所以不用每次都进行越界检查。
整体来看,FastList 的优化点还是很简单的。下面我们再来聊聊 HiKariCP 中的另外一个数据结构 ConcurrentBag,看看它又是如何提升性能的。
ConcurrentBag 解决了哪些性能问题
如果让我们自己来实现一个数据库连接池,最简单的办法就是用两个阻塞队列来实现,一个用于保存空闲数据库连接的队列 idle,另一个用于保存忙碌数据库连接的队列 busy;获取连接时将空闲的数据库连接从 idle 队列移动到 busy 队列,而关闭连接时将数据库连接从 busy 移动到 idle。这种方案将并发问题委托给了阻塞队列,实现简单,但是性能并不是很理想。因为 Java SDK 中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。
HiKariCP 并没有使用 Java SDK 中的阻塞队列,而是自己实现了一个叫做 ConcurrentBag 的并发容器。ConcurrentBag 的设计最初源自 C#,它的一个核心设计是使用 ThreadLocal 避免部分并发问题,不过 HiKariCP 中的 ConcurrentBag 并没有完全参考 C# 的实现,下面我们来看看它是如何实现的。
ConcurrentBag 中最关键的属性有 4 个,分别是:用于存储所有的数据库连接的共享队列 sharedList、线程本地存储 threadList、等待数据库连接的线程数 waiters 以及分配数据库连接的工具 handoffQueue。其中,handoffQueue 用的是 Java SDK 提供的 SynchronousQueue,SynchronousQueue 主要用于线程之间传递数据。
当线程池创建了一个数据库连接时,通过调用 ConcurrentBag 的 add() 方法加入到 ConcurrentBag 中,下面是 add() 方法的具体实现,逻辑很简单,就是将这个连接加入到共享队列 sharedList 中,如果此时有线程在等待数据库连接,那么就通过 handoffQueue 将这个连接分配给等待的线程。
通过 ConcurrentBag 提供的 borrow() 方法,可以获取一个空闲的数据库连接,borrow() 的主要逻辑是:
首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
如果线程本地存储中无空闲连接,则从共享队列中获取。
如果共享队列中也没有空闲的连接,则请求线程需要等待。
需要注意的是,线程本地存储中的连接是可以被其他线程窃取的,所以需要用 CAS 方法防止重复分配。在共享队列中获取空闲连接,也采用了 CAS 方法防止重复分配。
释放连接需要调用 ConcurrentBag 提供的 requite() 方法,该方法的逻辑很简单,首先将数据库连接状态更改为 STATE_NOT_IN_USE,之后查看是否存在等待线程,如果有,则分配给等待线程;如果没有,则将该数据库连接保存到线程本地存储里。
总结
HiKariCP 中的 FastList 和 ConcurrentBag 这两个数据结构使用得非常巧妙,虽然实现起来并不复杂,但是对于性能的提升非常明显,根本原因在于这两个数据结构适用于数据库连接池这个特定的场景。FastList 适用于逆序删除场景;而 ConcurrentBag 通过 ThreadLocal 做一次预分配,避免直接竞争共享资源,非常适合池化资源的分配。
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 26
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
40 | 案例分析(三):高性能队列Disruptor
下一篇
42 | Actor模型:面向对象原生的并发模型
精选留言(33)
- 空知2019-06-02线程本地的连接会被窃取 这个我觉得是因为 如果 Tl里面没有空闲的 会去 sharedList查找处于 Not_In_Use的连接 这个连接可能已经在其他TL里面存在了 所以就会出现线程T2从sharedList获取到了 T1存在TL里面存放的没有使用的连接这种情况
作者回复: 厉害
共 3 条评论65 - 拯救地球好累2019-08-10支持高性能并发的软件通常首先会关注整体的并发设计模式,并发设计模式将影响整个软件的设计架构,比如RateLimiter并非采用较为复杂的生产者消费者模式,而是用细粒度的互斥锁来实现令牌桶算法;Netty采用了Reactor模式而非阻塞的等待-通知机制的一些实现。对设计模式的考量应当根据实际需求先考虑线程分工,再从避免共享的模式考虑到一些无锁的模式,再到细粒度的锁控制,再到复杂的同步和互斥模式。 从高性能队列和高性能数据连接池中,可以看到,性能的提高通常会从几方面着手(实际场景中应当测试优于猜测,再根据阿姆达尔定律从性能瓶颈处先着手):并发设计模式;内存分配算法;缓存利用率;GC情况(有GC的语言);数据结构与算法的效率等。展开
作者回复: 👍
59 - 晓杰2019-06-02同问为什么线程本地的会被其他线程窃取,麻烦老师解释一下
作者回复: sharedlist和其他线程的threadlocal里有可能都有同一个连接,从前者取到连接,就相当于窃取了后者
18 - 阿健2019-06-01同问,为什么说线程本地的连接会被窃取呢?共 1 条评论10
- 沙漠里的骆驼2019-06-02窃取是在获取本地链接失败时,遍历sharelist实现的6
- 峰2019-06-01想了半天感觉ConcurrentBag应该是池化的一种通用性优化,但好像会有饥饿问题,如果某些线程总是占用连接,那么某些不经常占用连接的就可能一直拿不到连接,硬想的一个缺点,哈哈哈。5
- Just2020-06-08这个ThreadLocal和JVM内存分配的TLAB(Thread local allocation buffer)还是有点像,先从本地获取,没有的话再去申请3
- Simple life2020-08-18看了第二遍,有个疑问,在一半WEB项目中,每次请求SPRING都新建一个线程服务,所以ThreadLocal中的线程并不能重用,这块性能提升就无效了,都去COW中CAS获取可用线程了,CAS在高并发环境中表现并不好
作者回复: 多次请求之间是不共享任何数据都没有,性能提高只是一次请求范围之内
2 - cricket19812019-06-02可以用栈stack来代替list实现逆序关闭S6~S1吗?共 2 条评论2
- Monday2021-05-22又来打一次卡,配合代码和debug
作者回复: 👍🏻
1 - poordickey2021-01-10这里讲的是连接池 但是很想知道一个数据库连接从拿到到归还的整个过程细节,从一个连接池拿到一个连接,connect之后,执行了SQL,并close了,归还到线程池之后又是怎么一直和数据库保持连接的呢
作者回复: 并没有真的close,你调用了一个被hack的方法
1 - sky2020-05-23for (int i = list.size() - 1; i >= 0; i--) { final Object entry = list.remove(i); 遍历的同时删除 会报 currentModityException吧 另外没看懂为啥需要handoffQueue, 直接从share 里拿 和 threadLocal 里拿不行么共 2 条评论1
- Geek_89bbab2019-06-13threadList里面的连接可能也会存在于多个threadList,但是概率相对较小;threadList的连接的remove操作都由本线程来执行,窃取的线程只会把标识设置为已使用,而不会将其从对应的那个threadList移除。可能是为了避免多线程操作同一个队列,而影响性能。所以把移除threadList里的连接的任务交给对应的那个线程。1
- 多襄丸2019-06-02老师, 我看文中提到的是调用requite()释放链接的时候将这个链接添加到本地存储中。 那我想问,如果不是调用requite()方法释放连接的情况下,这个连接第一次被放入threadlocal是什么时候啊? 是第一次获取连接的时候吗?
作者回复: 只有requite的时候会放到threatlocal里
1 - 银时空de梦2019-06-02最后数据库连接都到线程本地池中了共 1 条评论1
- 张德2019-06-02强烈建议老师再讲一期
作者回复: 呵呵😄
1 - 码小呆2022-07-04请问是如何 保证数组不会越界的呢?
- 王应发2022-06-19删除statement时,为什么不直接调用clear方法清空?
- yellow2022-05-19老师你好,请问释放的连接,如果没有,仅仅被保存到线程本地存储中,为什么不需要同时被重新保存到sharedList中呢? 不重新保存到sharedList中,别的线程还怎么有机会拿得到这个连接呢?
作者回复: 所有的连接,不论是否被使用,都在sharedList中
- study的程序员2021-08-22如果会被窃取,requite方法中如果没有等待线程为什么不把连接放入sharedList?