[Java併發-25] 高性能數據庫鏈接池 HiKariCP 分析

實際工做中,咱們總會不免和數據庫打交道;只要和數據庫打交道,就免不了使用數據庫鏈接池。業界知名的數據庫鏈接池有很多,例如 DBCP、Tomcat JDBC Connection Pool、Druid 等,不過最近最火的是 HiKariCP。git

HiKariCP 號稱是業界跑得最快的數據庫鏈接池,尤爲是 Springboot 2.0 將其做爲默認數據庫鏈接池後,地位已經是毋庸置疑了。那它爲何那麼快呢?帶着問題咱們來看下。github

什麼是數據庫鏈接池

在詳細分析 HiKariCP 高性能以前,咱們有必要先簡單介紹一下什麼是數據庫鏈接池。本質上,數據庫鏈接池和線程池同樣,都屬於池化資源,做用都是避免重量級資源的頻繁建立和銷燬,對於數據庫鏈接池來講,也就是避免數據庫鏈接頻繁建立和銷燬。服務端會在運行期持有必定數量的數據庫鏈接,當須要執行 SQL 時,並非直接建立一個數據庫鏈接,而是從鏈接池中獲取一個;當 SQL 執行完,也並非將數據庫鏈接真的關掉,而是將其歸還到鏈接池中。數據庫

爲了能讓你更好地理解數據庫鏈接池的工做原理,咱們不使用使用任何框架,而是原生地使用 HiKariCP。執行數據庫操做基本上是一系列規範化的步驟:數組

  1. 經過數據源獲取一個數據庫鏈接;
  2. 建立 Statement;
  3. 執行 SQL;
  4. 經過 ResultSet 獲取 SQL 執行結果;
  5. 釋放ResultSet;
  6. 釋放 Statement;
  7. 釋放數據庫鏈接。

下面的示例代碼,經過 ds.getConnection() 獲取一個數據庫鏈接時,實際上是向數據庫鏈接池申請一個數據庫鏈接,而不是建立一個新的數據庫鏈接。一樣,經過 conn.close() 釋放一個數據庫鏈接時,也不是直接將鏈接關閉,而是將鏈接歸還給數據庫鏈接池。數據結構

// 數據庫鏈接池配置
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();
    }
  }
}

HiKariCP官文解釋了其性能之因此如此之高的祕密。微觀上 HiKariCP 程序編譯出的字節碼執行效率更高,站在字節碼的角度去優化 Java 代碼。而宏觀上主要是和兩個數據結構有關,一個是 FastList,另外一個是 ConcurrentBag。併發

FastList 解決了哪些性能問題

按照規範步驟,執行完數據庫操做以後,須要依次關閉 ResultSet、Statement、Connection,可是總有粗心的同窗只是關閉了 Connection,而忘了關閉 ResultSet 和 Statement。爲了解決這種問題,最好的辦法是當關閉 Connection 時,可以自動關閉 Statement。爲了達到這個目標,Connection 就須要跟蹤建立的 Statement,最簡單的辦法就是將建立的 Statement 保存在數組 ArrayList 裏,這樣當關閉 Connection 的時候,就能夠依次將數組中的全部 Statement 關閉。框架

HiKariCP 以爲用 ArrayList 仍是太慢。 由於 當經過 stmt.close() 關閉 Statement 的時候,須要調用 ArrayList 的 remove() 方法來將其從 ArrayList 中刪除,這裏是有優化餘地的。高併發

假設一個 Connection 依次建立 6 個 Statement,分別是 S一、S二、S三、S四、S五、S6,按照正常的編碼習慣,關閉 Statement 的順序通常是逆序的,關閉的順序是:S六、S五、S四、S三、S二、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 中的阻塞隊列是用鎖實現的,而高併發場景下鎖的爭用對性能影響很大。

// 忙碌隊列
BlockingQueue<Connection> busy;
// 空閒隊列
BlockingQueue<Connection> idle;

HiKariCP 並無使用 Java SDK 中的阻塞隊列,而是本身實現了一個叫作 ConcurrentBag 的併發容器。它的一個核心設計是使用 ThreadLocal 避免部分併發問題, 下面咱們來看看它是如何實現的。

ConcurrentBag 中最關鍵的屬性有 4 個,分別是:用於存儲全部的數據庫鏈接的共享隊列 sharedList、線程本地存儲 threadList、等待數據庫鏈接的線程數 waiters 以及分配數據庫鏈接的工具 handoffQueue。其中,handoffQueue 用的是 Java SDK 提供的 SynchronousQueue,SynchronousQueue 主要用於線程之間傳遞數據。

// 用於存儲全部的數據庫鏈接
CopyOnWriteArrayList<T> sharedList;
// 線程本地存儲中的數據庫鏈接
ThreadLocal<List<Object>> threadList;
// 等待數據庫鏈接的線程數
AtomicInteger waiters;
// 分配數據庫鏈接的工具
SynchronousQueue<T> handoffQueue;

當線程池建立了一個數據庫鏈接時,經過調用 ConcurrentBag 的 add() 方法加入到 ConcurrentBag 中,下面是 add() 方法的具體實現,邏輯很簡單,就是將這個鏈接加入到共享隊列 sharedList 中,若是此時有線程在等待數據庫鏈接,那麼就經過 handoffQueue 將這個鏈接分配給等待的線程。

// 將空閒鏈接添加到隊列
void add(final T bagEntry){
  // 加入共享隊列
  sharedList.add(bagEntry);
  // 若是有等待鏈接的線程,
  // 則經過 handoffQueue 直接分配給等待的線程
  while (waiters.get() > 0 
    && bagEntry.getState() == STATE_NOT_IN_USE 
    && !handoffQueue.offer(bagEntry)) {
      yield();
  }
}

經過 ConcurrentBag 提供的 borrow() 方法,能夠獲取一個空閒的數據庫鏈接,borrow() 的主要邏輯是:

  1. 首先查看線程本地存儲是否有空閒鏈接,若是有,則返回一個空閒的鏈接;
  2. 若是線程本地存儲中無空閒鏈接,則從共享隊列中獲取。
  3. 若是共享隊列中也沒有空閒的鏈接,則請求線程須要等待。

線程本地存儲中的鏈接是能夠被其餘線程竊取的,因此須要用 CAS 方法防止重複分配。在共享隊列中獲取空閒鏈接,也採用了 CAS 方法防止重複分配。

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 = weakThreadLocals 
      ? ((WeakReference<T>) entry).get() 
      : (T) entry;
    // 線程本地存儲中的鏈接也能夠被竊取,
    // 因此須要用 CAS 方法防止重複分配
    if (bagEntry != null 
      && bagEntry.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 > 10_000);
    // 超時沒有獲取到鏈接,返回 null
    return null;
  } finally {
    waiters.decrementAndGet();
  }
}

釋放鏈接須要調用 ConcurrentBag 提供的 requite() 方法,該方法的邏輯很簡單,首先將數據庫鏈接狀態更改成 STATE_NOT_IN_USE,以後查看是否存在等待線程,若是有,則分配給等待線程;若是沒有,則將該數據庫鏈接保存到線程本地存儲裏。

// 釋放鏈接
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);
  }
}

總結

HiKariCP 中的 FastList 和 ConcurrentBag 這兩個數據結構使用得很是巧妙,雖然實現起來並不複雜,可是對於性能的提高很是明顯,根本緣由在於這兩個數據結構適用於數據庫鏈接池這個特定的場景。FastList 適用於逆序刪除場景;而 ConcurrentBag 經過 ThreadLocal 作一次預分配,避免直接競爭共享資源,很是適合池化資源的分配。

相關文章
相關標籤/搜索