一、起因
最近檢視系統的後臺日誌,經常發現這樣的報錯資訊:The last package successfully received from the server was 40802382 milliseconds ago,截圖如下所示。
由於我們的系統都是在白天使用,夜裡基本上沒有使用者使用,再加上以上的報錯資訊都是出現在早晨,結合錯誤日誌初步分析,應該是資料庫連線超時自動斷開了。百度一番後,得知Mysql的預設連線時間是8小時,超過8小時沒有操作後就會自動斷開連線,但是已經使用了druid資料庫連線池,按理說已經對資料庫連線做了保護和檢查,不應該出現這樣的問題。要想徹底弄明白這個問題,就只能去研究druid資料庫連線池框架了。
二、Druid資料庫連線池
專案的資料庫連線池基本配置資訊如下所示
通過以上的配置分析得知,一個資料庫連線從連線池中借出後經過21600s即6小時後會被強制回收,不會超過Mysql的預設8小時,而且也不存在這麼長時間的事務,所以不太可能是因為資料庫連線借出超時導致上面的錯誤,那麼就是從資料庫連線池中申請的連線已經超時了?似乎也不太可能,因為有檢查機制,即每隔30s就會檢查一次連線池中的連線是否超時,並且連線池中允許存在的空閒連線最大時間為540s。這就奇怪了,到底是什麼原因導致上面的錯誤呢?這時注意到上述錯誤堆疊中的com.atomikos.datasource.pool.ConnectionPool.findOrWaitForAnAvailableConnection。是否問題的原因在於使用了Atomikos呢,帶著這樣的疑惑去閱讀了Druid和Atomikos相關的原始碼。
由於Atomikos連線池是基於Druid連線池之上的,所以Atomikos新建和銷燬資料庫連線都是從Druid連線池中借出和歸還資料庫連線,而不是直接與資料庫互動,那麼我們就來看看Druid是如何維持資料庫連線的。
public DruidPooledConnection getConnection(long maxWaitMillis) throws SQLException { //初始化檢查配置和後臺執行緒 init(); if (filters.size() > 0) { FilterChainImpl filterChain = new FilterChainImpl(this); return filterChain.dataSource_connect(this, maxWaitMillis); } else { return getConnectionDirect(maxWaitMillis); } }
從Druid連線池中獲取資料庫連線,先呼叫init()方法進行初始化工作,然後呼叫getConnectionDirect()獲取連線。
decrementPoolingCount(); DruidConnectionHolder last = connections[poolingCount]; connections[poolingCount] = null;
DruidPooledConnection poolalbeConnection = new DruidPooledConnection(holder); public DruidPooledConnection(DruidConnectionHolder holder){ super(holder.getConnection()); this.conn = holder.getConnection(); this.holder = holder; this.lock = holder.lock; dupCloseLogEnable = holder.getDataSource().isDupCloseLogEnable(); ownerThread = Thread.currentThread(); connectedTimeMillis = System.currentTimeMillis(); }
上述是獲取連線池中連線的關鍵程式碼,即獲取connections陣列中的最後一個元素,獲取到Holder後還需要將其封裝為DruidPooledConnection,這時該連線的connectedTimeMillis會被賦值為當前時間,這個時間在後續的分析中會非常重要。
因為配置了testWhileIdle為true,所以需要進行下面的有效性檢查,獲取該連線的上次活躍時間,得到空閒時間,如果超過30s則做有效性檢查。
long idleMillis = currentTimeMillis - lastActiveTimeMillis; long timeBetweenEvictionRunsMillis = this.timeBetweenEvictionRunsMillis; if (timeBetweenEvictionRunsMillis <= 0) { timeBetweenEvictionRunsMillis = DEFAULT_TIME_BETWEEN_EVICTION_RUNS_MILLIS; } if (idleMillis >= timeBetweenEvictionRunsMillis || idleMillis < 0 // unexcepted branch ) { boolean validate = testConnectionInternal(poolableConnection.holder, poolableConnection.conn); if (!validate) { if (LOG.isDebugEnabled()) { LOG.debug("skip not validate connection."); } discardConnection(poolableConnection.holder); continue; } }
long timeMillis = (currrentNanos - pooledConnection.getConnectedTimeNano()) / (1000 * 1000); if (timeMillis >= removeAbandonedTimeoutMillis) { iter.remove(); pooledConnection.setTraceEnable(false); abandonedList.add(pooledConnection); }
同時,由於配置了removeAbandoned為true,所以需要檢查活躍連線是否超時,如果超時就斷開物理連線。下面看一下連線池的回收方法recycle的關鍵程式碼
if (phyTimeoutMillis > 0) { long phyConnectTimeMillis = currentTimeMillis - holder.connectTimeMillis; if (phyConnectTimeMillis > phyTimeoutMillis) { discardConnection(holder); return; } } lock.lock(); try { if (holder.active) { activeCount--; holder.active = false; } closeCount++; result = putLast(holder, currentTimeMillis); recycleCount++; } finally { lock.unlock(); }
在對資料庫連線進行回收時,如果連線時間超過了資料庫的物理連線時間(預設8小時)則需要斷開物理連線,否則就呼叫putLast方法將該連線回收到連線池。
boolean putLast(DruidConnectionHolder e, long lastActiveTimeMillis) { if (poolingCount >= maxActive || e.discard) { return false; } e.lastActiveTimeMillis = lastActiveTimeMillis; connections[poolingCount] = e; incrementPoolingCount(); if (poolingCount > poolingPeak) { poolingPeak = poolingCount; poolingPeakTime = lastActiveTimeMillis; } notEmpty.signal(); notEmptySignalCount++; return true; }
注意上述標紅的地方,回收的這個連線的lastActiveTimeMillis被重新整理為當前時間,這個時間也是非常重要的,在後續分析中會用到。
三、Atomikos框架
專案關於Atomikos的配置資訊,如下所示
從上面的配置可以看出,atomikos連線池的最大連線數是25個,最小連線數是10個,連線最大的存活時間是500s,下面來看一下atomikos的原始碼。
private void init() throws ConnectionPoolException {
if ( LOGGER.isTraceEnabled() ) LOGGER.logTrace ( this + ": initializing..." );
//如果連線池最小連線數沒有達到就新增資料庫連線 addConnectionsIfMinPoolSizeNotReached();
//開啟維持連線池平衡的執行緒 launchMaintenanceTimer(); }
以上是Atomikos初始化的部分,先補充資料庫連線池達到最小連線數,然後開啟後臺執行緒維持連線池的平衡。
private void launchMaintenanceTimer() { int maintenanceInterval = properties.getMaintenanceInterval(); if ( maintenanceInterval <= 0 ) { if ( LOGGER.isTraceEnabled() ) LOGGER.logTrace ( this + ": using default maintenance interval..." ); maintenanceInterval = DEFAULT_MAINTENANCE_INTERVAL; } maintenanceTimer = new PooledAlarmTimer ( maintenanceInterval * 1000 ); maintenanceTimer.addAlarmTimerListener(new AlarmTimerListener() { public void alarm(AlarmTimer timer) { reapPool();
//如果達到了最大的存活時間就移除該連線 removeConnectionsThatExceededMaxLifetime();
//如果沒有滿足最小連線數就新增連線 addConnectionsIfMinPoolSizeNotReached();
//移除超過最小連線數以外的連線 removeIdleConnectionsIfMinPoolSizeExceeded(); } }); TaskManager.SINGLETON.executeTask ( maintenanceTimer ); }
在配置中,maintenanceInterval的值為30,即每個30秒執行一次上述的四個方法,主要看一下removeConnectionsThatExceededMaxLifetime()這個方法。
private synchronized void removeConnectionsThatExceededMaxLifetime() { long maxLifetime = properties.getMaxLifetime(); if ( connections == null || maxLifetime <= 0 ) return; if ( LOGGER.isTraceEnabled() ) LOGGER.logTrace ( this + ": closing connections that exceeded maxLifetime" ); Iterator<XPooledConnection> it = connections.iterator(); while ( it.hasNext() ) { XPooledConnection xpc = it.next(); long creationTime = xpc.getCreationTime(); long now = System.currentTimeMillis(); if ( xpc.isAvailable() && ( (now - creationTime) >= (maxLifetime * 1000L) ) ) { if ( LOGGER.isTraceEnabled() ) LOGGER.logTrace ( this + ": connection in use for more than " + maxLifetime + "s, destroying it: " + xpc );
//如果超過最大的存活時間就銷燬該連線 destroyPooledConnection(xpc); it.remove(); } } logCurrentPoolSize(); }
上述方法遍歷資料庫連線池中的所有連線,如果存活時間超過maxLifetime即500s就銷燬該連線,這時由於連線池中的連線數就小於minPoolSize,所以會立即補充新的連線到連線池中。那麼,系統在夜間沒有使用者使用時,Atomikos連線池的執行狀態為:維持最小的連線數10個資料庫連線,當這10個連線超過500s時就會銷燬,再重新建立10個新的資料庫連線,不斷重複這樣的操作。
四、分析與總結
下面我們開始分析產生錯誤日誌的原因,當沒有使用者使用系統時,Druid連線池應該有10個空閒的連線,Atomikos連線池也有10個空閒的連線,這時Atomikos的10個連線達到了最大的生存時間500s,就需要銷燬這些連線,對於Druid來說就是回收連線,呼叫recycle方法。由於這10個連線應該是500s之前從Druid連線池借出的,所以它們的connectTimeMillis也是500s之前的時間,即物理連線時間肯定小於8小時,可以成功回收到Druid連線池中,同時lastActiveTimeMillis也更新為當前時間,放在connections陣列的末尾。
與此同時,Atomikos還需要重新生成10個新的連線,即從Druid連線池獲取10個連線,呼叫getConnection方法,這時會進行有效性的檢查,又因為lastActiveTimeMillis基本上為當前時間,所以idleMillis肯定比30s小,不需要進行select 1的連線資料庫操作,這樣即使該連線已經失效了還是會借出給Atomikos。每隔500s不斷迴圈上述操作,並且期間沒有使用者的操作,一旦超過8個小時的Mysql連線時間,Atomikos在使用資料庫連線時就會產生上述日誌中的錯誤了。
綜上所述,導致報錯的原因其實是使用了兩層資料庫連線池,這樣Druid連線池借出的資料庫連線並沒有被實際使用,這才導致這些資料庫連線成功躲避了Druid本身的檢查機制。