在我們項目中,Druid提供了一個高效、功能強大、可擴展性好的數據庫連接池。我們使用他來替代C3P0作為數據庫的連接池;
翻車背景
平台私有化給一個三十人的小團隊使用,某天有人反饋平台無響應,接口全部超時無響應;
排查過程
-
連上服務器,發現服務狀態都健康,內存CPU等都很穩定;
-
jstack查看線程狀態,發現所有容器工作線程都是wait狀態,如下:
"XNIO-1 task-5" #178 prio=5 os_prio=0 tid=0x000000002a03e000 nid=0x350c waiting on condition [0x000000004013a000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000006c4f36dd0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at com.alibaba.druid.pool.DruidDataSource.takeLast(DruidDataSource.java:2029) at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1557) at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1337) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1317) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:1307) at com.alibaba.druid.pool.DruidDataSource.getConnection(DruidDataSource.java:109) at org.springframework.jdbc.datasource.DataSourceUtils.fetchConnection(DataSourceUtils.java:158) at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:116) at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:79) at org.mybatis.spring.transaction.SpringManagedTransaction.openConnection(SpringManagedTransaction.java:82) at org.mybatis.spring.transaction.SpringManagedTransaction.getConnection(SpringManagedTransaction.java:68)可以看見所有線程都卡在了DruidDataSource.takeLast方法的2029行,我們查看源碼找到這行代碼:
// maxWait是 獲取連接等待超時時間 默認是-1,即不超時,此時會走到takeLast方法 if (maxWait > 0) { holder = pollLast(nanos); } else { holder = takeLast(); } DruidConnectionHolder takeLast() throws InterruptedException, SQLException { try { while (poolingCount == 0) { emptySignal(); // send signal to CreateThread create connection if (failFast && isFailContinuous()) { throw new DataSourceNotAvailableException(createError); } notEmptyWaitThreadCount++; if (notEmptyWaitThreadCount > notEmptyWaitThreadPeak) { notEmptyWaitThreadPeak = notEmptyWaitThreadCount; } try { // 因為數據庫的連接都沒有釋放且被占用,連接池中無可用連接,導致請求被阻塞了 notEmpty.await(); // signal by recycle or creator } finally { notEmptyWaitThreadCount--; } notEmptyWaitCount++; if (!enable) { connectErrorCountUpdater.incrementAndGet(this); throw new DataSourceDisableException(); } } } catch (InterruptedException ie) { notEmpty.signal(); // propagate to non-interrupted thread notEmptySignalCount++; throw ie; } decrementPoolingCount(); DruidConnectionHolder last = connections[poolingCount]; connections[poolingCount] = null; return last; }
找到問題所在后,我們找到服務排查為什么會出現這樣的情景,發現服務里使用了多數據源,這就導致了必須自己創建DataSource,繼續查看創建DataSource的地方
@Bean("masterDataSource")
@Primary
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().type(com.alibaba.druid.pool.DruidDataSource.class).build();
}
這里創建的時候直接使用了DruidDataSource的默認配置,沒有手動修改任何配置,而在DruidDataSource中maxwait默認值為-1:
public final static int DEFAULT_MAX_WAIT = -1;
protected volatile int maxActive = DEFAULT_MAX_ACTIVE_SIZE;8個
這就會導致代碼分叉到takeLast上,然后剛好如果所有線程都被使用且沒有正常釋放,那么就會導致一直await卡死;
場景復現
那么問題的發生場景就很明了了,需要兩個條件,
-
maxWait使用默認值-1,即不會超時
-
poolingCount == 0,即線程池可用數量為0
其余參數均使用默認值;
我們嘗試使用Jmeter來復現問題;
Jmeter設置100個並發,在300秒內啟動,觀察服務;發現在並發13個左右(多次)線程就會導致服務卡死,此時Jstack查看線程發現所有線程均在wait;
因為maxActive默認值為8,所以超過8個后,一旦數據庫連接不及時釋放,則會導致poolingCount ==0,此時剛好maxWait為-1,則進入takeLast復現此問題
嘗試解決
在網上搜索‘ druid的takeLast導致卡死’,可以看到很多人遇到了同樣的問題;而且在官方的issue也可以查到相關的提交;如下:
https://github.com/alibaba/druid/issues/2376
https://github.com/alibaba/druid/issues/1160
可以看到里面的回復大多是貼上一兩個配置,其實這樣都是不可解決問題的;知其然知其所以然,我們必須弄明白問題的本事在哪里,才可以解決問題;
就此問題本身來說,看着代碼思考,那么我設置下這個參數使得參數不為默認值(-1),讓他避免這個問題不就可以了嗎?
dataSource.setMaxWait(100);
我們設置超時時間(毫秒)為100,此時我們通過壓測再來驗證一下這個問題,結果發現繼續報錯,錯誤為獲取不到連接;
因為連接默認只有8個,設置超時時間后,因為連接不能及時的釋放,所以有可能拿不到連接;
### Cause: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 101, active 8, maxActive 8, creating 0
at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:150)
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:141)
at sun.reflect.GeneratedMethodAccessor268.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:433)
... 104 common frames omitted
Caused by: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 101, active 8, maxActive 8, creating 0
所以,我們還需要配置一下連接數來保證可用,以下為druid的配置說明,可以酌情使用:
| 屬性 | 說明 | 建議值 |
|---|---|---|
| url | 數據庫的jdbc連接地址。一般為連接oracle/mysql。示例如下: | |
| mysql : jdbc:mysql://ip:port/dbname?option1&option2&… | ||
| oracle : jdbc:oracle:thin:@ip:port:oracle_sid | ||
| username | 登錄數據庫的用戶名 | |
| password | 登錄數據庫的用戶密碼 | |
| initialSize | 啟動程序時,在連接池中初始化多少個連接 | 10-50已足夠 |
| maxActive | 連接池中最多支持多少個活動會話 | |
| maxWait | 程序向連接池中請求連接時,超過maxWait的值后,認為本次請求失敗,即連接池 | 100 |
| 沒有可用連接,單位毫秒,設置-1時表示無限等待 | ||
| minEvictableIdleTimeMillis | 池中某個連接的空閑時長達到 N 毫秒后, 連接池在下次檢查空閑連接時,將回收該連接,要小於防火牆超時設置 | 見說明部分 |
| net.netfilter.nf_conntrack_tcp_timeout_established的設置 | ||
| timeBetweenEvictionRunsMillis | 檢查空閑連接的頻率,單位毫秒, 非正整數時表示不進行檢查 | |
| keepAlive | 程序沒有close連接且空閑時長超過 minEvictableIdleTimeMillis,則會執 | true |
| 行validationQuery指定的SQL,以保證該程序連接不會池kill掉,其范圍不超過minIdle指定的連接個數 | ||
| minIdle | 回收空閑連接時,將保證至少有minIdle個連接. | 與initialSize相同 |
| removeAbandoned | 要求程序從池中get到連接后, N 秒后必須close,否則druid 會強制回收該連接,不管該連接中是活動還是空閑, 以防止進程不會進行close而霸占連接。 | false,當發現程序有未正常close連接時設置為true |
| removeAbandonedTimeout | 設置druid 強制回收連接的時限,當程序從池中get到連接開始算起,超過此 | 應大於業務運行最長時間 |
| 值后,druid將強制回收該連接,單位秒。 | ||
| logAbandoned | 當druid強制回收連接后,是否將stack trace 記錄到日志中 | true |
| testWhileIdle | 當程序請求連接,池在分配連接時,是否先檢查該連接是否有效。(高效) | true |
| validationQuery | 檢查池中的連接是否仍可用的 SQL 語句,drui會連接到數據庫執行該SQL, 如果 | |
| 正常返回,則表示連接可用,否則表示連接不可用 | ||
| testOnBorrow | 程序 申請 連接時,進行連接有效性檢查(低效,影響性能) | false |
| testOnReturn | 程序 返還 連接時,進行連接有效性檢查(低效,影響性能) | false |
| poolPreparedStatements | 緩存通過以下兩個方法發起的SQL: | true |
| public PreparedStatement prepareStatement(String sql) | ||
| public PreparedStatement prepareStatement(String sql, | ||
| int resultSetType, int resultSetConcurrency) | ||
| maxPoolPrepareStatementPerConnectionSize | 每個連接最多緩存多少個SQL | 20 |
| filters | 這里配置的是插件,常用的插件有: | stat,wall,slf4j |
| 監控統計: filter:stat | ||
| 日志監控: filter:log4j 或者 slf4j | ||
| 防御SQL注入: filter:wall | ||
| connectProperties | 連接屬性。比如設置一些連接池統計方面的配置。 | |
| druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000 | ||
| 比如設置一些數據庫連接屬性 |
了解了每個配置的作用,那么我們只需要合理的進行配置即可解決這個問題。以下為經過壓測驗證后的我們服務的配置,供參考:
// 初始連接數
dataSource.setInitialSize(10);
// 最小連接池數量
dataSource.setMinIdle(10);
// 最大連接池數量
dataSource.setMaxActive(100);
// 配置獲取連接等待超時時間 毫秒
dataSource.setMaxWait(100);
//緩存通過以下兩個方法發起的SQL:
dataSource.setPoolPreparedStatements(true);
//每個連接最多緩存多少個SQL
dataSource.setMaxPoolPreparedStatementPerConnectionSize(50);
//檢查空閑連接的頻率,單位毫秒, 非正整數時表示不進行檢查
dataSource.setTimeBetweenEvictionRunsMillis(-1);
//池中某個連接的空閑時長達到 N 毫秒后, 連接池在下次檢查空閑連接時,將回收該連接,要小於防火牆超時設置
dataSource.setMinEvictableIdleTimeMillis(300000);
//當程序請求連接,池在分配連接時,是否先檢查該連接是否有效。(高效)
dataSource.setTestWhileIdle(true);
// 程序 申請 連接時,進行連接有效性檢查(低效,影響性能)
dataSource.setTestOnBorrow(false);
//程序 返還 連接時,進行連接有效性檢查(低效,影響性能)
dataSource.setTestOnReturn(false);
// 要求程序從池中get到連接后, N 秒后必須close,否則druid 會強制回收該連接,不管該連接中是活動還是空閑, 以防止進程不會進行close而霸占連接。
dataSource.setRemoveAbandoned(true);
// 設置druid 強制回收連接的時限,當程序從池中get到連接開始算起,超過此值后,druid將強制回收該連接,單位秒。
// 結合業務來看,存在jpa極大事務;不好設置 暫時為設置兩分鍾
dataSource.setRemoveAbandonedTimeout(120);
//當druid強制回收連接后,是否將stack trace 記錄到日志中
dataSource.setLogAbandoned(true);
題外話:
對於此類問題,官方給的回復是我們沒有正確的關閉連接,導致連接泄漏;
這樣通過配置的方法雖然解決了問題,但是為什么其他連接池就沒有此類問題;也不需要人為的來通過配置避免此'BUG'呢?不得而知
