天天看點

SpringBoot 2.0 中 HikariCP 資料庫連接配接池原了解析

本文重點講解了FastList 與ConcurrentBag 的優化原理,通過 ThreadLocal 将連接配接池中的連接配接按線程做一次預配置設定,避免直接競争共享資源,減少并發CAS帶來的CPU CACHE的頻繁失效,達到顯著提升性能的效果。

作為背景服務開發,在日常工作中我們天天都在跟資料庫打交道,一直在進行各種CRUD操作,都會使用到資料庫連接配接池。按照發展曆程,業界知名的資料庫連接配接池有以下幾種:c3p0、DBCP、Tomcat JDBC Connection Pool、Druid 等,不過最近最火的是 HiKariCP。

HiKariCP 号稱是業界跑得最快的資料庫連接配接池,自從 SpringBoot 2.0 将其作為預設資料庫連接配接池後,其發展勢頭銳不可當。那它為什麼那麼快呢?今天咱們就重點聊聊其中的原因。

一、什麼是資料庫連接配接池

在講解HiKariCP之前,我們先簡單介紹下什麼是資料庫連接配接池(Database Connection Pooling),以及為什麼要有資料庫連接配接池。

從根本上而言,資料庫連接配接池和我們常用的線程池一樣,都屬于池化資源,它在程式初始化時建立一定數量的資料庫連接配接對象并将其儲存在一塊記憶體區中。它允許應用程式重複使用一個現有的資料庫連接配接,當需要執行 SQL 時,我們是直接從連接配接池中擷取一個連接配接,而不是重建立立一個資料庫連接配接,當 SQL 執行完,也并不是将資料庫連接配接真的關掉,而是将其歸還到資料庫連接配接池中。我們可以通過配置連接配接池的參數來控制連接配接池中的初始連接配接數、最小連接配接、最大連接配接、最大空閑時間等參數,來保證通路資料庫的數量在一定可控制的範圍類,防止系統崩潰,同時保證使用者良好的體驗。資料庫連接配接池示意圖如下所示:

SpringBoot 2.0 中 HikariCP 資料庫連接配接池原了解析

是以使用資料庫連接配接池的核心作用,就是避免資料庫連接配接頻繁建立和銷毀,節省系統開銷。因為資料庫連接配接是有限且代價昂貴,建立和釋放資料庫連接配接都非常耗時,頻繁地進行這樣的操作将占用大量的性能開銷,進而導緻網站的響應速度下降,甚至引起伺服器崩潰。

二、常見資料庫連接配接池對比分析

這裡詳細總結了常見資料庫連接配接池的各項功能比較,我們重點分析下目前主流的阿裡巴巴Druid與HikariCP,HikariCP在性能上是完全優于Druid連接配接池的。而Druid的性能稍微差點是由于鎖機制的不同,并且Druid提供更豐富的功能,包括監控、sql攔截與解析等功能,兩者的側重點不一樣,HikariCP追求極緻的高性能。

SpringBoot 2.0 中 HikariCP 資料庫連接配接池原了解析

下面是官網提供的性能對比圖,在性能上面這五種資料庫連接配接池的排序如下:HikariCP>druid>tomcat-jdbc>dbcp>c3p0:

SpringBoot 2.0 中 HikariCP 資料庫連接配接池原了解析

三、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 去除了檢查範圍 和 從頭到尾周遊檢查元素的步驟,其性能更快。

SpringBoot 2.0 中 HikariCP 資料庫連接配接池原了解析
  • 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 遊戲技術團隊

分享 vivo 網際網路技術幹貨與沙龍活動,推薦最新行業動态與熱門會議。

繼續閱讀