首頁 > 軟體

MySQL使用ReplicationConnection導致連線失效解決

2022-07-08 18:07:38

引言

MySQL資料庫讀寫分離,是提高服務質量的常用手段之一,而對於技術方案,有很多成熟開源框架或方案,例如:sharding-jdbc、spring中的AbstractRoutingDatasource、MySQL-Router等,而mysql-jdbc中的ReplicationConnection亦可支援。

本文暫不對讀寫分離的技術選型做過多的分析,只是探索在使用druid作為資料來源、結合ReplicationConnection做讀寫分離時,連線失效的原因,並找到一個簡單有效的解決方案。

問題背景

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

從紀錄檔不難看出,這是由於該連線長時間未和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("skip not validate connection.");
            }
            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中獲取連線的主體流程如下:

對於以“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中的主要組成如下圖:

ReplicationConnectionProxy存在masterConnection和slavesConnection兩個實際連線物件,currentConnetion(當前連線)可以切換成mastetConnection或者slavesConnection,切換方式可以通過設定readOnly實現。

業務邏輯中,實現讀寫分離的核心也在於此,簡單來說:使用ReplicationConnection做讀寫分離時,只要做一個“設定connection的readOnly屬性的”aop即可。

基於ReplicationConnectionProxy,業務邏輯中獲取到的Connection代理物件,資料庫存取時的主要邏輯是什麼樣的呢?

ReplicationConnection代理物件處理過程

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

對於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("com.mysql.jdbc.MySQLConnection");
        if (clazz == null) {
            clazz = Utils.loadClass("com.mysql.cj.jdbc.ConnectionImpl");
        }
        if (clazz != null) {
            ping = clazz.getMethod("pingInternal", boolean.class, int.class);
        }
        if (ping != null) {
            usePingMethod = true;
        }
    } catch (Exception e) {
        LOG.warn("Cannot resolve com.mysql.jdbc.Connection.ping method.  Will use 'SELECT 1' instead.", e);
    }
    configFromProperties(System.getProperties());
}
@Override
public void configFromProperties(Properties properties) {
    String property = properties.getProperty("druid.mysql.usePingMethod");
    if ("true".equals(property)) {
        setUsePingMethod(true);
    } else if ("false".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("validate connection success: connection=" + conn.toString());
                return true;
            } catch (SQLException e) {
                LOG.error("validate connection error: connection=" + conn.toString(), e);
                throw e;
            }
        }
        return super.isValidConnection(conn, validateQuery, validationQueryTimeout);
    }
}

ReplicatoinConnection.ping()的實現邏輯中,會對所有master和slaves連線進行ping操作,最終每個ping操作都會呼叫到LoadBalancedConnectionProxy.doPing進行處理,而此處,可在資料庫設定url中設定loadBalancePingTimeout屬性設定超時時間。

以上就是MySQL使用ReplicationConnection導致連線失效解決的詳細內容,更多關於MySQL Replication連線失效的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com