Tomcat-JDBC原始碼解析及優化
資料庫連線池
連線池是常見的一種資源複用的技術。利用連線池,可以將那些建立開銷較大的資源匯聚到一個池子裡快取起來,需要使用的時候只需要從連線池裡取出來就可以了。中間省去了頻繁的建立和銷燬的過程。資料庫連線池就是其中的典型應用。
深入Tomcat-JDBC
Tomcat-JDBC
是Spring Boot
中自動配置優先順序最高的連線池方案,它的出現是用來替代Apache
早期的連線池產品——DBCP 1.x
。總得來說,各款連線池的原理大同小異,具體還得看細節,比如某些早期連線池對於併發和利用CPU多核考慮得就不夠到位。
在介紹Tomcat-JDBC
之前,我們可以簡單的思考一下,假設讓我們來實現一個數據庫連線池,會有哪些問題需要解決?
- 如何保障快取連線的有效性
- 如何維護連線池中連線的數量
現在,我們可以帶著上面的兩個問題來看看Tomcat-JDBC
的實現細節
核心動作解析
連線池初始化
資料庫連線池初始化的核心就是:
- 構建idle和busy佇列
- 根據引數決定是否啟動清潔工任務
- JdbcInterceptors的初始化
- 根據引數initialSize建立初始化連線
TIPS:在【初始化JdbcInterceptors】環節,會呼叫每個攔截器的poolStarted方法。但是這裡的JdbcInterceptor例項只是臨時建立,不會在後續使用,所以在自己實現JdbcInterceptor重寫poolStarted方法的時候,不要裡面操作類的成員變數,只有操作靜態變數才是有意義的
清潔工任務是什麼?
上圖主要可能會涉及到三塊清理業務:
- 取出後長期未操作且未還回連線池的連線
- idle佇列長度大於minIdle時的縮容
- idle佇列中的所有連線的有效性校驗
其中有幾點可能從上圖看不是特別清楚的再說明下:
- waitCount是什麼?在從連線池獲取連線時,如果連線池的連線數量已達到maxActive,且全部被佔用,且maxWait配置大於0,那麼會進入wait狀態,此時waitCount會加1,後面在【獲取連線】這個核心步驟的時候還會提到
- connectionVersion 和 poolVersion 是什麼?這個是連線池用來實現purge功能的,也就是可以將連線池中的當前連線全部失效。這個功能最簡單的做法就是給pool設計一個版本,比如為V1,那麼從V1創建出來的連線的版本也都是V1。這個時候想失效所有連線,只需要把poolVersion升到V2,那麼V1的連線將全部失效。
- 針對於第二個任務,有個可優化的點,可以先判斷minEvictableIdleTimeMillis是否大於0,如果不大於0,那麼都不需要再遍歷idle隊列了
- 針對於第三個任務,如何判斷連線有效性?在下一節【有效性驗證】中會有講解,此處對應的型別為
testWhileIdle
哪些引數控制著清潔工任務的啟動?
- timeBetweenEvictionRunsMillis,清潔工任務頻率
- removeAbandoned/removeAbandonedTimeout,是否釋放取出時間過長的連線
- suspectTimeout,疑似超時
- testWhileIdle/validateQuery,定時測試空閒連線
- minEvictableIdleTimeMillis,在連線池閒置一定時間會被當做空閒連線
1是必要條件,2|3|4|5 只要有一個滿足,那麼就會啟動清潔工任務
有效性驗證
還記得剛才的清潔工任務中有一個是專門來檢查空閒連線的有效性的,下面就來介紹如何判斷一個連線的有效性:
其中,所有型別的驗證都在這裡維護,包括
- 建立連線時 testOnConnect
- 從連線池取出時 testOnBorrow
- 放回連線池時 testOnReturn
- Idle連線 testWhileIdle
上面這四種類型,在【需要驗證】這個步驟裡會有體現,分別對應上面的配置項。
另外,對於非testOnConnect
這種型別的驗證,可以用到validationInterval
來避免頻繁的連線驗證
下面再給出和有效性驗證相關的引數:
- testOnBorrow : 獲取連線後是否檢驗連線有效性,影響效能
- testOnConnect : 建立連線後是否檢驗連線有效性,影響效能
- testOnReturn : 將連線返回連線池前是否檢驗連結有效性,影響效能
- testWhileIdle : 清潔工定時任務校驗空閒連線的有效性
- validationQuery : 校驗連線有效性的查詢語句,Mysql基本就是
select 1
- validationQueryTimeout : 校驗連線有效性的查詢語句的超時時間
- validationInterval:防止頻繁校驗,如上一次與本次校驗時間不超過validationInterval,則不會執行校驗,直接返回校驗通過
- logValidationErrors:有效性校驗失敗是否記錄日誌
獲取連線
關於【獲取連線】,有兩點需要注意一下:
- 可能原先對maxWait有點誤解,以為是【獲取連線】的最大等待時間,這個理解是不對的,從流程圖中可以看出,這個maxWait只在連線池滿的時候才有用,並且指的是等待idle佇列有新的空閒連線的最大超時時間
- 關於連線有效性校驗的步驟,有一種比較特殊:直接從連線池中拿到連線,做
testOnBorrow
的校驗時,如果第一次校驗失敗,還會給予一次reconnect
的機會去重連資料庫,然後繼續校驗(這次校驗不通過那就報錯了)。其他的有效性校驗只要不通過就報錯
返回連線
返回連線的核心是將取出的連線放回連線池中,但是在放回池中之前會做一系列的校驗:比如是否超過maxAge,有效性驗證是否通過等。如果前置校驗通不過,那麼會將該連線直接釋放掉,而不返回到池中。
連線池維護相關引數
下面這些引數在上述流程中基本都有提到,可以結合起來再回顧一下:
- maxActive : 視系統負載而定,預設值100
- maxIdle : 視系統負載而定,只有在未開啟清潔工任務的情況下會使用到,用在returnToPool操作當中,如果判斷當前idle佇列大小已經大於等於maxIdle了,則會把該連線釋放。預設值 = maxActive
- minIdle : 池中常駐的空閒連線數量,如果Idle佇列大小超過該值,且在池中閒置超過minEvictableIdleTimeMillis的連線將會被釋放(只要啟用了清潔工任務)
- initialSize : 初始化時建立的連線數量
- maxWait : 獲取連線時,超過maxActive後,等待idle佇列重新有新連線的時長,注意,這個等待時間並不考慮建立連線的耗時
- maxAge:獲取連線時或者返回連線時,都會判斷 當前時間 - 連線建立時間 有沒有超過 maxAge,如果超過了,則該連線會被釋放
- removeAbandoned:是否移除長時間取出且未歸還的連線,對於應用層面沒有關閉連線的情況做一個兜底
- removeAbandonedTimeout:時間閾值
- abandonWhenPercentageFull:移除還有個前置比例的判斷
- logAbandoned : 是否記錄通過abandoned移除的連線
- suspectTimeout:沒什麼實質性的功能,最多也就打打日誌,並且只會在非abandoned情況或者是關閉了removeAbandoned的情況下才有可能起作用
攔截器
也就是JdbcInterceptor,繼承它的攔截器可以攔截connection所有方法的呼叫。
下面列幾個個人覺得非常有用的攔截器:
QueryTimeoutInterceptor
可配置Sql超時時間x,超過該時間的Sql將被Kill掉,客戶端報錯。簡單原理就是執行Sql之前會延遲x毫秒啟動一個定時任務,該定時任務就是傳送Kill Query命令到資料庫,如果在x毫秒之內執行完,那麼該定時任務會在執行前就被cancel掉,如果到了x毫秒還沒執行完,那麼定時任務啟動,Kill Query,導致客戶端連線報錯。這個可以對系統起到一定的保護和監控作用
SlowQueryReport
可配置慢查的閾值,主要就是記錄慢查,但是這個東西有點不完善的地方,就是打慢查日誌只會打PrepareStatement,打不了裡面的引數,這樣會造成無法拿Sql去DB裡找對應的查詢,後面在優化之路里會給出一個增強的慢查監控攔截器程式碼
ConnectionState
用來快取autoCommit, readOnly, transactionIsolation和catalog這幾個屬性,將它們快取在本地,避免各種和資料庫之間的roundtrip消耗。比如:
- getAutoCommit時如果本地有快取,則直接讀取本地快取的autoCommit值
- setAutoCommit時如果發現與本地快取一致,則無需傳送請求到資料庫
其他引數
- useDisposableConnectionFacade:預設為true,多加了一層Interceptor,防止同一個執行緒中取出連線,close之後再執行sql,感覺沒太大作用
排查資料庫相關問題的一些經驗
沒有慢查,但是請求響應很慢,並且該請求的處理邏輯只有一個數據庫事務操作,如何排查?
想要搞清楚這個問題,首先需要了解執行一個事務,我們到底會和資料庫(MySQL)產生幾次互動?
我們以一個簡單的事務為例,比如說訂單到店這個事務,涉及到訂單表的更新,以及訂單操作記錄表的更新,我們簡化成兩條語句:updateorder
和 updateorder_operate_record
,在執行該事務的過程中,我們會和MySQL互動幾次呢?
1. 先從連線池取出連線,其中可能會碰到連線池中沒有空閒連線的情況,這個時候假設還沒超出最大活躍連線的話,連線池會發起建立連線,這時就會產生一次互動,並且建立連線的消耗相對於執行Sql更大
2. 根據不同的連線池引數配置,可能還需要對取出的連線做有效性校驗,MySQL中一般都是用SELECT 1
來充當校驗語句的
3. 下面需要開啟事務,SET AUTOCOMMIT = 0
;
4. 然後再發送兩條update語句
5. 最後需要告訴MySQL我們的事務結束了,COMMIT,當然也有可能中間碰到一些問題,ROLLBACK掉
6. 還記得第三步開啟事務的時候,執行的SET AUTOCOMMIT = 0
嗎?所以做為完整的事務操作,最後還有一步SET AUTOCOMMIT = 1
7. 結束了麼?最後還要把連線放回連線池。貌似不用和MySQL互動?放回去之前,根據不同的連線池引數配置,可能還需要對放回去的連線做有效性校驗。等等,除了有效性校驗之外,可能還會有maxAge/maxIdle之類的校驗?不過這個不用和MySQL互動。好吧,這裡先打住,不然內容太多了。
看看上面的內容,你大概知道一個事務的執行,不僅僅只有事務中的兩行更新語句和資料庫有互動吧,所以,也不難理解為什麼慢查抓不到,但是實際請求處理得很慢了。但是就這樣結束了麼?既然對於慢查可以監控,為什麼不把所有和MySQL有互動的點都監控起來呢?好,有想法是好事,那我們來看看如何把所有的節點都監控起來?
Tomcat-JDBC連線池似乎不提供這個功能。更換連線池?貌似有點牽強。我們何不轉向與MySQL更加緊密的MySQL驅動呢?翻閱了MySQL驅動的官方文件,發現其中是有效能監控的開關——profileSQL
通過這個開關,我們可以觀察到應用與MySQL互動的每一條語句,包括建立連線時做了哪些初始化操作?什麼時候開啟事務,什麼時候校驗連線有效性,什麼時候提交事務等,都會有日誌列印,包括耗時。
但是,通過上述的配置項,還是無法監控到我們上面第一點的建立連線的耗時,這塊通過查詢驅動原始碼發現也是可以實現的,於是自己動手,一點點程式碼量就完成了這個小功能。至此,上述每個節點都有跡可循,目測可以輕鬆的找到耗時所在。
優化之路
1. 上面的問題中與MySQL的互動是否能減少
當然是可以的。
首先最容易想到的就是連線有效性校驗那一塊兒,比如上面提到的第2點和第7點裡,也就是取出連線之後(testOnBorrow/testOnConnect)和把連線放回連線池(testOnReturn)之前,可能需要做的校驗操作,我們可以省去。那麼有效性怎麼保證呢?理論上來說,大多數情況下都是有效的,除非資料庫掛了之類,所以單獨執行緒來做就好了(testWhileIdle)
第5點的COMMIT貌似也可以省去,看了官網,應該是隻要再SET AUTOCOMMIT = 1
的時候會自動COMMIT,不過這個目前還沒有驗證過
2. 如何監控建立資料庫連線的耗時?
這個通過連線池似乎不太好做,我們可以通過MySQL Connector
提供的ConnectionLifecycleInterceptor
來實現:
/**
* Created by Zhu on 2017/9/19.
*/
public class ConnectionLifeInteceptor implements ConnectionLifecycleInterceptor{
private ConnectionImpl connection;
public static final Logger LOGGER = LoggerFactory.getLogger(ConnectionLifeInteceptor.class);
// 這裡只關注建立連線耗時
@Override public void init(Connection conn, Properties props) throws SQLException {
this.connection = (ConnectionImpl) conn;
Field field = ReflectionUtils.findField(conn.getClass(), "connectionCreationTimeMillis", Long.TYPE);
ReflectionUtils.makeAccessible(field);
Long connectionCreationTimeMillis = (Long) ReflectionUtils.getField(field, conn);
LOGGER.info("connection:{} cost:{}", connection.getId(), System.currentTimeMillis() - connectionCreationTimeMillis);
}
}
3. 是否能對建立連線做timeout設定?
超時設定一直都是系統優化中很重要的節點,所以這裡自然而然就想到了是否可以通過timeout來避免潛在的建立連線hang住的風險。答案自然是肯定的,但是翻遍
Tomcat-JDBC
連線池配置也並找不到類似的配置。想必聰明的你已經想到了MySQL Connector
。沒錯,還是MySQL Connector
,它提供了一個引數connectTimeout
用來設定建立連線的超時時間。
4. 衰老連線重連問題
通過監控建立連線耗時幫助我們最後定位到偶爾慢的現象是因為取出的連線衰老而死(超過maxAge),觸發了reconnect,導致重新與MySQL建立連線,並且建立連線耗時1s左右。那怎麼辦呢?我們可以掃描idle佇列裡即將要超過maxAge(比如60s內)的連線,比如發現快要過期了,那麼我們就拿該連線reconnect一下,重新啟用,該任務我們可以直接置於
PoolCleaner
中,附上部分程式碼:
protected static class PoolCleaner extends TimerTask {
// 省略部分程式碼
@Override
public void run() {
// 省略部分程式碼
if (pool.getPoolProperties().isTestWhileIdle()){
pool.testAllIdle();
pool.recoverNearDeath();
}
// 省略部分程式碼
}
// 省略部分程式碼
}
public void recoverNearDeath(){
try {
if (idle.size()==0) return;
Iterator<PooledConnection> unlocked = idle.iterator();
while (unlocked.hasNext()) {
PooledConnection con = unlocked.next();
try {
con.lock();
// 連線被取出,不做處理
if (busy.contains(con))
continue;
if (con.isNearDeath()){
log.info("Connection ["+con+"] is near death, last connected:" + con.getLastConnected());
con.reconnect();
}
} finally {
con.unlock();
}
} //while
} catch (Exception e) {
log.error("recoverNearDeath failed",e);
}
}
public boolean isNearDeath(){
// 這裡暫時不做配置,定義離maxAge還差1min以內的為瀕臨死亡的連線
final long val = 60000;
return (System.currentTimeMillis() - getLastConnected()) > (getPoolProperties().getMaxAge() - val);
}
5. 重寫慢查監控攔截器,用來列印引數解析完的SQL
/**
* @author Zhu
* @date 2017年3月22日 下午11:00:11
* @description
*/
public class MonitorSlowQueryReport extends SlowQueryReport {
private String systemCode;
// logger
private static final Logger LOGGER = LoggerFactory.getLogger(MonitorSlowQueryReport.class);
class RecordParamStatementProxy extends StatementProxy {
/**
* @param parent
* @param query
*/
public RecordParamStatementProxy(Object parent, String query) {
super(parent, query);
}
/*
* (non-Javadoc)
*
* @see org.apache.tomcat.jdbc.pool.interceptor.AbstractQueryReport.
* StatementProxy#invoke(java.lang.Object, java.lang.reflect.Method,
* java.lang.Object[])
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().startsWith("set") && args != null && args.length >= 2) {
ParamHolder.params.get().add(args[1]);
}
Object result = null;
try {
result = super.invoke(proxy, method, args);
} finally {
if (isExecute(method, false)) {
ParamHolder.params.remove();
}
}
return result;
}
}
@Override
public void setProperties(Map<String, InterceptorProperty> properties) {
super.setProperties(properties);
final String systemCode = "systemCode";
InterceptorProperty p1 = properties.get(systemCode);
if (p1 != null) {
setSystemCode(p1.getValue());
}
}
/*
* (non-Javadoc)
*
* @see
* org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReport#reportSlowQuery(
* java.lang.String, java.lang.Object[], java.lang.String, long, long)
*/
@Override
protected String reportSlowQuery(String query, Object[] args, String name, long start, long delta) {
// extract the query string
String sql = (query == null && args != null && args.length > 0) ? (String) args[0] : query;
// if we do batch execution, then we name the query 'batch'
if (sql == null && compare(EXECUTE_BATCH, name)) {
sql = "batch";
}
if (isLogSlow() && sql != null) {
String beautifulSql = sql.replace("\n", "").replaceAll("[' ']+", " ");
LOGGER.warn("Slow Query Report SQL={}; param:[{}], consume={};", beautifulSql,
StringUtils.join(ParamHolder.params.get(), ','), delta);
}
return sql;
}
public String getLocalHostAddress() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
LOGGER.error("獲取本地ip異常", e);
}
return "";
}
/**
* 為了列印全貌sql,重寫一下
*/
@Override
public Object createStatement(Object proxy, Method method, Object[] args, Object statement, long time) {
try {
Object result = null;
String name = method.getName();
String sql = null;
Constructor<?> constructor = null;
if (compare(CREATE_STATEMENT, name)) {
// createStatement
constructor = getConstructor(CREATE_STATEMENT_IDX, Statement.class);
} else if (compare(PREPARE_STATEMENT, name)) {
// prepareStatement
sql = (String) args[0];
constructor = getConstructor(PREPARE_STATEMENT_IDX, PreparedStatement.class);
if (sql != null) {
prepareStatement(sql, time);
}
} else if (compare(PREPARE_CALL, name)) {
// prepareCall
sql = (String) args[0];
constructor = getConstructor(PREPARE_CALL_IDX, CallableStatement.class);
prepareCall(sql, time);
} else {
// do nothing, might be a future unsupported method
// so we better bail out and let the system continue
return statement;
}
result = constructor.newInstance(new Object[] { new RecordParamStatementProxy(statement, sql) });
return result;
} catch (Exception x) {
LOGGER.warn("Unable to create statement proxy for slow query report.", x);
}
return statement;
}
/**
* @return the systemCode
*/
public String getSystemCode() {
return systemCode;
}
/**
* @param systemCode
* the systemCode to set
*/
public void setSystemCode(String systemCode) {
this.systemCode = systemCode;
}
}
6. MySQL Connector 其他一些有趣的引數
後續再更新吧