服務假死問題解決過程實記(二)——C3P0 數據庫連接池配置引發的血案

接上文《服務假死問題解決過程實記(一)——問題發現篇》


三、03.30 Tomcat 假死後續——C3P0 連接池參數配置問題

昨晚上正在看有關 B+Tree 相關的內容,收到業務組的微信消息:

最帥氣的大龍龍:現場數據庫連接不上,他們排查問題,懷疑與連接池或者日誌有關係,最後發現從昨天下午到現在產生 30 萬條日誌,其中我們就有 22 萬條,明天查一下我們服務 @琦小蝦

好吧,那就和師父一起查問題好了。第二天早上,果然數據庫組的同事過來和我們說了說情況,說現場傳來的具體情況:現場忽然之間所有業務都不能連接 Oracle,後來查詢了下原因,看到 Oracle 的監聽日誌過大,導致所有業務不能連接數據庫。後來通過某些手段打開 Oracle 監聽日誌 (listener.log),發現總共產生了 30 萬條日誌,我們業務組相關的日誌佔了 20+ 萬條。所以建議我們檢查一下數據庫連接池相關的參數。

注:Oracle 監聽日誌文件過大導致無法數據庫無法連接的相關問題參考連接:
《ORACLE的監聽日誌太大,客戶端無法連接 BUG:9879101》
《ORACLE清理、截斷監聽日誌文件(listener.log)》

數據庫組老大專門來我師父的機器上的 C3P0 的數據庫連接池相關參數,大佬感覺沒什麼問題(然而是個小坑)。那是爲什麼呢?最好的方法還是調過來開發環境的 Oracle 監聽日誌看看吧。
經過我一番猛如虎的操作,我們把日誌分析的準備工作做好了:

  1. 安裝 XShell 用 sftp 連接 Oracle 所在的 CentOS 服務器,把數據庫監聽日誌 listener.log 宕到本機;
  2. 監聽日誌記錄了兩個月的日誌信息,大小大概有 5 個多 G;記事本與 NotePad 都不能打開這麼大的日誌文件;
  3. 由於不能連接外網下載第三方工具,我在網上找了個 Java 方法,用 NIO 的方法把 5G 的日誌文件分成了 200 個文件,這樣就可以進行分析了。
public static void splitFile(String filePath, int fileCount) throws IOException {
    FileInputStream fis = new FileInputStream(filePath);
    FileChannel inputChannel = fis.getChannel();
    final long fileSize = inputChannel.size();
    long average = fileSize / fileCount;//平均值
    long bufferSize = 200; //緩存塊大小,自行調整
    ByteBuffer byteBuffer = ByteBuffer.allocate(Integer.valueOf(bufferSize + "")); // 申請一個緩存區
    long startPosition = 0; //子文件開始位置
    long endPosition = average < bufferSize ? 0 : average - bufferSize;//子文件結束位置
    for (int i = 0; i < fileCount; i++) {
        if (i + 1 != fileCount) {
            int read = inputChannel.read(byteBuffer, endPosition);// 讀取數據
            readW:
            while (read != -1) {
                byteBuffer.flip();//切換讀模式
                byte[] array = byteBuffer.array();
                for (int j = 0; j < array.length; j++) {
                    byte b = array[j];
                    if (b == 10 || b == 13) { //判斷\n\r
                        endPosition += j;
                        break readW;
                    }
                }
                endPosition += bufferSize;
                byteBuffer.clear(); //重置緩存塊指針
                read = inputChannel.read(byteBuffer, endPosition);
            }
        }else{
            endPosition = fileSize; //最後一個文件直接指向文件末尾
        }

        FileOutputStream fos = new FileOutputStream(filePath + (i + 1));
        FileChannel outputChannel = fos.getChannel();
        inputChannel.transferTo(startPosition, endPosition - startPosition, outputChannel);//通道傳輸文件數據
        outputChannel.close();
        fos.close();
        startPosition = endPosition + 1;
        endPosition += average;
    }
    inputChannel.close();
    fis.close();

}

public static void main(String[] args) throws Exception {
    long startTime = System.currentTimeMillis();
    splitFile("/Users/yangpeng/Documents/temp/big_file.csv",5);
    long endTime = System.currentTimeMillis();
    System.out.println("耗費時間: " + (endTime - startTime) + " ms");
}

好吧,終於可以分析日誌了。

既然所有業務組都在和這個 Oracle 連接,那麼就統計一下幾個流量比較大的服務的 IP 出現頻率吧。隨手打開分割的 200 箇中隨便一個日誌 (25M),首先用 NotePad 統計了一下所有業務都會訪問的共享數據 DAO 服務 IP,總共 300+ 的頻率。
我們業務 DAO 服務幾個 IP 的頻率呢?52796 + 140293 + 70802 + 142 = 264033 次……

文件裏看到了滿屏熟悉的 IP…… 沒錯,這些 IP 就是我們曾經以及正在運行過 DAO 服務的四臺主機 IP 地址…… (其中 28.1.25.91 就是區區在下臭名昭著的開發機 IP 地址)


22-MAR-2019 13:23:41 * (CONNECT_DATA=(SERVICE_NAME=DBdb)(CID=()(HOST=SC-201707102126)(USER=Administrator))) * (ADDRESS=(PROTOCOL=tcp)(HOST=28.1.25.91)(PORT=53088)) * establish * DBdb * 0
22-MAR-2019 13:23:41 * (CONNECT_DATA=(SERVICE_NAME=DBdb)(CID=()(HOST=SC-201707102126)(USER=Administrator))) * (ADDRESS=(PROTOCOL=tcp)(HOST=28.1.25.91)(PORT=53088)) * establish * DBdb * 0

**“完了,這次真的要背血鍋了。哈哈哈哈哈。”**這是我第一反應,發現了服務的坑,我竟然這麼興奮哇哈哈哈哈~

隨手選了一個文件就是這樣,檢查了一下其他分割的日誌文件,也全都是這種情況。但爲什麼這個日誌文件裏,我們四個不同的服務地址總共出現了 26 萬次 IP 地址,其中一個只有 142 次,和其他三個 IP 頻率差了這麼多?
原來這個 142 次的 IP 是我師父的開發機 IP,而且他自己也說不清楚什麼時候出於什麼思考,把數據庫的連接池給改小了(就是數據庫組大佬親自檢查後說沒有問題的那組參數),然而我和其他小夥伴的 DAO 服務 C3P0 參數沒有改過,所以只有我們三臺服務的 IP 地址顯得那麼不正常。哈哈哈我師父果然是個心機 BOY~

注:關於 C3P0 參數設置的相關內容筆者總結到了另一篇博客裏:《C3P0 連接池相關概念》

之前的 C3P0 參數是這樣的:

# unit:ms
cpool.checkoutTimeout=60000
cpool.minPoolSize=200
cpool.initialPoolSize=200
cpool.maxPoolSize=500
# unit:s
cpool.maxIdleTime=60
cpool.maxIdleTimeExcessConnections=20
cpool.acquireIncrement=5
cpool.acquireRetryAttempts=3

修改後的 C3P0 參數是這樣的:

# unit:ms
cpool.checkoutTimeout=5000
cpool.minPoolSize=5
cpool.maxPoolSize=500
cpool.initialPoolSize=5
# unit:s
cpool.maxIdleTime=0
cpool.maxIdleTimeExcessConnections=200
cpool.idleConnectionTestPeriod=60
cpool.acquireIncrement=5
cpool.acquireRetryAttempts=3

04.21 記:cpool.maxIdleTimeExcessConnections=200 依舊是個大坑,後續解析。

可以看出來,差距最大的幾個參數是:

  • cpool.checkoutTimeout: 60000 -> 5000 (單位 ms)
  • cpool.minPoolSize: 200 -> 5
  • cpool.initialPoolSize: 200 -> 5
  • cpool.maxIdleTime: 60 -> 0 (單位 s)

所以,可以總結現場出現的現象如下:

  1. 我們的 DAO 服務由於設置了 initialPoolSize 的值爲 200,所以 DAO 服務在一開始啓動的時候,就已經和 Oracle 建立了 200 個連接;
  2. 由於服務大部分時間都不會有太多人使用,所以運行過程中每超過 maxIdleTime 的時間即 60 秒後,沒有被使用到的數據庫連接被釋放。一般釋放的連接數量大約會在 195 ~ 200 個左右;
  3. 剛剛釋放了大量的數據庫連接(數量計作 size),由於 minPoolSize 設置爲 200,所以立即又會發起 size 個數據庫連接,使數據庫連接數量保持在 minPoolSize 個;
  4. 每 60s (maxIdleTime) 重複 2~3 步驟;

所以現場的 Oracle 的監聽日誌也會固定每 60 秒 (maxIdleTime) 添加約 200 條,運行了一段時間後,就出現了 Oracle 監聽日誌過大(一般情況下指一個 listener.log 監聽文件大於 4G),Oracle 數據庫無法被連接的情況。

所以,前面三月六日我發現的大量出現 1521 端口的 TIME_WAIT,就應該是 DAO 服務端檢測到有 200 個空閒連接,便爲這些連接向數據庫發送關閉請求,然後這些連接在等待 maxIdleTime 時間的過程中就進入了 TIME_WAIT 狀態。釋放這些連接後由於 minPoolSize 設置值爲 200,所以又重新發起了約 200 個新的數據庫連接。所以我如果在 cmd 中隨一定時間週期 (每 60s) 輸入 netstat -ano
| findstr “1521” 的指令,列出來的與數據庫 1521 端口應該是變動的。

TCP 四次分手示意圖

至此,數據庫連接池的問題應該是解決了。但我認爲服務假死問題應該不是出在這裏。目前懷疑的問題,有因爲虛擬機開啓了 -XDebug, -Xrunjdwp 參數,也可能是由於我們使用線程池的方式有誤。還是需要繼續進一步檢查啊。


四、04.15 100 插入併發假死問題——C3P0 連接池參數配置問題

參考地址:
《c3p0 不斷的輸出debug錯誤信息》

很長一段時間裏,在忙一些其他雜事,沒有時間開發。終於把雜事忙完之後,筆者和師父在修正了 C3P0 參數之後,開始嘗試測試併發性能。
用 LoadRunner 寫了一個腳本,同時 50 個用戶併發插入一條數據,無思考時間的插入一分鐘。腳本跑起來之後,很快服務就出現了問題。

首先,DAO 服務直接完全假死。而且由於筆者在虛擬機參數中添加了 -XX:+PrintGCDetails 參數,觀察到打印出來的 GC 日誌,竟然有一秒鐘三到四次的 FullGC!而且虛擬機的舊生代已經完全被填滿,每次 FullGC 幾乎完全沒有任何的釋放。此外,DAO 服務也會偶爾報出 OutofMemoryError,只是沒有引起虛擬機崩潰而已。
當然,軟件服務也由於大量的插入無響應,報出了大量的 Read Time Out 錯誤。

開始分析問題的時候,筆者也是一臉懵逼。打開 JVisualVM 監控 Java 堆,反覆試了多次,依舊是長時間的內存不釋放的現象。正當有一次對着 JVisualVM 監控畫面發呆,發呆到執行併發腳本幾分鐘之後,忽然我看到有一次 FullGC 直接令 Java 堆有了一次斷崖式的下降,堆內存直接下降了 80%!!
我當時就意識到這就是問題的突破點。所以由重新跑了一次併發腳本復現問題。再次卡死時,我用 jmap 指令把堆內存 Dump 下來,加載到前幾天準備好的 Eclipse 插件 Memory Analyse Tool (MAT) 中進行分析。
果然看到了很異常的 HeapDump 餅圖:1.5G 的堆內存,有 70%-80% 的容量都在存着一個名爲 newPooledConnection 的對象,這種對象的數量大概有 60 個,每個對象大小 20M 左右。這個對象是在 c3p0 的包裏,所以用腳指頭想就知道,肯定是我們的 C3P0 配置還有問題。

查了一下 C3P0 的配置參數,觀察到有一條信息:

  • maxIdelTimeExcessConnections: 這個配置主要是爲了快速減輕連接池的負載,比如連接池中連接數因爲某次數據訪問高峯導致創建了很多數據連接,但是後面的時間段需要的數據庫連接數很少,需要快速釋放,必須小於 maxIdleTime。其實這個沒必要配置,maxIdleTime 已經配置了。

而此時我看了一眼我們的 C3P0 參數,有這樣兩個參數:

cpool.maxIdleTime=0
cpool.maxIdleTimeExcessConnections=200

所以由於 cpool.maxIdleTimeExcessConnections=200 這個參數,在併發發生之後,C3P0 持續持有併發後產生的數據庫連接,直到 200s 之內沒有再複用到這些連接,纔會將其釋放。所以我之前發呆後忽然的斷崖式內存釋放,肯定就是因爲這個原因……

果然把 maxIdleTime, maxIdleTimeExcessConnections 都設置爲 0,併發插入立即變得順滑了很多。
至此,DAO 服務最重要的問題找到,對它的優化過程基本告一段落。但我們的服務依舊有很多待優化的點,也有很多業務邏輯可以優化,這是後面一段時間需要考慮的問題。


未完待續。下篇《服務假死問題解決過程實記(三)——緩存問題優化》

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