作為后臺服務開發,在日常工作中我們天天都在跟數據庫打交道,一直在進行各種CRUD操作,都會使用到數據庫連接池。按照發展歷程,業界知名的數據庫連接池有以下幾種:c3p0、DBCP、Tomcat JDBC Connection Pool、Druid 等,不過最近最火的是 HiKariCP。
HiKariCP 號稱是業界跑得最快的數據庫連接池,自從 SpringBoot 2.0 將其作為默認數據庫連接池后,其發展勢頭銳不可當。那它為什么那么快呢?今天咱們就重點聊聊其中的原因。
數據庫連接池?
在講解HiKariCP之前,我們先簡單介紹下什么是數據庫連接池(Database Connection Pooling),以及為什么要有數據庫連接池。
從根本上而言,數據庫連接池和我們常用的線程池一樣,都屬于池化資源,它在程序初始化時創建一定數量的數據庫連接對象并將其保存在一塊內存區中。它允許應用程序重復使用一個現有的數據庫連接,當需要執行 SQL 時,我們是直接從連接池中獲取一個連接,而不是重新建立一個數據庫連接,當 SQL 執行完,也并不是將數據庫連接真的關掉,而是將其歸還到數據庫連接池中。我們可以通過配置連接池的參數來控制連接池中的初始連接數、最小連接、最大連接、最大空閑時間等參數,來保證訪問數據庫的數量在一定可控制的范圍類,防止系統崩潰,同時保證用戶良好的體驗。數據庫連接池示意圖如下所示:
因此使用數據庫連接池的核心作用,就是避免數據庫連接頻繁創建和銷毀,節省系統開銷。因為數據庫連接是有限且代價昂貴,創建和釋放數據庫連接都非常耗時,頻繁地進行這樣的操作將占用大量的性能開銷,進而導致網站的響應速度下降,甚至引起服務器崩潰。
?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 核心源碼解析?
?1、FastList 是如何優化性能問題的
首先我們來看一下執行數據庫操作規范化的操作步驟:
- 通過數據源獲取一個數據庫連接;
- 創建 Statement;
- 執行 SQL;
- 通過 ResultSet 獲取 SQL 執行結果;
- 釋放 ResultSet;
- 釋放 Statement;
- 釋放數據庫連接
當前所有數據庫連接池都是嚴格地根據這個順序來進行數據庫操作的,為了防止最后的釋放操作,各類數據庫連接池都會把創建的 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 去除了檢查范圍 和 從頭到尾遍歷檢查元素的步驟,其性能更快。
?
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);}//如果角標是最后面的 直接初始化為nullelementData[--size] = null;return true;}}return false;
}
通過上述源碼分析,FastList 的優化點還是很簡單的。相比ArrayList僅僅是去掉了rage檢查,擴容優化等細節處,刪除時數組從后往前遍歷查找元素等微小的調整,從而追求性能極致。當然FastList 對于 ArrayList 的優化,我們不能說ArrayList不好。所謂定位不同、追求不同,ArrayList作為通用容器,更追求安全、穩定,操作前rangeCheck檢查,對非法請求直接拋出異常,更符合 fail-fast(快速失敗)機制,而FastList追求的是性能極致。
下面我們再來聊聊 HiKariCP 中的另外一個數據結構 ConcurrentBag,看看它又是如何提升性能的。
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()");}// 新添加的資源優先放入sharedListsharedList.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中移出/*fin查看線程本地存儲 threadList 中是否有空閑連接,如果有,則返回一個空閑的連接;如果線程本地存儲中無空閑連接,則從共享隊列 sharedList 中獲取;如果共享隊列中也沒有空閑的連接,則請求線程需要等待。*/if (!removed && !closed) {LOGGER.warn("Attempt to remove an object from the bag that does not exist: {}", bagEntry);}return removed;
}
同時ConcurrentBag通過提供的 borrow() 方法來獲取一個空閑的數據庫連接,并通過requite()方法進行資源回收,borrow() 的主要邏輯是:
- 查看線程本地存儲 threadList 中是否有空閑連接,如果有,則返回一個空閑的連接;
- 如果線程本地存儲中無空閑連接,則從共享隊列 sharedList 中獲取;
- 如果共享隊列中也沒有空閑的連接,則請求線程需要等待。
- 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 方法防止重復分配。
總結
本文首先對為什么使用數據庫連接池做介紹,通過分析HiKariCP官網介紹及其源碼,可以發現HiKariCP主要通過對字節碼進行精簡、優化代理和攔截器、自定義數組類型 FastList 及自定義并發集合類型 ConcurrentBag 等內容進行優化,文中重點講解了FastList 與ConcurrentBag 的優化原理(FastList 適用于逆序刪除場景;而 ConcurrentBag 本質上是通過 ThreadLocal 將連接池中的連接按照線程做一次預分配,避免直接競爭共享資源,減少并發CAS帶來的CPU CACHE的頻繁失效,從而提高性能,非常適合池化資源的分配),達到顯著提升數據庫連接池性能的效果。需要注意的是threadLocal可能帶來連接池關閉時引用還存在的情況,有可能導致內存泄露,因此一定要使用requite()方法來進行資源回收處理。
Hikari 作為 SpringBoot2.0默認的連接池,目前在行業內使用范圍非常廣,對于大部分業務來說,都可以實現快速接入使用,做到高效連接。