Java 數據持久化系列之 HikariCP (一)

在上一篇《Java 數據持久化系列之池化技術》中,我們瞭解了池化技術,並使用 Apache-common-Pool2 實現了一個簡單連接池,實驗對比了它和 HikariCP、Druid 等數據庫連接池的性能數據。在性能方面,HikariCP遙遙領先,而且它還是 Spring Boot 2.0 默認的數據庫連接池。下面我們就來了解一下這款明星級開源數據庫連接池的實現。

本文的主要內容包括:

  • HikariCP 簡介,介紹它的特性和現況。
  • HikariCP 的配置項詳解,分析部分配置的影響。
  • HikariCP 爲什麼這麼快,介紹其優化點。

這裏囉嗦兩句,由於本系列會涉及很多開源項目,比如說 HikariCP、Druid、Mybatis等,所以簡單聊一下我對學習開源項目的認識,這也是我自己行文或者組織系列文章順序的思路,後續有時間再詳細總結一下。

  • 安裝並檢查提供的所有工具,比如 Redis 目錄下的 redis-check-aof 等工具的作用,這些工具都是官方特意提供的,一般都是日常經常要使用的,瞭解其功能。
  • 運行,學習所有配置項的功能,原理和優缺點,比如 Redis 的內存溢出控制策略 maxmemory-policy 的可選值都有哪些,分別對應的策略是什麼含義,適用於哪些場景等。
  • 原理研究,針對關鍵特性進行研究,比如 Netty 的異步 NIO 和零拷,HikariCP的高併發
  • 優缺點對比,同類型開源產品對比,一般某一領域的開源項目往往有多個,比如說 Redis 和 Memcache,Kafka 和 RocketMQ,這些項目之間往往各有優劣,適用場景,瞭解了這些,也往往進一步加深了對項目關鍵特性和原理的研究。
  • demo或者性能測試,按照自己的使用場景去進行 Demo 驗證和性能測試
  • 根據demo來查看調用棧,閱讀關鍵源碼,帶着問題去閱讀源碼,比如閱讀 Redis 如何進行 aof 持久化等。
  • 試圖修改源碼,只是閱讀源碼其實很多時候無法體會到代碼爲什麼實現成這樣,在有餘力的情況下修改源碼,比較實現方案,可以更好的理解實現方案,並未後續成爲 commiter 打下基礎。

HikariCP 簡介

Hikari 在日語中的含義是光,作者特意用這個含義來表示這塊數據庫連接池真的速度很快。官方地址是 https://github.com/brettwooldridge/HikariCP。

Hikari 最引以爲傲的就是它的性能,所以作者也在貼下了很多性能數據和用戶反饋。筆者也在上一篇文章中使用它的 benchmark 進行了性能對比。

從上圖中可以直觀的看出,Hikari 在 獲取和釋放 Connection 和 Statement 方法的 OPS 不是一般的高,那是相當的高,基本上是碾壓其他連接池,這裏就不一一點名了。

除了 OPS 外,HikariCP 的穩定性也更好,性能毛刺更少。

除了性能之外,HikariCP 在很多編碼細節上也下了很多功夫。

比如說使用 JDBC4Connection 的 isValid 函數來檢查 Connection 有效性,該函數使用原生的 ping 命令檢查,比一般數據庫連接池默認使用的 select 1 語句快一倍,性能更好。

更加遵循 JDBC 規範,在關閉 Connection 之前先關閉與之關聯的 Statement 和ResultSet 等。對 JDBC 不瞭解的同學可以閱讀本系列中第一篇文章

對於數據庫連接中斷的情況,HikariCP 也處理的更加出色。作者做了實驗,通過測試獲取 Connection 的超時場景,各個數據庫都設置了跟連接超時 connectionTimeout 類似的參數爲 5 秒鐘。其中 HikariCP 等待5秒鐘後,如果連接還是沒有恢復,則拋出一個SQLExceptions 異常,後續再獲取 Connection 也是一樣處理。其他數據庫連接池的處理則不理想,要麼是一直等到 TCP 超時才響應,比如 Dbcp2 和 C3PO,要麼是需要修改默認配置,比如說 Vibur。

具體文章可以閱讀 《Bad Behavior: Handling Database Down》一文(鏈接在文末)。

配置詳解

下面,我們來詳細瞭解一下 HikariCP 的相關配置。
首先,Spring Boot 2.0 的默認數據庫連接池配置就是 HikariCP,所以你無需引入其他依賴,直接在 yml 文件中進行 HikariCP 的相關配置即可。基礎配置如下所示。

spring:
  datasource:
    hikari:
      minimum-idle: 20
      maximum-pool-size: 100
      pool-name: dbcp1
      idle-timeout: 10000
    ### Driver 類名和 數據庫 URL,用戶名密碼等 datasource 基礎配置
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3300/test?rewriteBatchedStatements=true&autoReconnect=true&useSSL=false&useUnicode=true&characterEncoding=utf-8
    username: ${AUTH_DB_PWD:root}
    password: ${AUTH_DB_USER:test}
    ### 顯示指定數據庫連接池,默認也是 HikariDataSource,指定數據庫連接池
    type: com.zaxxer.hikari.HikariDataSource

HikariCP 的所有配置及其默認值可以在 HikariConfig 中查看,下面我們來依次介紹較爲常用的配置。

  • autoCommit:控制從數據庫連接池返回的 Connection 是否默認事務自動提交行爲,默認爲 true。
  • connectionTimeout:控制客戶端在獲取池中 Connection 的等待時間,如果沒有連接可用的情況下超過該時間,則拋出 SQLException 異常,比如說 getConnection時連接數已經大於 maximumPoolSize 並且一直沒有空閒的連接 。默認 30 s。
  • idleTimeout:控制 Connection 閒置在池中的最大時間。當 minimumIdle 值大於 maximumPoolSize 小時才生效,而且只有當池中 Connection 數量大於 minimumIdle 時才根據該時間進行 Connection 剔除。默認爲 600000 s(10 分鐘)。
  • maxLifetime:控制池中 Connection 的最大生命週期。處於使用中的 Connection 不會因爲自身生命超出該時間而被剔除,只有等到被歸還關閉後纔會被剔除。HikariCP 作者強烈建議用戶設置該值,並且它應該比任何數據庫服務的連接事件限制短几秒。默認爲 1800000 s(30分鐘)。
  • connectionTestQuery:控制數據庫連接池借出 Connection 前對其進行檢查,如果使用的 Driver 是 JDBC4 則不建議設置該屬性。不配做會使用 ping 命令進行檢查,其性能大致爲 select 1 的1倍左右。默認爲無。
  • minimumIdle:控制池中維護的空閒 Connection 的最小數量。如果空閒連接數大小該數值,並且總連接數小於 maximumPoolSize,則 HikariCP 將盡力快速添加新的 Connection。默認等於 maximumPoolSize。
  • maximumPoolSize:控制數據庫連接池 Connection 的最大數量,包括空閒和正在使用的。

對於 minimumIdle 和 maximumPoolSize 對數據庫連接數量的影響如下圖所示,當 minimumIdle 小於 maximumPoolSize 時,連接數量會在該區間內變化,空閒時間超過 idleTimeout 的連接會被剔除,直到數量變爲 minimumIdle 位置。

但是 HikariCP 的作者建議不設置 minimumIdle,或將其設置爲maximumPoolSize 相同數值(默認也是如此),將 HikariCP 充當一個固定大小的連接池使用,這樣可以最大限度提高性能和對突發流量的相應能力。

HikariCP 對於這些配置的默認值都進行最優配置,使用時往往不需要調整。但是使用場景千變萬化,有些情況下還是需要根據自己的情況進行調整,後續文章會對較爲重要的幾個屬性的影響和調整技巧做詳細的說明。

爲什麼這麼快

官網詳細地說明了 HikariCP 所做的一些優化,總結如下:

  • 字節碼精簡 :優化代碼,直到編譯後的字節碼最少,這樣,CPU 緩存可以加載更多的程序代碼;
  • 優化代理和攔截器:減少代碼,例如 HikariCP 的 Statement proxy 只有100行代碼,只有 BoneCP 的十分之一;
  • 自定義的 FastList 代替 ArrayList:避免每次 get 調用都要進行 range check,避免調用 remove 時的從頭到尾的掃描;
  • 自定義集合類型 ConcurrentBag,提高併發讀寫的效率;
  • 其他針對 BoneCP 缺陷的優化,比如對於耗時超過一個 CPU 時間片的方法調用的研究(但沒說具體怎麼優化)

HikariCP 具體的優化細節可以閱讀作者寫的《Down the Rabbit Hole》一文(地址鏈接在文末),Rabbit Hole 是指兔子洞,寓意是複雜奇藝且未知的境地,來自愛麗絲漫遊奇境記中愛麗絲掉入兔子洞。

下面我們就簡單說明一下幾項優化。

使用 FastList 替代 ArrayList

HikariCP 通過分析 Connection 使用 Statement 的場景,提出了使用 FastList 代替 ArrayList 的優化方案。

FastList 是一個 List 接口的精簡實現,只實現了接口中必要的幾個方法。它主要做了如下幾點優化:

  • ArrayList 每次調用 get 方法時都會進行 rangeCheck 檢查索引是否越界,其實只要保證索引合法那麼 rangeCheck 就成爲不必要的計算開銷。因此,FastList 不會進行該檢查。
  • ArrayList 的 remove(Object) 方法是從頭開始遍歷數組,而 FastList 是從數組的尾部開始遍歷,在 HikariCP 使用的場景下更爲高效。

HikariCP 使用列表來保存打開的 Statement,當 Statement 關閉或 Connection 關閉時需要將對應的 Statement 從列表中移除。通常情況下,同一個Connection創建了多個 Statement 時,後打開的 Statement 會先關閉。所以 FastList在該場景下更加高效。

優化並精簡字節碼

這裏需要聲明一項誤區,並不是使用字節碼技術使得代碼性能更好。HikariCP 使用字節碼技術的目的是減少重複代碼的編輯工作,生成統一的代碼邏輯。但是在這個基礎之上,HikariCP 優化並精簡了生成的字節碼,提高了性能。

HikariCP 使用 Java 字節碼修改類庫 Javassist 來生成委託實現動態代理。動態代理的實現在 ProxyFactory 類。Javassist 生成動態代理,是因爲其速度更快,相比於 JDK Proxy 生成的字節碼更少,精簡了很多不必要的字節碼。

HikariCP 還對項目進行了 JIT 優化。比如說 JIT 方法內聯優化默認的字節碼個數閾值爲 35 字節,低於 35 字節纔會進行優化。而 HikariCP 對自己的字節碼進行研究,精簡了部分方法的字節碼,使用了諸如減少了類繼承層次結構等方式,將關鍵部分限制在 35 字節以內,有利於 JIT 進行優化。

比如說 HikariCP 對 invokevirtual 和 invokestatic 兩種字節碼中函數調用指令的優化。

HikariCP 的早期版本使用單例工廠實例來生成 Connection、Statement 和 ResultSet 的代理。該單例工廠實例以全局靜態變量 (PROXY_FACTORY) 的形式存在。

public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
    return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}

使用這種方式,編輯出來的字節碼如下所示 (可以使用 javap 等方式查看字節碼)。下邊有詳細的註解,但更加詳細字節碼的含義還需大家自行學習一下。

public final java.sql.PreparedStatement 
prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
    flags: ACC_PRIVATE, ACC_FINAL
    Code:
      stack=5, locals=3, args_size=3
         0: getstatic     #59    // 獲取靜態變量 PROXY_FACTORY,放入操作數棧
         3: aload_0           // 本地變量0中加載值,放入操作數棧,也就是 this
         4: aload_0           // 本地變量0中加載值,放入操作數棧,也就是 this
         5: getfield      #3  // 獲取成員變量 delegate 放入操作數棧,使用操作棧中的 this
         8: aload_1          //  將本地變量1放入操作數棧,也就是 sql 變量
         9: aload_2          //  將本地變量1放入操作數棧,也就是 columnNames 變量
        10: invokeinterface #74,  3     // 調用 prepareStatement 方法
        15: invokevirtual #69              // 調用 getProxyPreparedStatement 方法
        18: return

通過上邊字節碼發現,首先要調用 getstatic 指令獲取靜態對象,然後再調用 invokevirtual 指令執行 getProxyPreparedStatement 方法。

HikariCP 後續對此進行了優化,直接使用靜態方法調用,如下所示。getProxyPreparedStatement 方法是 ProxyFactory 靜態方法。

public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
    {
        return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
    }

這些修改後,字節碼如下所示。

private final java.sql.PreparedStatement 
prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
    flags: ACC_PRIVATE, ACC_FINAL
    Code:
      stack=4, locals=3, args_size=3
         0: aload_0              
         1: aload_0
         2: getfield      #3   // 獲取 delegate 變量
         5: aload_1
         6: aload_2
         7: invokeinterface #72,  3    // 調用 prepareStatement 方法
        12: invokestatic  #67            // 調用 getProxyPreparedStatement 靜態方法
        15: areturn

這樣修改後不再需要 getstatic 指令,並且使用了 invokestatic 代替 invokevirtual 指令,前者 invokestatic 更容易被JIT優化。另外從堆棧的角度來說,堆棧大小也從原來的 5 變成了 4,方法字節碼數量也更少了。

ConcurrentBag:更好的併發集合類實現

ConcurrentBag 的實現借鑑於C#中的同名類,是一個專門爲連接池設計的lock-less集合,實現了比 LinkedBlockingQueue、LinkedTransferQueue 更好的併發性能。

ConcurrentBag 內部同時使用了 ThreadLocal 和 CopyOnWriteArrayList 來存儲元素,其中 CopyOnWriteArrayList 是線程共享的。

ConcurrentBag 採用了 queue-stealing 的機制獲取元素,首先嚐試從 ThreadLocal 中獲取屬於當前線程的元素來避免鎖競爭,如果沒有可用元素則再次從共享的 CopyOnWriteArrayList 中獲取。此外,ThreadLocal 和 CopyOnWriteArrayList 在 ConcurrentBag 中都是成員變量,線程間不共享,避免了僞共享 false sharing 的發生。

ConcurrentBag 的具體原理和實現將是下一篇文章的重點內容。

後記

按照文章開始的開源項目研究順序,下一篇文章我們會着重瞭解 HikariCP 的關鍵特性及其源碼實現,詳細分析它爲什麼這麼快,並通過 JMH 實驗數據分析這些優化是如何影響性能的。

個人博客,歡迎來玩

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章