【線上問題】由防火牆導致的數據庫空閒連接斷開問題

問題描述

公司一個新項目上線,處於試運行階段,這個項目雖然是外網可訪問的,故部署在了DMZ區,但試運行階段只給了公司內少部分員工地址和賬號(其中包括一些領導),故訪問量很小,但項目還是挺重要的。
試運行階段中,項目應用日誌中不定期會報異常,尤其是在剛上午剛開始使用時,還有空閒一段時間後再次使用時,具體異常如下:

ERROR [com.alibaba.druid.util.JdbcUtils] - close connection error
java.sql.SQLRecoverableException: IO Error: Broken pipe

    at oracle.jdbc.driver.T4CConnection.logoff(T4CConnection.java:556)
    at oracle.jdbc.driver.PhysicalConnection.close(PhysicalConnection.java:3984)
    at com.alibaba.druid.filter.FilterChainImpl.connection_close(FilterChainImpl.java:167)
    at com.alibaba.druid.filter.stat.StatFilter.connection_close(StatFilter.java:254)
    at com.alibaba.druid.filter.FilterChainImpl.connection_close(FilterChainImpl.java:163)
    at com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl.close(ConnectionProxyImpl.java:115)
    at com.alibaba.druid.util.JdbcUtils.close(JdbcUtils.java:79)
    at com.alibaba.druid.pool.DruidDataSource.discardConnection(DruidDataSource.java:965)
    at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:932)
    at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:4534)
    at com.alibaba.druid.filter.stat.StatFilter.dataSource_getConnection(StatFilter.java:661)
    at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:4530)
    at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:884)
    at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:876)
    at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:92)
    at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(DataSourceTransactionManager.java:205)
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:373)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:420)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:257)
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:95)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:92)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:644)
    at xxx.xx.modules.deposit.api.service.DepositApiService$$EnhancerBySpringCGLIB$$59c8f6e2.doRecharge()
    at xxx.xx.modules.deposit.FundDepositController.rechargeConfirm(FundDepositController.java:125)
......

Caused by: java.net.SocketException: Broken pipe
    at java.net.SocketOutputStream.socketWrite0(Native Method)
    at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:113)
    at java.net.SocketOutputStream.write(SocketOutputStream.java:159)
    at oracle.net.ns.DataPacket.send(DataPacket.java:210)
    at oracle.net.ns.NetOutputStream.flush(NetOutputStream.java:230)
    at oracle.net.ns.NetInputStream.getNextPacket(NetInputStream.java:312)
    at oracle.net.ns.NetInputStream.read(NetInputStream.java:260)
    at oracle.net.ns.NetInputStream.read(NetInputStream.java:185)
    at oracle.net.ns.NetInputStream.read(NetInputStream.java:102)
    at oracle.jdbc.driver.T4CSocketInputStreamWrapper.readNextPacket(T4CSocketInputStreamWrapper.java:124)
    at oracle.jdbc.driver.T4CSocketInputStreamWrapper.read(T4CSocketInputStreamWrapper.java:80)
    at oracle.jdbc.driver.T4CMAREngine.unmarshalUB1(T4CMAREngine.java:1137)
    at oracle.jdbc.driver.T4CTTIfun.receive(T4CTTIfun.java:290)
    at oracle.jdbc.driver.T4CTTIfun.doRPC(T4CTTIfun.java:192)
    at oracle.jdbc.driver.T4C7Ocommoncall.doOLOGOFF(T4C7Ocommoncall.java:61)
    at oracle.jdbc.driver.T4CConnection.logoff(T4CConnection.java:543)
    ... 69 more

從異常信息可以看出,問題是發生在Druid數據庫連接池在關閉物理數據庫連接時,報了 SocketException: Broken pipe,但爲什麼在使用時Druid會關閉數據庫連接,關閉數據連接又爲什麼會報SocketException呢?這個異常到底對系統有多大的影響呢?下面一步步分析。

問題逐步分析

1、java.net.SocketException: Broken pipe異常是怎麼產生的?有什麼影響?

項目中使用是的Druid連接數據庫,可爲什麼在系統空閒一段時間後再使用,會嘗試關閉數據庫連接,而且關閉的時候還拋了 java.net.SocketException: Broken pipe 呢?
從異常堆棧信息,或者翻看Druid源碼可以知道,異常是發生在從數據庫連接池中獲取連接,用於後續數據庫操作時,在執行到DruidDataSource.getConnectionDirect(maxWaitMillis)方法時,有如下邏輯:

public DruidPooledConnection getConnectionDirect(long maxWaitMillis) throws SQLException {
    //循環
    for (;;) {
        //maxWaitMillis時間內從連接池獲取一個連接
        DruidPooledConnection poolalbeConnection = getConnectionInternal(maxWaitMillis);

        //testOnBorrow爲true,即從池中獲取連接後需要檢查連接
        if (isTestOnBorrow()) {
            boolean validate = testConnectionInternal(poolalbeConnection.getConnection());
            if (!validate) {
                if (LOG.isDebugEnabled()) {
                    LOG.debug("skip not validate connection.");
                }

                Connection realConnection = poolalbeConnection.getConnection();
                discardConnection(realConnection);
                continue;
            }
        } 
        else {
            Connection realConnection = poolalbeConnection.getConnection();
            //如果連接已經關閉,再從池中獲取一個
            if (realConnection.isClosed()) {
                discardConnection(null); // 傳入null,避免重複關閉
                continue;
            }

            //testWhileIdle爲true,即空閒後需要檢查連接
            if (isTestWhileIdle()) {
                //連接空閒時間(當前時間 - 上次ActiveTime)
                long idleMillis = System.currentTimeMillis()
                                  - poolalbeConnection.getConnectionHolder().getLastActiveTimeMillis();
                
                //連接空閒時間 > timeBetweenEvictionRunsMillis,檢查連接
                if (idleMillis >= this.getTimeBetweenEvictionRunsMillis()) {
                    boolean validate = testConnectionInternal(poolalbeConnection.getConnection());
                    
                    //連接檢查失敗,打印log,丟棄連接,再獲取一個連接
                    if (!validate) {
                        if (LOG.isDebugEnabled()) {
                            LOG.debug("skip not validate connection.");
                        }

                        discardConnection(realConnection);
                        continue;
                    }
                }
            }
        }

        //如果開啓了連接超時回收
        if (isRemoveAbandoned()) {
            StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
            poolalbeConnection.setConnectStackTrace(stackTrace);
            poolalbeConnection.setConnectedTimeNano(); //設置當前時間爲ConnectedTime
            poolalbeConnection.setTraceEnable(true);

            synchronized (activeConnections) {
                activeConnections.put(poolalbeConnection, PRESENT); //將連接放入activeConnections Map
            }
        }

        if (!this.isDefaultAutoCommit()) {
            poolalbeConnection.setAutoCommit(false);
        }

        return poolalbeConnection;
    }
}

簡單來說,在從Druid獲取數據庫連接時,可以進行test,這段代碼中包含testOnBorrow(借出時檢查)和testWhileIdle(空閒時檢查)的邏輯,此項目在配置文件中

testOnBorrow = false
testWhileIdle = true
timeBetweenEvictionRunsMillis = 60000(60s)

故只會在連接空閒60s後再次使用時進行檢測,其實就是執行一個SQL,而在執行SQL時如果失敗了,就會調用JdbcUtils.close(realConnection)關閉連接,在關閉這個連接時拋了SocketException異常,但其實這個異常倒不會對希望獲取Connection執行SQL查詢的程序造成太大影響,因爲JdbcUtils.close()方法中捕獲了這個異常,打印log,並沒有上拋

public static void close(Connection x) {
    if (x == null) {
        return;
    }
    try {
        x.close();
    } catch (Exception e) {
        LOG.debug("close connection error", e);
    }
}

那麼java.net.SocketException: Broken pipe是什麼意思呢?
其實就是與數據庫建立的tcp連接因爲某些原因斷開了,而導致了“管道破裂”。一般數據庫連接池會與數據庫保持長連接,在需要的時候省去建立連接的過程,直接使用,而爲什麼這些空閒的連接會被斷開呢?被誰斷開了?

2、爲什麼數據庫TCP連接會被斷開?

一開始百思不得其解,想着是因爲Oracle數據庫主動斷開了連接嗎?因爲某些原因,比如從服務器到數據庫的連接太多?明顯不是,這個項目還在試運行階段,用的人不多,且觀察Druid的連接池監控,一般建立的連接也就幾個
後來和同事討論的過程中得知別的項目組也發生過類似的情況,而他們和這個項目的共同之處就在於服務都是在DMZ區,外網可訪問,而數據庫在內網,需要通過防火牆才能訪問到數據庫。於是去找負責維護網絡、防火牆的同事瞭解,原來防火牆有一個TCP超時時間,目前設置的爲半小時,其意義是,對於通過防火牆的所有TCP連接,如果在半小時內沒有任何活動,就會被防火牆拆除,這樣就會導致連接中斷。在拆除連接時,也不會向連接的兩端發送任何數據來通知連接已經拆除。
這下數據庫連接斷開的原因找到了,那麼這就是一個應用與數據庫在不同的網絡中,連接需要經過防火牆的場景中會遇到的一個典型問題,怎麼能夠使應用和數據庫之間即使比較空閒也能夠保持一定數量的長連接,是亟待解決的。

3、防火牆切斷數據庫連接會造成的影響

數據庫會話正在執行耗時長的SQL
切斷連接之前,連接對應的Oracle會話正在執行一個耗時特別長的SQL,比如存儲過程而在此過程中沒有任何數據輸出到客戶端,這樣當SQL執行完成之後,向客戶端返回結果時,如果TCP連接已經被防火牆中斷,這時候顯然會出現錯誤,連接中斷,那麼會話也就會中斷。但是客戶端還不知道,會一直處於等待服務器返回結果的狀態。
如果客戶端沒有針對這種執行耗時長的SQL的連接回收機制,那麼客戶端這個連接將一直處於等待狀態,如果客戶端不斷執行這種耗時長SQL,那麼客戶端堆積的等待連接將越來越多。
Druid連接池的removeAbandoned相關配置以及邏輯,就是爲了解決這種連接回收設置的。

數據庫會話空閒
切斷連接之前,Oracle會話一直處於空閒狀態,在防火牆中斷之後,客戶端向Oracle服務器提交SQL時,由於TCP連接已經中斷,這時客戶端偵測到連接中斷,那麼客戶端就會報ORA-03113/ORA-03114這類錯誤,然後會話中斷。但是在Oracle服務器端,會話一直在處於等待客戶端消息的狀態。
而對於Druid這種有testOnBorrow、testWhileIdle的檢測機制,且檢測失敗可以重新建立連接的連接池,空閒的被防火牆切斷的連接在後續會被不斷重建,而在數據庫服務器端,則連接越來越多,即會話數越來越多,甚至最終超過了數據爲最大連接數。

解決方法

1、調大防火牆的連接切斷時長

這是一個臨時解決方法,比如將防火牆的連接超時時間調整爲8小時,這樣可以儘量避免空閒連接的切斷,但無法完全避免,因爲無法預計連接會被空閒多久,如果你的系統不是總有人訪問的話,那麼連接遲早會因爲空閒而被切斷,導致一些不可預計的問題,而調大超時時間只是緩解而已

2、tcp keepalive功能

tcp的keepalive,其實就是用來保持tcp連接的,其原理簡單說就是如果一個TCP連接在指定的時間內沒有任何活動,會發送一個探測包到連接的對端,檢測連接的對端是否仍然存在,如果對端一定時間內仍沒有對探測的響應,會再次發送探測包,發送幾次後,仍然沒有響應,就認爲連接已經失效,關閉本地連接。
tcp keepalive並不是默認開啓的,在開發程序時可以設置tcp keepalive爲true,這樣tcp連接在一定時間內沒有任何數據報文傳輸則啓動探測,這個時間一般是操作系統規定,Linux系統中可以通過設置net.ipv4.tcp_keepalive_time來修改,默認是7200秒,即2小時。當然在編程時也可以設置這個時間用於當前socket,但是Java的Socket API中好像只有設置keepalive=true,並沒法設置tcp_keepalive_time
當設置了tcp keepalive之後,只要tcp探測包發送的時間小於防火牆的連接超時時間,防火牆就會檢查到連接中仍然有數據傳輸,就不會斷開這個連接。

使用JDBC創建的數據庫tcp連接是沒有設置keepalive的,這點可以通過Linux的netstat或ss命令在數據庫客戶端(即應用端)驗證
使用命令netstat -anoss -ano,其中參數o都是顯示timer計時器,timer計時器在連接建立狀態下可以對連接保活計時
netstat命令對沒有開啓keepalive的tcp連接顯示爲:off (0.00/0/0)
ss命令對沒有keepalive的tcp連接,不會顯示timer計時器

3、Oracle數據庫的DCD

Oracle提供了類似tcp keepalive的機制,也就是DCD(Dead Conneciton Detection)。在$ORACLE_HOME/network/admin/sqlnet.ora文件中增加如下一行:

sqlnet.expire_time=NNN

這裏NNN爲分鐘數,Oracle數據庫會在會話IDLE時間超過這個指定的時間時,檢測這個會話的對端(即客戶端)是否還有效。避免客戶端由於異常退出,導致會話一直存在。
同樣的如果DCD的時間比防火牆切斷空閒連接的時間短,連接也可以一直保持

4、程序不定時執行查詢

以上幾種方法要麼是利用tcp連接keepalive特性,要麼是採用數據庫端的空閒連接檢測,我們的程序中也可以主動做這種心跳檢測

Druid數據庫連接池從1.0.28開始,添加了druid.keepAlive屬性,默認關閉
打開druid.keepAlive之後,當連接池空閒時,池中的minIdle數量以內的連接,空閒時間超過minEvictableIdleTimeMillis,則會執行keepAlive操作,即執行druid.validationQuery指定的查詢SQL,一般爲select * from dual,只要minEvictableIdleTimeMillis設置的小於防火牆切斷連接時間,就可以保證當連接空閒時自動做保活檢測,不會被防火牆切斷

參考:
Oracle與防火牆
防火牆、DCD與TCP Keep alive

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