当前位置:网站首页>MySQL使用ReplicationConnection導致的連接失效分析與解决

MySQL使用ReplicationConnection導致的連接失效分析與解决

2022-06-23 12:41:00 InfoQ

MySQL數據庫讀寫分離,是提高服務質量的常用手段之一,而對於技術方案,有很多成熟開源框架或方案,例如:sharding-jdbc、spring中的AbstractRoutingDatasource、MySQL-Router等,而mysql-jdbc中的ReplicationConnection亦可支持。本文暫不對讀寫分離的技術選型做過多的分析,只是探索在使用druid作為數據源、結合ReplicationConnection做讀寫分離時,連接失效的原因,並找到一個簡單有效的解决方案。

問題背景

由於曆史原因,某幾個服務出現連接失效异常,關鍵報錯如下:

null
從日志不難看出,這是由於該連接長時間未和MySQL服務端交互,服務端已將連接關閉,典型的連接失效場景。

涉及的主要配置如下:

jdbc配置

jdbc:mysql:replication://master_host:port,slave_host:port/database_name

druid配置

testWhileIdle=true(即,開啟了空閑連接檢查);timeBetweenEvictionRunsMillis=6000L(即,對於獲取連接的場景,如果某連接空閑時間超過1分鐘,將會進行檢查,如果連接無效,將拋弃後重新獲取)。

附:DruidDataSource.getConnectionDirect中,處理邏輯如下:

if (testWhileIdle) {
 final DruidConnectionHolder holder = poolableConnection.holder;
 long currentTimeMillis = System.currentTimeMillis();
 long lastActiveTimeMillis = holder.lastActiveTimeMillis;
 long lastExecTimeMillis = holder.lastExecTimeMillis;
 long lastKeepTimeMillis = holder.lastKeepTimeMillis;

 if (checkExecuteTime
 && lastExecTimeMillis != lastActiveTimeMillis) {
 lastActiveTimeMillis = lastExecTimeMillis;
 }

 if (lastKeepTimeMillis > lastActiveTimeMillis) {
 lastActiveTimeMillis = lastKeepTimeMillis;
 }

 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(&quot;skip not validate connection.&quot;);
 }

 discardConnection(poolableConnection.holder);
 continue;
 }
 }
}

mysql超時參數配置

wait_timeout=3600(3600秒,即:如果某連接超過一個小時和服務端沒有交互,該連接將會被服務端kill)。顯而易見,基於如上配置,按照常規理解,不應該出現“The last packet successfully received from server was xxx,xxx,xxx milliseconds ago”的問題。(當然,當時也排除了人工介入kill掉數據庫連接的可能)。

當“理所應當”的經驗解釋不了問題所在,往往需要跳出可能浮於錶面經驗束縛,來一次追根究底。那麼,該問題的真正原因是什麼呢?

本質原因

當使用druid管理數據源,結合mysql-jdbc中原生的ReplicationConnection做讀寫分離時,ReplicationConnection代理對象中實際存在master和slaves兩套連接,druid在做連接檢測時候,只能檢測到其中的master連接,如果某個slave連接長時間未使用,會導致連接失效問題。

原因分析

mysql-jdbc中,數據庫驅動對連接的處理過程

結合com.mysql.jdbc.Driver源碼,不難看出mysql-jdbc中獲取連接的主體流程如下:

null
對於以“jdbc:mysql:replication://”開頭配置的jdbc-url,通過mysql-jdbc獲取到的連接,其實是一個ReplicationConnection的代理對象,默認情况下,“jdbc:mysql:replication://”後的第一個host和port對應master連接,其後的host和port對應slaves連接,而對於存在多個slave配置的場景,默認使用隨機策略進行負載均衡。

ReplicationConnection代理對象,使用JDK動態代理生成的,其中InvocationHandler的具體實現,是ReplicationConnectionProxy,關鍵代碼如下:

public static ReplicationConnection createProxyInstance(List<String> masterHostList, Properties masterProperties, List<String> slaveHostList,
 Properties slaveProperties) throws SQLException {
 ReplicationConnectionProxy connProxy = new ReplicationConnectionProxy(masterHostList, masterProperties, slaveHostList, slaveProperties);
 return (ReplicationConnection) java.lang.reflect.Proxy.newProxyInstance(ReplicationConnection.class.getClassLoader(), INTERFACES_TO_PROXY, connProxy);
 }

ReplicationConnectionProxy的重要組成

關於數據庫連接代理,ReplicationConnectionProxy中的主要組成如下圖:

null
ReplicationConnectionProxy存在masterConnection和slavesConnection兩個實際連接對象,currentConnetion(當前連接)可以切換成mastetConnection或者slavesConnection,切換方式可以通過設置readOnly實現。業務邏輯中,實現讀寫分離的核心也在於此,簡單來說:使用ReplicationConnection做讀寫分離時,只要做一個“設置connection的readOnly屬性的”aop即可。基於ReplicationConnectionProxy,業務邏輯中獲取到的Connection代理對象,數據庫訪問時的主要邏輯是什麼樣的呢?

ReplicationConnection代理對象處理過程

對於業務邏輯而言,獲取到的Connection實例,是ReplicationConnection代理對象,該代理對象通過ReplicationConnectionProxy和ReplicationMySQLConnection相互協同完成對數據庫訪問的處理,其中ReplicationConnectionProxy在實現 InvocationHandler的同時,還充當對連接管理的角色,核心邏輯如下圖:

null
對於prepareStatement等常規邏輯,ConnectionMySQConnection獲取到當前連接進行處理(普通的讀寫分離的處理的重點正是在此);此時,重點提及pingInternal方法,其處理方式也是獲取當前連接,然後執行pingInternal邏輯。

對於ping()這個特殊邏輯,圖中描述相對簡單,但主體含義不變,即:對master連接和sleves連接都要進行ping()的處理。

圖中,pingInternal流程和druid的MySQ連接檢查有關,而ping的特殊處理,也正是解决問題的關鍵。

druid數據源對MySQ連接的檢查

druid中對MySQL連接檢查的默認實現類是MySqlValidConnectionChecker,其中核心邏輯如下:

public boolean isValidConnection(Connection conn, String validateQuery, int validationQueryTimeout) throws Exception {
 if (conn.isClosed()) {
 return false;
 }

 if (usePingMethod) {
 if (conn instanceof DruidPooledConnection) {
 conn = ((DruidPooledConnection) conn).getConnection();
 }

 if (conn instanceof ConnectionProxy) {
 conn = ((ConnectionProxy) conn).getRawObject();
 }

 if (clazz.isAssignableFrom(conn.getClass())) {
 if (validationQueryTimeout <= 0) {
 validationQueryTimeout = DEFAULT_VALIDATION_QUERY_TIMEOUT;
 }

 try {
 ping.invoke(conn, true, validationQueryTimeout * 1000);
 } catch (InvocationTargetException e) {
 Throwable cause = e.getCause();
 if (cause instanceof SQLException) {
 throw (SQLException) cause;
 }
 throw e;
 }
 return true;
 }
 }

 String query = validateQuery;
 if (validateQuery == null || validateQuery.isEmpty()) {
 query = DEFAULT_VALIDATION_QUERY;
 }

 Statement stmt = null;
 ResultSet rs = null;
 try {
 stmt = conn.createStatement();
 if (validationQueryTimeout > 0) {
 stmt.setQueryTimeout(validationQueryTimeout);
 }
 rs = stmt.executeQuery(query);
 return true;
 } finally {
 JdbcUtils.close(rs);
 JdbcUtils.close(stmt);
 }

}

對應服務中使用的mysql-jdbc(5.1.45版),在未設置“druid.mysql.usePingMethod”系統屬性的情况下,默認usePingMethod為true,如下:

public MySqlValidConnectionChecker(){
try {
 clazz = Utils.loadClass(&quot;com.mysql.jdbc.MySQLConnection&quot;);
 if (clazz == null) {
 clazz = Utils.loadClass(&quot;com.mysql.cj.jdbc.ConnectionImpl&quot;);
 }

 if (clazz != null) {
 ping = clazz.getMethod(&quot;pingInternal&quot;, boolean.class, int.class);
 }

 if (ping != null) {
 usePingMethod = true;
 }
 } catch (Exception e) {
 LOG.warn(&quot;Cannot resolve com.mysql.jdbc.Connection.ping method. Will use 'SELECT 1' instead.&quot;, e);
 }

 configFromProperties(System.getProperties());
}

@Override
public void configFromProperties(Properties properties) {
 String property = properties.getProperty(&quot;druid.mysql.usePingMethod&quot;);
 if (&quot;true&quot;.equals(property)) {
 setUsePingMethod(true);
 } else if (&quot;false&quot;.equals(property)) {
 setUsePingMethod(false);
 }
}

同時,可以看出MySqlValidConnectionChecker中的ping方法使用的是MySQLConnection中的pingInternal方法,而該方法,結合上面對ReplicationConnection的分析,當調用pingInternal時,只是對當前連接進行檢驗。執行檢驗連接的時機是通過DrduiDatasource獲取連接時,此時未設置readOnly屬性,檢查的連接,其實只是ReplicationConnectionProxy中的master連接。

此外,如果通過“druid.mysql.usePingMethod”屬性設置usePingMeghod為false,其實也會導致連接失效的問題,因為:當通過valideQuery(例如“select 1”)進行連接校驗時,會走到ReplicationConnection中的普通查詢邏輯,此時對應的連接依然是master連接。

題外一問
:ping方法為什麼使用“pingInternal”,而不是常規的ping?原因:pingInternal預留了超時時間等控制參數。

解决方式

調整依賴版本

服務中使用的mysql-jdbc版本為5.1.45,druid版本為1.1.20。經過對其他高版本依賴的了解,依然存在該問題。

修改讀寫分離實現

修改的工作量主要在於數據源配置和aop調整,但需要一定的整體回歸驗證成本,鑒於涉及該問題的服務重要性一般,暫不做大調整。

拓展mysql-jdbc驅動

基於原有ReplicationConnection的功能,拓展pingInternal調整為普通的ping,集成原有Driver拓展新的Driver。方案可行,但修改成本不算小。

基於druid,拓展MySQL連接檢查

為簡單高效解决問題,選擇拓展MySqlValidConnectionChecker,並在druid數據源中加上對應配置即可。拓展如下:

public class MySqlReplicationCompatibleValidConnectionChecker extends MySqlValidConnectionChecker {


 private static final Log LOG = LogFactory.getLog(MySqlValidConnectionChecker.class);
 /**
 * 
 */
 private static final long serialVersionUID = 1L;

 @Override
 public boolean isValidConnection(Connection conn, String validateQuery, int validationQueryTimeout) throws Exception {

 if (conn.isClosed()) {
 return false;
 }

 if (conn instanceof DruidPooledConnection) {
 conn = ((DruidPooledConnection) conn).getConnection();
 }

 if (conn instanceof ConnectionProxy) {
 conn = ((ConnectionProxy) conn).getRawObject();
 }

 if (conn instanceof ReplicationConnection) {

 try {
 ((ReplicationConnection) conn).ping();
 LOG.info(&quot;validate connection success: connection=&quot; + conn.toString());
 return true;
 } catch (SQLException e) {
 LOG.error(&quot;validate connection error: connection=&quot; + conn.toString(), e);
 throw e;
 }

 }

 return super.isValidConnection(conn, validateQuery, validationQueryTimeout);
 }

}

ReplicatoinConnection.ping()的實現邏輯中,會對所有master和slaves連接進行ping操作,最終每個ping操作都會調用到LoadBalancedConnectionProxy.doPing進行處理,而此處,可在數據庫配置url中設置loadBalancePingTimeout屬性設置超時時間。



轉轉研發中心及業界小夥伴們的技術學習交流平臺,定期分享一線的實戰經驗及業界前沿的技術話題。關注公眾號「轉轉技術」,各種幹貨實踐,歡迎交流分享~
原网站

版权声明
本文为[InfoQ]所创,转载请带上原文链接,感谢
https://yzsam.com/2022/06/202206231217468145.html

随机推荐