資料庫連線池之c3p0-0.9.1.2,16年的古董,發生連線洩露怎麼查(一)

2023-07-14 06:01:21

背景

這篇文章是寫給有緣人的,為什麼這麼說呢,因為本篇主要講講資料庫連線池之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已經達到池子的最大值了,也沒法擴容),於是超時。

問題根因如何找

現在看起來,直接原因是找到了,就是有連線洩露,但是具體是哪裡有洩露呢?是不是真的有洩露呢?感覺長路仍漫漫,繼續努力吧。

留到下篇繼續吧,天也晚了,現在早上上班早,晚上不早點睡真是扛不住。