关于数据库:SpringBoot-20-中-HikariCP-数据库连接池原理解析

39次阅读

共计 7849 个字符,预计需要花费 20 分钟才能阅读完成。

作为后盾服务开发,在日常工作中咱们天天都在跟数据库打交道,始终在进行各种 CRUD 操作,都会应用到数据库连接池。依照倒退历程,业界出名的数据库连接池有以下几种:c3p0、DBCP、Tomcat JDBC Connection Pool、Druid 等,不过最近最火的是 HiKariCP。

HiKariCP 号称是业界跑得最快的数据库连接池,自从 SpringBoot 2.0 将其作为默认数据库连接池后,其发展势头锐不可当。那它为什么那么快呢?明天咱们就重点聊聊其中的起因。

一、什么是数据库连接池

在解说 HiKariCP 之前,咱们先简略介绍下什么是数据库连接池(Database Connection Pooling),以及为什么要有数据库连接池。

从根本上而言,数据库连接池和咱们罕用的线程池一样,都属于池化资源,它在程序初始化时创立肯定数量的数据库连贯对象并将其保留在一块内存区中。它容许应用程序重复使用一个现有的数据库连贯,当须要执行 SQL 时,咱们是间接从连接池中获取一个连贯,而不是从新建设一个数据库连贯,当 SQL 执行完,也并不是将数据库连贯真的关掉,而是将其偿还到数据库连接池中。咱们能够通过配置连接池的参数来管制连接池中的初始连接数、最小连贯、最大连贯、最大闲暇工夫等参数,来保障拜访数据库的数量在肯定可管制的范畴类,避免零碎解体,同时保障用户良好的体验。数据库连接池示意图如下所示:

因而应用数据库连接池的核心作用,就是防止数据库连贯频繁创立和销毁,节俭零碎开销。因为数据库连贯是无限且代价低廉,创立和开释数据库连贯都十分耗时,频繁地进行这样的操作将占用大量的性能开销,进而导致网站的响应速度降落,甚至引起服务器解体。

二、常见数据库连接池比照剖析

这里具体总结了常见数据库连接池的各项性能比拟,咱们重点剖析下以后支流的阿里巴巴 Druid 与 HikariCP,HikariCP 在性能上是齐全优于 Druid 连接池的。而 Druid 的性能略微差点是因为锁机制的不同,并且 Druid 提供更丰盛的性能,包含监控、sql 拦挡与解析等性能,两者的侧重点不一样,HikariCP 谋求极致的高性能。

上面是官网提供的性能比照图,在性能下面这五种数据库连接池的排序如下:HikariCP>druid>tomcat-jdbc>dbcp>c3p0:

三、HikariCP 数据库连接池简介

HikariCP 号称是史上性能最好的数据库连接池,SpringBoot 2.0 将它设置为默认的数据源连接池。Hikari 相比起其它连接池的性能高了十分多,那么,这是怎么做到的呢?通过查看 HikariCP 官网介绍,对于 HikariCP 所做优化总结如下:

1. 字节码精简:优化代码,编译后的字节码量极少,使得 CPU 缓存能够加载更多的程序代码;

HikariCP 在优化并精简字节码上也下了功夫,应用第三方的 Java 字节码批改类库 Javassist 来生成委托实现动静代理. 动静代理的实现在 ProxyFactory 类,速度更快,相比于 JDK Proxy 生成的字节码更少,精简了很多不必要的字节码。

2. 优化代理和拦截器:缩小代码,例如 HikariCP 的 Statement proxy 只有 100 行代码,只有 BoneCP 的十分之一;

3. 自定义数组类型(FastStatementList)代替 ArrayList:防止 ArrayList 每次 get()都要进行 range check,防止调用 remove()时的从头到尾的扫描(因为连贯的特点是后获取连贯的先开释);

4. 自定义汇合类型(ConcurrentBag):进步并发读写的效率;

5. 其余针对 BoneCP 缺点的优化,比方对于耗时超过一个 CPU 工夫片的办法调用的钻研。

当然作为一个数据库连接池,不能说快就会被消费者所推崇,它还具备十分好的健壮性及稳定性。HikariCP 从 15 年推出以来,曾经禁受了宽广利用市场的考验,并且胜利地被 SpringBoot2.0 作为默认数据库连接池进行推广,在可靠性下面是值得信赖的。其次借助于其代码量少,占用 cpu 和内存量小的长处,使得它的执行率十分高。最初,Spring 配置 HikariCP 和 druid 根本没什么区别,迁徙过去十分不便,这些都是为什么 HikariCP 目前如此受欢迎的起因。

字节码精简、优化代理和拦截器、自定义数组类型。

四、HikariCP 外围源码解析

4.1 FastList 是如何优化性能问题的

 首先咱们来看一下执行数据库操作规范化的操作步骤:

  1. 通过数据源获取一个数据库连贯;
  2. 创立 Statement;
  3. 执行 SQL;
  4. 通过 ResultSet 获取 SQL 执行后果;
  5. 开释 ResultSet;
  6. 开释 Statement;
  7. 开释数据库连贯。

以后所有数据库连接池都是严格地依据这个程序来进行数据库操作的,为了避免最初的开释操作,各类数据库连接池都会把创立的 Statement 保留在数组 ArrayList 里,来保障当敞开连贯的时候,能够顺次将数组中的所有 Statement 敞开。HiKariCP 在解决这一步骤中,认为 ArrayList 的某些办法操作存在优化空间,因而对 List 接口的精简实现,针对 List 接口中外围的几个办法进行优化,其余局部与 ArrayList 基本一致。

首先是 get()办法,ArrayList 每次调用 get()办法时都会进行 rangeCheck 查看索引是否越界,FastList 的实现中去除了这一查看,是因为数据库连接池满足索引的合法性,能保障不会越界,此时 rangeCheck 就属于有效的计算开销,所以不必每次都进行越界查看。省去频繁的有效操作,能够显著地缩小性能耗费。

  • FastList get()操作
public T get(int index)
{// ArrayList 在此多了范畴检测 rangeCheck(index);
   return elementData[index];
}

其次是 remove 办法,当通过 conn.createStatement() 创立一个 Statement 时,须要调用 ArrayList 的 add() 办法退出到 ArrayList 中,这个是没有问题的;然而当通过 stmt.close() 敞开 Statement 的时候,须要调用 ArrayList 的 remove() 办法来将其从 ArrayList 中删除,而 ArrayList 的 remove(Object)办法是从头开始遍历数组,而 FastList 是从数组的尾部开始遍历,因而更为高效。假如一个 Connection 顺次创立 6 个 Statement,别离是 S1、S2、S3、S4、S5、S6,而敞开 Statement 的程序个别都是逆序的,从 S6 到 S1,而 ArrayList 的 remove(Object o) 办法是程序遍历查找,逆序删除而程序查找,这样的查找效率就太慢了。因而 FastList 对其进行优化,改成了逆序查找。如下代码为 FastList 实现的数据移除操作,相比于 ArrayList 的 remove()代码,FastList 去除了查看范畴 和 从头到尾遍历查看元素的步骤,其性能更快。

  • FastList 删除操作
public boolean remove(Object element)
{
   // 删除操作应用逆序查找
   for (int index = size - 1; index >= 0; index--) {if (element == elementData[index]) {
         final int numMoved = size - index - 1;
         // 如果角标不是最初一个,复制一个新的数组构造
         if (numMoved > 0) {System.arraycopy(elementData, index + 1, elementData, index, numMoved);
         }
         // 如果角标是最初面的 间接初始化为 null
         elementData[--size] = null;
         return true;
      }
   }
   return false;
}

通过上述源码剖析,FastList 的优化点还是很简略的。相比 ArrayList 仅仅是去掉了 rage 查看,扩容优化等细节处,删除时数组从后往前遍历查找元素等渺小的调整,从而谋求性能极致。当然 FastList 对于 ArrayList 的优化,咱们不能说 ArrayList 不好。所谓定位不同、谋求不同,ArrayList 作为通用容器,更谋求平安、稳固,操作前 rangeCheck 查看,对非法申请间接抛出异样,更合乎 fail-fast(疾速失败)机制,而 FastList 谋求的是性能极致。

上面咱们再来聊聊 HiKariCP 中的另外一个数据结构 ConcurrentBag,看看它又是如何晋升性能的。

4.2 ConcurrentBag 实现原理剖析

以后支流数据库连接池实现形式,大都用两个阻塞队列来实现。一个用于保留闲暇数据库连贯的队列 idle,另一个用于保留繁忙数据库连贯的队列 busy;获取连贯时将闲暇的数据库连贯从 idle 队列挪动到 busy 队列,而敞开连贯时将数据库连贯从 busy 挪动到 idle。这种计划将并发问题委托给了阻塞队列,实现简略,然而性能并不是很现实。因为 Java SDK 中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。

HiKariCP 并没有应用 Java SDK 中的阻塞队列,而是本人实现了一个叫做 ConcurrentBag 的并发容器,在连接池(多线程数据交互)的实现上具备比 LinkedBlockingQueue 和 LinkedTransferQueue 更优越的性能。

ConcurrentBag 中最要害的属性有 4 个,别离是:用于存储所有的数据库连贯的共享队列 sharedList、线程本地存储 threadList、期待数据库连贯的线程数 waiters 以及调配数据库连贯的工具 handoffQueue。其中,handoffQueue 用的是 Java SDK 提供的 SynchronousQueue,SynchronousQueue 次要用于线程之间传递数据。

  • ConcurrentBag 中的要害属性
// 寄存共享元素,用于存储所有的数据库连贯
private final CopyOnWriteArrayList<T> sharedList;
// 在 ThreadLocal 缓存线程本地的数据库连贯,防止线程争用
private final ThreadLocal<List<Object>> threadList;
// 期待数据库连贯的线程数
private final AtomicInteger waiters;
// 接力队列,用来调配数据库连贯
private final SynchronousQueue<T> handoffQueue;

ConcurrentBag 保障了全副的资源均只能通过 add() 办法进行增加,当线程池创立了一个数据库连贯时,通过调用 ConcurrentBag 的 add() 办法退出到 ConcurrentBag 中,并通过 remove() 办法进行移出。上面是 add() 办法和 remove() 办法的具体实现,增加时实现了将这个连贯退出到共享队列 sharedList 中,如果此时有线程在期待数据库连贯,那么就通过 handoffQueue 将这个连贯调配给期待的线程。

  • ConcurrentBag 的 add() 与 remove() 办法
public void add(final T bagEntry)
{if (closed) {LOGGER.info("ConcurrentBag has been closed, ignoring add()");
      throw new IllegalStateException("ConcurrentBag has been closed, ignoring add()");
   }
   // 新增加的资源优先放入 sharedList
   sharedList.add(bagEntry);
 
   // 当有期待资源的线程时,将资源交到期待线程 handoffQueue 后才返回
   while (waiters.get() > 0 && bagEntry.getState() == STATE_NOT_IN_USE && !handoffQueue.offer(bagEntry)) {yield();
   }
}
public boolean remove(final T bagEntry)
{
   // 如果资源正在应用且无奈进行状态切换,则返回失败
   if (!bagEntry.compareAndSet(STATE_IN_USE, STATE_REMOVED) && !bagEntry.compareAndSet(STATE_RESERVED, STATE_REMOVED) && !closed) {LOGGER.warn("Attempt to remove an object from the bag that was not borrowed or reserved: {}", bagEntry);
      return false;
   }
   // 从 sharedList 中移出
   final boolean removed = sharedList.remove(bagEntry);
   if (!removed && !closed) {LOGGER.warn("Attempt to remove an object from the bag that does not exist: {}", bagEntry);
   }
   return removed;
}

同时 ConcurrentBag 通过提供的 borrow() 办法来获取一个闲暇的数据库连贯,并通过 requite()办法进行资源回收,borrow() 的次要逻辑是:

  1. 查看线程本地存储 threadList 中是否有闲暇连贯,如果有,则返回一个闲暇的连贯;
  2. 如果线程本地存储中无闲暇连贯,则从共享队列 sharedList 中获取;
  3. 如果共享队列中也没有闲暇的连贯,则申请线程须要期待。
  • ConcurrentBag 的 borrow() 与 requite() 办法
// 该办法会从连接池中获取连贯, 如果没有连贯可用, 会始终期待 timeout 超时
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
   // 首先查看线程本地资源 threadList 是否有闲暇连贯
   final List<Object> list = threadList.get();
   // 从后往前反向遍历是有益处的, 因为最初一次应用的连贯, 闲暇的可能性比拟大, 之前的连贯可能会被其余线程提前借走了
   for (int i = list.size() - 1; i >= 0; i--) {final Object entry = list.remove(i);
      @SuppressWarnings("unchecked")
      final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
      // 线程本地存储中的连贯也能够被窃取,所以须要用 CAS 办法避免反复调配
      if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {return bagEntry;}
   }
   // 当无可用本地化资源时,遍历全副资源,查看可用资源,并用 CAS 办法避免资源被反复调配
   final int waiting = waiters.incrementAndGet();
   try {for (T bagEntry : sharedList) {if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
            // 因为可能“抢走”了其余线程的资源,因而揭示包裹进行资源增加
            if (waiting > 1) {listener.addBagItem(waiting - 1);
            }
            return bagEntry;
         }
      }
 
      listener.addBagItem(waiting);
      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 > 10_000);
      return null;
   }
   finally {waiters.decrementAndGet();
   }
}
 
public void requite(final T bagEntry)
{
   // 将资源状态转为未在应用
   bagEntry.setState(STATE_NOT_IN_USE);
   // 判断是否存在期待线程,若存在,则间接转手资源
   for (int i = 0; waiters.get() > 0; i++) {if (bagEntry.getState() != STATE_NOT_IN_USE || handoffQueue.offer(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);
   }
}

borrow() 办法能够说是整个 HikariCP 中最外围的办法,它是咱们从连接池中获取连贯的时候最终会调用到的办法。须要留神的是 borrow() 办法只提供对象援用,不移除对象,因而应用时必须通过 requite() 办法进行放回,否则容易导致内存泄露。requite() 办法首先将数据库连贯状态改为未应用,之后查看是否存在期待线程,如果有则调配给期待线程;否则将该数据库连贯保留到线程本地存储里。

ConcurrentBag 实现采纳了 queue-stealing 的机制获取元素:首先尝试从 ThreadLocal 中获取属于以后线程的元素来防止锁竞争,如果没有可用元素则再次从共享的 CopyOnWriteArrayList 中获取。此外,ThreadLocal 和 CopyOnWriteArrayList 在 ConcurrentBag 中都是成员变量,线程间不共享,防止了伪共享 (false sharing) 的产生。同时因为线程本地存储中的连贯是能够被其余线程窃取的,在共享队列中获取闲暇连贯,所以须要用 CAS 办法避免反复调配。

五、总结

Hikari 作为 SpringBoot2.0 默认的连接池,目前在行业内应用范畴十分广,对于大部分业务来说,都能够实现疾速接入应用,做到高效连贯。

参考资料

  1. https://github.com/brettwooldridge/HikariCP
  2. https://github.com/alibaba/druid

作者:vivo 游戏技术团队

正文完
 0