數據庫連接池之c3p0-0.9.1.2,16年的古董,發生連接泄露怎麼查(一)

背景

這篇文章是寫給有緣人的,爲什麼這麼說呢,因爲本篇主要講講數據庫連接池之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:

image-20230713202730043

再下一個版本是2012年的0.9.2-pre2-RELEASE,來到了2012年,座標改成了:

<!-- https://mvnrepository.com/artifact/com.mchange/c3p0 -->
<dependency>
    <groupId>com.mchange</groupId>
    <artifactId>c3p0</artifactId>
</dependency>

後續的更新版本如下:

image-20230713202934354

可以看到,維護到15年後,又消失了幾年,直到19年又重新維護了一年,然後就再無動靜。

所以,爲啥我覺得還是可以講講c3p0-0.9.1.2這個版本呢,因爲據說當年還是比較火的,很多那時候的項目都用了這個版本,然後就一直再沒有升級(想升也沒得升啊),所以,我估計,如果那些老項目還在維護的話,估計有不少有緣人還在和這個c3p0-0.9.1.2打交道,我,就是其中一個。

在一些求穩的行業,線上能跑的項目,那肯定是沒人會去大動的,只會不斷地添磚加瓦,而這也導致更難大動,如果沒被重構掉的話,就遺留到了現在。

我現在手裏的維護的一個項目,就是用的這個框架,而且,它很容易有bug,不信的話,搜索看看:

image-20230713203545631

本文,就打算來講講我遇到的問題和這個框架的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日誌,那基本看不出個啥,就是大片大片的等待從連接池獲取連接,最後直到超時都獲取不到的報錯。

image-20230713211244859

當時苦於沒有其他手段,又是偶現,也看不出個啥,找dba了也看過db,dba表示運行穩定,當然,dba說的也不一定準,反正是沒收穫。

後來,2月份的時候,搞了個腳本,服務出現問題的時候,先執行下腳本,打印下jstack、jmap、netstat、top等一些東西,而一開始的時候,運維經常忘記執行,直接就重啓了,於是只能等下次,直到2月底的某一天吧,總算是執行了下腳本,拿到了jmap等信息。

jmap確認直接原因

查看資源池現狀

分析jmap,個人習慣用MAT。MAT支持object query language語言進行堆對象查詢,具體語法可以自己學一學。

我就如下圖所示,查詢連接池的情況,我這邊有多數據源,所以有多個連接池,其中有問題的那個連接池,池子裏維護的連接有40個:

image-20230713212745805

這裏有必要說一下,這個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空閒鏈表。

現狀反映出的問題

問題如下,空閒鏈表爲空,連接池被出借一空:

image-20230713212745805

image-20230713223407432

隨便找了個連接看出借時間:

image-20230713223738787

image-20230713223754293

這個時間,距離執行jmap的時候,已經過去了一分鐘了,而大部分的punchCard都是這樣,這說明了什麼,說明了這些連接被借出去一分鐘了,都還沒有歸還到unused空閒鏈表,導致空閒鏈表size爲0,後續的請求在unused上死等也等不到連接(因爲managed已經達到池子的最大值了,也沒法擴容),於是超時。

問題根因如何找

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

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

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