背景
這篇文章是寫給有緣人的,爲什麼這麼說呢,因爲本篇主要講講數據庫連接池之c3p0-0.9.1.2版本。
年輕的朋友,可能沒怎麼聽過c3p0了,或者也僅限於聽說,這都很正常,因爲c3p0算是200幾年時比較流行的技術,後來,作者消失了好幾年,12年重新開始維護,這時候已經出現了很多第二代線程池了,c3p0已經不佔優勢,就這樣,又維護了幾年,直到19年徹底停止更新。
看下其版本歷史吧,一開始的maven座標是這樣的:
<!-- https://mvnrepository.com/artifact/c3p0/c3p0 -->
<dependency>
<groupId>c3p0</groupId>
<artifactId>c3p0</artifactId>
</dependency>
07年發了最後一個版本c3p0-0.9.1.2:
再下一個版本是2012年的0.9.2-pre2-RELEASE,來到了2012年,座標改成了:
<!-- https://mvnrepository.com/artifact/com.mchange/c3p0 -->
<dependency>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
</dependency>
後續的更新版本如下:
可以看到,維護到15年後,又消失了幾年,直到19年又重新維護了一年,然後就再無動靜。
所以,爲啥我覺得還是可以講講c3p0-0.9.1.2這個版本呢,因爲據說當年還是比較火的,很多那時候的項目都用了這個版本,然後就一直再沒有升級(想升也沒得升啊),所以,我估計,如果那些老項目還在維護的話,估計有不少有緣人還在和這個c3p0-0.9.1.2打交道,我,就是其中一個。
在一些求穩的行業,線上能跑的項目,那肯定是沒人會去大動的,只會不斷地添磚加瓦,而這也導致更難大動,如果沒被重構掉的話,就遺留到了現在。
我現在手裏的維護的一個項目,就是用的這個框架,而且,它很容易有bug,不信的話,搜索看看:
本文,就打算來講講我遇到的問題和這個框架的0.9.1.2版本的大概的源碼邏輯。
我遇到的線上問題
我目前手裏這套服務的代碼框架應該是0幾年誕生的,不是市面上曾經流行的框架,如struts、spring mvc那些,而是c++開發的類比netty、servlet容器的東西,在監聽端口收到客戶端請求後,能根據請求中的功能id來反向調用對應的java代碼,還是有點東西的。而java代碼裏也是一套框架,框架源碼還失傳了,框架裏代碼定死了用c3p0這個來創建數據庫連接池,導致我想換也不好換,比較費勁。
業務層呢,託了jdbc規範的福,就是隻和jdbc的api打交道,比如找datasource拿connection,這個拿,一般也就是從連接池裏面取,用完了,再調用connection.close(內部會把連接再還回連接池)。
所以,我們線上到底有啥問題呢?具體表現就是,業務會突然在某個時刻,調用datasource.getConnection的時候,取不到連接,直接超時,而且是全部的業務請求都出這個問題,這時候,服務基本就hang死了,前端一直轉圈。
這個是完全隨機的,不定時地炸,每次炸了後,就要靠運維同事重啓服務,重啓後,服務就好了。
下面來說說定位的過程吧,現在其實也沒找到根本原因,只是有瞭解決的辦法和一些猜測,可以等下次再出現的時候,驗證一下。
定位初始
剛開始的時候,線上服務只有日誌,而且只有error日誌,那基本看不出個啥,就是大片大片的等待從連接池獲取連接,最後直到超時都獲取不到的報錯。
當時苦於沒有其他手段,又是偶現,也看不出個啥,找dba了也看過db,dba表示運行穩定,當然,dba說的也不一定準,反正是沒收穫。
後來,2月份的時候,搞了個腳本,服務出現問題的時候,先執行下腳本,打印下jstack、jmap、netstat、top等一些東西,而一開始的時候,運維經常忘記執行,直接就重啓了,於是只能等下次,直到2月底的某一天吧,總算是執行了下腳本,拿到了jmap等信息。
jmap確認直接原因
查看資源池現狀
分析jmap,個人習慣用MAT。MAT支持object query language語言進行堆對象查詢,具體語法可以自己學一學。
我就如下圖所示,查詢連接池的情況,我這邊有多數據源,所以有多個連接池,其中有問題的那個連接池,池子裏維護的連接有40個:
這裏有必要說一下,這個managed:
/* keys are all valid, managed resources, value is a PunchCard */
HashMap managed = new HashMap();
這個hashmap,就是連接池。
初始化連接池--維護managed、unused
那麼,它是怎麼初始化的呢,以下面的參數舉例:
<property name="minPoolSize">10</property>
<property name="maxPoolSize">50</property>
在BasicResourcePool的構造函數中,就會調用如下方法:
//start acquiring our initial resources
ensureStartResources();
private void ensureStartResources()
{ recheckResizePool(); }
具體就會調用:
private void expandPool(int count)
{
for (int i = 0; i < count; ++i)
taskRunner.postRunnable( new AcquireTask() );
}
c3p0會計算出,需要建10個連接出來,上面的count就是10,那麼會new 10個runnable,提交給線程池執行,在每個線程執行時:
private void doAcquire() throws Exception
{
// 1 交給具體的manager去獲取底層連接
Object resc = mgr.acquireResource();
...
// 2 拿到連接後,維護到池子裏
assimilateResource(resc);
}
這裏的mgr,負責具體去創建數據庫連接,由於涉及到多種數據庫,因此mgr就負責具體髒活累活,連接池這邊就不和這些髒話累活打交道,就是類似於我們代碼分層架構中的,用來操作redis、es、第三方服務等的一個層,相當於把一些通用的業務邏輯下沉。
而上面2處的代碼,就負責池子維護:
private void assimilateResource( Object resc ) throws Exception
{
// 1
managed.put(resc, new PunchCard());
// 2
unused.add(0, resc);
// 3
this.notifyAll();
}
這裏的1處,就會往managed裏面存放連接,key就是創建的連接,那麼value是啥呢?
final static class PunchCard
{
long acquisition_time;// 創建時間
long last_checkin_time; //上次歸還到連接池的時間
long checkout_time; // 從連接池借出的時間,未借出時,值爲-1
Exception checkoutStackTraceException; // 被借出時,該借出線程的堆棧
PunchCard()
{
this.acquisition_time = System.currentTimeMillis();
this.last_checkin_time = acquisition_time;
this.checkout_time = -1;
this.checkoutStackTraceException = null;
}
}
Punchcard這個詞,翻譯的意思是:穿孔卡(舊時把信息打成一排排的小孔,用以將指令輸入計算機等),我這邊就理解成這個數據庫連接的一些記錄出借/歸還信息的卡片。
裏面有個checkout_time字段,初始化的值是 -1,表示未被出借。
另外,還有個重要字段,unused,這個主要是存放可供出借的連接。
/* all valid, managed resources currently available for checkout */
LinkedList unused = new LinkedList();
上面的2處,就會把新的連接往這裏面放,放完後,用notify通知其他消費者線程。
綜上所述,剛開始的時候,
<property name="minPoolSize">10</property>
<property name="maxPoolSize">50</property>
managed的size是10,unused也是10
連接池出借連接的邏輯
檢查unused是否有空閒連接
private synchronized Object prelimCheckoutResource( long timeout ){
int available = unused.size();
if (available == 0){
// 檢查是否可以擴容,可以的話,觸發擴容後開始等待。擴容也是異步的,擴容成功的話,unused的size就大於0
...
}
Object resc = unused.get(0);
// 檢查連接是否過期了,如果過期了,這個連接不能要,得銷燬
if ( shouldExpire( resc ) )
{
removeResource( resc );
ensureMinResources();
return prelimCheckoutResource( timeout );
}
else
{ // 連接可用,那就從unused中摘除本連接並返回
unused.remove(0);
return resc;
}
}
檢查連接是否真實有效
boolean refurb = attemptRefurbishResourceOnCheckout( resc );
if (!refurb)
{
removeResource( resc );
ensureMinResources();
resc = null;
}
這個步驟類似於在連接上執行一個select 1,檢查連接到底能不能用。不能用的話,銷燬連接。
借到連接後,維護出借卡片
PunchCard card = (PunchCard) managed.get( resc );
card.checkout_time = System.currentTimeMillis();
if (debug_store_checkout_exceptions)
card.checkoutStackTraceException = new Exception("DEBUG ONLY: Overdue resource check-out stack trace.");
這裏就是,獲取到這個連接的punchCard信息卡,然後登記出借的時間爲當前時間,那麼,是誰借了呢,這裏是通過new一個異常的方式,通過這個異常,就能知道當前線程的堆棧。
用完後,歸還連接給連接池
if (managed.keySet().contains(resc))
doCheckinManaged( resc );
這個歸還呢,如下,也不是直接歸還,竟然也是new一個runnable去歸還,個人覺得,這個有巨大的隱患,因爲線程池是可能會堵的,而這個就極有可能導致還不進去。
private void doCheckinManaged( final Object resc ){
Runnable doMe = new RefurbishCheckinResourceTask();
taskRunner.postRunnable( doMe );
}
class RefurbishCheckinResourceTask implements Runnable
{
public void run()
{
// 1 歸還前試着測試下連接是否能用,比如select 1
boolean resc_okay = attemptRefurbishResourceOnCheckin( resc );
// 2 獲取卡片並更新卡片
PunchCard card = (PunchCard) managed.get( resc );
if ( resc_okay && card != null)
{
// 3
unused.add(0, resc );
card.last_checkin_time = System.currentTimeMillis();
card.checkout_time = -1;
}
BasicResourcePool.this.notifyAll();
}
}
這裏主要就是,歸還前先測試下連接是不是好的,免得還個壞的進去;再就是,拿到之前的出借卡,更新歸還時間爲當前時間、借出時間改爲-1;再把連接放回到unused空閒鏈表。
現狀反映出的問題
問題如下,空閒鏈表爲空,連接池被出借一空:
隨便找了個連接看出借時間:
這個時間,距離執行jmap的時候,已經過去了一分鐘了,而大部分的punchCard都是這樣,這說明了什麼,說明了這些連接被借出去一分鐘了,都還沒有歸還到unused空閒鏈表,導致空閒鏈表size爲0,後續的請求在unused上死等也等不到連接(因爲managed已經達到池子的最大值了,也沒法擴容),於是超時。
問題根因如何找
現在看起來,直接原因是找到了,就是有連接泄露,但是具體是哪裏有泄露呢?是不是真的有泄露呢?感覺長路仍漫漫,繼續努力吧。
留到下篇繼續吧,天也晚了,現在早上上班早,晚上不早點睡真是扛不住。