記一次 【Unknown thread id: XXX】 的排查

背景

線上一個服務偶爾會產生【Unknown thread id: XXX】異常

異常堆棧

org.springframework.jdbc.UncategorizedSQLException: 
### Error updating database.  Cause: java.sql.SQLException: Unknown thread id: 64278282
### The error may involve com.xxx.xxx_xxxxx.xxx.dao.XxxDao.insert-Inline
### The error occurred while setting parameters
### SQL: INSERT INTO `t_xxx_xxx_xxx` (XXX,XXX,XXX)    VALUES (?, ?, ? )
### Cause: java.sql.SQLException: Unknown thread id: 64278282
; uncategorized SQLException for SQL []; SQL state [HY000]; error code [1094]; Unknown thread id: 64278282; nested exception is java.sql.SQLException: Unknown thread id: 64278282
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:84)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
	at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:73)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:371)
	at com.sun.proxy.$Proxy26.insert(Unknown Source)
	at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:240)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:52)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:53)
	at com.sun.proxy.$Proxy28.insert(Unknown Source)
	// 省略了部分信息
	at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1091)
	at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:668)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1521)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.run(NioEndpoint.java:1478)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:745)
Caused by: java.sql.SQLException: Unknown thread id: 64278282
	at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:959)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3870)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3806)
	at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:2470)
	at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2617)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2546)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2504)
	at com.mysql.jdbc.StatementImpl.executeInternal(StatementImpl.java:840)
	at com.mysql.jdbc.StatementImpl.execute(StatementImpl.java:740)
	at com.mysql.jdbc.StatementImpl$CancelTask$1.run(StatementImpl.java:119)

初步總結定位

異常都集中在一個數據庫,產生異常的語句並不同,操作的表也不同,insert、update、select都發現過這種異常。
可確認的是部分異常時間點數據庫確實存在高負載。只是不明白爲何sql失敗會得到這樣的一種異常信息呢?

問題

  1. 是什麼:這個異常是什麼類型的異常
  2. 如何產生:這個異常是怎麼產生的
  3. 如何解決:如何解決這個異常

調研

圍繞這以上三個問題,我們逐個解析。

是什麼

首先我們通過兩個維度來解析一下這個異常,分別是數據庫維度和應用維度。

數據庫層面異常解析

由於我們用的是mysql數據庫,那麼mysql到底是如果定義和描述異常的。可參考:mysql官網的描述
我們文中所謂的mysql異常在mysql層面叫做error(下文稱:mysql錯誤),我們從官網摘取了如下信息:

mysql錯誤分類

按照錯誤產生來源可以分爲兩種

  1. 服務端錯誤:啓動或者關閉進程(需要dba干預的),執行sql發生問題(dba可干預,也很大可能需要反饋給客戶端的)。錯誤碼範圍:[1000,1999],目前有6、7百個
  2. 客戶端錯誤:通常都是與服務端的通信問題產生的異常(例:主機連接不通)。錯誤碼範圍:[2000,+],目前比較少,只有幾十個。
mysql錯誤結構

mysql錯誤信息由錯誤碼、SQLSTATE值以及錯誤描述三部分組成。
對照一下我們上文異常堆棧中的信息:

SQL state [HY000]; error code [1094]; Unknown thread id: 64278282

  1. 錯誤碼(符號標識)
    純數字組成(例:1094)、每個錯誤碼都有一個對應的符號標識(例如:ER_NO_SUCH_THREAD),這些錯誤碼是mysql自己定義的,不適用於其他數據庫。
  2. SQLSTATE值
    一共由5個字符組成,值取自ansi sql和odbc,比數字錯誤代碼更標準化,值的前兩個字符表示錯誤類型
    • 00標識成功
    • 01標識警告
    • 02標識未找到
    • 03[+]標識異常
    • 對於服務端發生的錯誤,並不是所有的mysql錯誤碼都能對應上一個SQLSTATE值,這個時候就用【HY000】,表示常規錯誤。
    • 對於客戶端發生的錯誤,都用【HY000】表示。
  3. 錯誤描述
    異常簡要描述(例:Unknown thread id: %lu)。
小結

經過這個小科普我們可以得出結論,我們得到的這個異常是個服務端異常。這個異常沒有對應的SQLSTATE值,這是一個mysql特有的自定義異常。能得到的有效描述信息僅僅就是字面的意思(未知的或者說是不能識別的線程id)。

應用框架層面異常解析

通過異常堆棧我們可以發現:
棧頂異常是:org.springframework.jdbc.UncategorizedSQLException
棧底異常是:java.sql.SQLException

UncategorizedSQLException異常解析

spring有幾個主要模塊,IOC、AOP、數據訪問和集成、WEB以及遠程操作、測試框架等。數據訪問和集成是spring框架中比較核心的一部分,spring在數據訪問和繼承方面的一個體現就是spring框架統一了數據訪問異常體系,對於常見的數據訪問操作異常進行了包裝(可以參見org.springframework.dao和org.springframework.transaction兩個包下的異常類)。
那麼UncategorizedSQLException指代的是什麼類型的異常呢?

  1. 通過類所處位置確定異常範圍
    UncategorizedSQLException類位於org.springframework.jdbc包下,並沒有位於dao或者transaction包下,這也就說明他是spring框架對於jdbc實現的一個特定異常。
  2. 通過類名也可見一斑
    UncategorizedSQLException以SQLException結尾,說明應該和java.sql.SQLException有關。
  3. 分析該類源碼(爲了節省篇幅,去掉了部分註釋和代碼)
/**
 * Exception thrown when we can't classify a SQLException into
 * one of our generic data access exceptions.
 */
public class UncategorizedSQLException extends UncategorizedDataAccessException {

	/** SQL that led to the problem */
	private final String sql;

	public UncategorizedSQLException(String task, String sql, SQLException ex) {
		super(task + "; uncategorized SQLException for SQL [" + sql + "]; SQL state [" +
				ex.getSQLState() + "]; error code [" + ex.getErrorCode() + "]; " + ex.getMessage(), ex);
		this.sql = sql;
	}

	/**
	 * Return the underlying SQLException.
	 */
	public SQLException getSQLException() {
		return (SQLException) getCause();
	}

	/**
	 * Return the SQL that led to the problem.
	 */
	public String getSql() {
		return this.sql;
	}
}

  • 通過該類的註釋可以得到如下信息:

當我們無法將SQLException分類爲一個通用數據訪問異常時,就會拋出這個異常。

  • 還可以通過UncategorizedSQLException的父類UncategorizedDataAccessException的註釋我們得到如下信息:

當我們僅僅知道是底層(譯者注:這裏所謂的底層指的底層api,JDBC等)出了問題,沒有更細化的信息的時候就可以使用這個異常。舉了個例子:jdbc拋出的SQLException。

  • 這個異常類定義了一個字符串類型的sql屬性
  • 這個異常類還定義了一個getSQLException方法,返回一個SQLException對象
SQLException

1、該類位於java.sql包下,屬於jdk的類。
2、java.sql包是幹什麼的?
這個包我們日常開發可能很少關注和留意,但是另外一個概念大家肯定都不陌生,那就是JDBC。JDBC是java定義的一套進行數據庫操作的規範,是一套api,這套api裏面既有接口也有普通的類。jdbc的所有接口和類都在java.sql包下。java.sql包就是jdbc所在。java.sql包下大部分是接口,需要各個數據庫廠商進行實現。

  • jdbc的接口:Driver、Connection、Statement等。
  • jdbc的普通類:SQLException就是其中之一。還有 DriverManager、Date、JDBCType(枚舉)等。
小結

到這一步,從應用框架層面確定了這個底層異常是jdbc的一個SQLException。沒有被mybatis包裝(因爲我們也用到了mybatis框架,但是異常堆棧中並沒有發現有mybatis框架定義的異常),被spring包裝成了一個UncategorizedSQLException拋出。


經過以上兩種角度的分析,同時以點帶面的科普了相關知識點,還都是理論和現象之間的互相印證而已,主要解答了這個異常是什麼,接下要分析這個異常是如何產生的


如何產生

通過場景分析總結的時候,我們提到應該是由於當時(發生異常的時間點)數據庫壓力較大,導致了sql執行失敗,可是爲什麼是這樣一種異常,而不是更具象化的異常呢,爲什麼不是超時異常和獲取不到連接異常呢?
從度娘和谷歌搜這個異常(unknown thread id)幾乎得不到什麼有價值的參考信息,是否這個異常是個特殊場景或者具有公司特色的異常。
於是我們可能還是需要從源碼層面找一找答案。

再來觀察異常堆棧:

Caused by: java.sql.SQLException: Unknown thread id: 71436599
	at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:959)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3870)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3806)
	at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:2470)
	at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2617)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2546)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2504)
	at com.mysql.jdbc.StatementImpl.executeInternal(StatementImpl.java:840)
	at com.mysql.jdbc.StatementImpl.execute(StatementImpl.java:740)
	at com.mysql.jdbc.StatementImpl$CancelTask$1.run(StatementImpl.java:119)
StatementImpl$CancelTask部分源碼解析

異常堆棧的棧底很清楚的說明了調用com.mysql.jdbc.StatementImpl類的內部類CancelTask的run方法的時候產生了這個異常。代碼行號是119。我們通過行號路由的相關代碼片段:

public class StatementImpl implements Statement {

    //此處省略了很多代碼

    protected boolean wasCancelled = false; // 標識是否被取消
    protected boolean wasCancelledByTimeout = false; // 標識是否因超時被取消
    
    class CancelTask extends TimerTask {

        //此處省略了很多代碼

        
        SQLException caughtWhileCancelling = null;// 接收並存儲取消過程中發生的SQLException類型異常
        StatementImpl toCancel;// 指向要取消的StatementImpl實例

        CancelTask(StatementImpl cancellee) throws SQLException {
            //此處省略了很多代碼
        }

        @Override
        public void run() {

            Thread cancelThread = new Thread() {

                @Override
                public void run() {

                    Connection cancelConn = null;
                    java.sql.Statement cancelStmt = null;

                    try {
                        if (StatementImpl.this.connection.getQueryTimeoutKillsConnection()) {
                            //此處省略了很多代碼
                        } else {
                            synchronized (StatementImpl.this.cancelTimeoutMutex) {
                                if (CancelTask.this.origConnURL.equals(StatementImpl.this.connection.getURL())) {
                                    //All's fine
                                    cancelConn = StatementImpl.this.connection.duplicate();
                                    cancelStmt = cancelConn.createStatement();
119                                 cancelStmt.execute("KILL QUERY " + CancelTask.this.connectionId);
                                } else {
                                    //此處省略了很多代碼
                                }
/* 
 * 執行到這裏說明外部類實例的Statement任務因爲超時被成功取消,所以以下的兩個標識都設置爲true 
 */                              CancelTask.this.toCancel.wasCancelled = true;
                                CancelTask.this.toCancel.wasCancelledByTimeout = true;
                            }
                        }
                    } catch (SQLException sqlEx) {
                        /*
                         * 如果捕捉到的是SQLException
                         * 那麼賦給自己的實例變量caughtWhileCancelling(並沒有向上拋出)。
                        */
                        CancelTask.this.caughtWhileCancelling = sqlEx;
                    } catch (NullPointerException npe) {
                        
                    } finally {
                        //此處省略了很多代碼
                    }
                }
            };

            cancelThread.start();
        }
    }

    //此處省略了很多代碼
}

代碼段中119行號對應的就是產生異常的代碼行。
到這裏有幾個點值得講一講。

  1. StatementImpl類是幹什麼的?
    他是jdbc的Statement接口的實現,通過jdbc來進行絕大部分數據操作,都是通過Statement的executeXXX方法進行的。
  2. CancelTask是幹什麼的?
    他是StatementImpl的內部類,如果設置了Statement超時機制,那麼該類的作用就是在Statement執行超時的時候取消掉這個Statement任務。
  3. KILL QUERY 是幹什麼的?
    這個命令在我們的業務代碼裏幾乎用不到,但是對於DBA來講,應該再熟悉不過。
    當某個sql進行了全表掃描;某個ddl激發了元數據鎖,導致後續所有請求阻塞;這些時候都需要DBA進行及時干預,殺掉這些危險進程,這個時候用到的就是kill命令。
    KILL 命令有兩種使用方法,可參考:mysql官網的描述
    • KILL xxid: 殺掉xxid對應的sql線程,同時殺掉其關聯的連接。
    • KILL QUERY xxid: 僅僅殺掉xxid對應的sql線程,不會殺掉對應的連接。

根據以上分析就說明我們的業務層面配置了statement超時,而且也確實執行超時了,也觸發了取消任務去kill這個超時的任務。但是取消的過程中產生了異常,拋出到了我們業務層面上。
這時又產生了幾個疑問。


  1. 取消任務是一個獨立的線程,和業務主線程是隔離的,怎麼取消失敗的異常會拋到業務主線程裏(代碼層面並沒有向外拋出)?
  2. 這個超時的sql到底取消了沒有?

這就需要繼續觀察源碼,這次我們要從業務sql執行的角度來觀察。我們常規的業務sql語句最終都會交給Statement接口的execute方法們(該方法有幾個重載方法)來執行。這些方法在StatementImpl類和PrepareStatement類中的實現都是調用各自類中的私有方法executeInternal來執行,而executeInternal方法在兩者中的實現主體流程大體相同,我們就以StatementImpl中的源碼爲例進行分析。

StatementImpl的executeInternal方法中的超時邏輯解析
private boolean executeInternal(String sql, boolean returnGeneratedKeys) throws SQLException {
    MySQLConnection locallyScopedConn = checkClosed();

    synchronized (locallyScopedConn.getConnectionMutex()) {

        // 此處省略了部分代碼

        try {
            
            // 此處省略了部分代碼

            if (useServerFetch()) { 
                //連接參數裏如果設置了useServerFetch=true則會執行這部分邏輯,最終還是會路由到PrepareStatement的executeInternal的方法中
                rs = createResultSetUsingServerFetch(sql);
            } else {
                
                CancelTask timeoutTask = null; // 聲明一個取消任務變量

                String oldCatalog = null;

                try {
                    /* 如果enableQueryTimeouts配置爲true(默認爲true)
                     * 超時時間不等於0(此處的判斷等同於大於0),這毫秒值是必須爲大於等於0的數值,這個地方之所以沒有用>0,是因爲setTimeoutInMillis方法中做了校驗,若設置一個小於0的數值會拋出異常。
                     * 校驗數據庫版本必須爲5.0.0以上
                     */
                    if (locallyScopedConn.getEnableQueryTimeouts() && this.timeoutInMillis != 0 && locallyScopedConn.versionMeetsMinimum(5, 0, 0)) {
                        timeoutTask = new CancelTask(this); // 創建一個取消任務
                        /* 
                         * 把這個任務交給一個Timer調度器進行調度,timeoutInMillis毫秒後開始執行該取消任務(如果業務在timeoutInMillis的時間裏沒有執行完,就會被調度觸發的取消任務取消掉)。
                         * 結合Timer和CancelTask的源碼,我們可以發現,當Timer調度執行其隊列任務時,會調用任務的run方法,那麼當調用CancelTask的run方法的時候,會觸發其內部取消線程的執行(cancelThread.start();)
                         */
                        locallyScopedConn.getCancelTimer().schedule(timeoutTask, this.timeoutInMillis); 
                    }

                    if (!locallyScopedConn.getCatalog().equals(this.currentCatalog)) {
                        oldCatalog = locallyScopedConn.getCatalog();
                        locallyScopedConn.setCatalog(this.currentCatalog);
                    }

                    // 此處省略部分代碼

                    // 這一步才真正的執行sql語句,返回結果
                    rs = locallyScopedConn.execSQL(this, sql, this.maxRows, null, this.resultSetType, this.resultSetConcurrency,
                            createStreamingResultSet(), this.currentCatalog, cachedFields);

                    // 判斷是否存在超時取消任務,配置了超時機制,那麼就存在
                    if (timeoutTask != null) {
                        // 如果取消任務的caughtWhileCancelling變量不爲空(在CancelTask源碼分析過該變量的類型是SQLException)
                        if (timeoutTask.caughtWhileCancelling != null) {
942(就是這兒)                   throw timeoutTask.caughtWhileCancelling; //拋出該SQLException異常
                        }

                        // 走到這一步說明,取消任務沒有發生異常。有可能未執行;有可能取消成功;
                        // 無論是那種情況,都可任務已經sql已經返回結果了,取消任務沒有必要存在了,所以取消掉這個調度任務
                        timeoutTask.cancel(); 
                        // 置爲空,爲了能被儘快回收。如果不置爲空,就意味着外部類的StatementImpl實例一直引用這個取消任務,只要StatementImpl實例不銷燬,那麼這個timeoutTask就不被銷燬。
                        timeoutTask = null; 
                    }

                    synchronized (this.cancelTimeoutMutex) {
                        if (this.wasCancelled) { // 如果自己的statement被成功取消
                            SQLException cause = null; // 定義個jdbc異常,用來指向具體子類

                            if (this.wasCancelledByTimeout) { // 如果是因爲超時被取消
                                cause = new MySQLTimeoutException(); // 生成一個mysql超時異常
                            } else {
                                cause = new MySQLStatementCancelledException(); // 生成一個mysql取消異常
                            }

                            resetCancelledState(); // 重置wasCancelled和wasCancelledByTimeout爲false

                            throw cause; // 拋出異常
                        }
                    }
                } finally { // 在finally中做進一步的資源清理
                    
                    if (timeoutTask != null) {
                        timeoutTask.cancel(); // 取消掉任務
                        locallyScopedConn.getCancelTimer().purge(); // 從任務隊列中移除掉被取消的任務
                    }

                    if (oldCatalog != null) {
                        locallyScopedConn.setCatalog(oldCatalog);
                    }
                }
            }

            if (rs != null) {
                // 此處省略了部分代碼
            }

            return ((rs != null) && rs.reallyResult());
        } finally {
            locallyScopedConn.setReadInfoMsgEnabled(readInfoMsgState);

            this.statementExecuting.set(false);
        }
    }
}

通過上面的源碼解讀我們可以回答前文提到的兩個問題。

  1. 取消任務是一個獨立的線程,和業務主線程是隔離的,怎麼取消失敗的異常會拋到業務主線程裏(代碼層面並沒有向外拋出)?

    • 首先CancelTask中捕捉到SQLException沒有拋出,只是賦給了自己的一個實例變量caughtWhileCancelling。
    • 其次在executeInternal方法中,會檢查CancelTask實例(timeoutTask)的實例變量caughtWhileCancelling是否爲空,不爲空,則拋出該異常。

    正因爲這兩點,取消任務的發生的異常才通過業務線程拋出了。

  2. 這個超時的sql到底取消了沒有?
    因爲取消過程中發生了異常,所以超時的sql並沒有被取消,sql還是繼續執行了,而是在sql執行成功之後,通過後置的校驗,拋出了這個取消異常。

    • 拋出異常的時候,後續流程肯定會中斷。
    • 無論業務sql是否處於事務當中,讀操作都肯定執行成功了。
    • 如果業務sql處於事務當中的話,那麼寫操作可以被回滾。
    • 如果業務sql沒有處在事務當中的話,那麼寫操作不會被回滾。

經過以上兩部分的源碼解析,我們可以確定異常產生的背景是我們配置了statement超時機制,當主線程的statement執行超時,異步線程取消任務去kill超時的主線程的statement時發生了這個異常。但是這個取消異常只是暫存了起來,等到主線程statement執行完成後,才由後置的校驗機制檢測到,拋出。這裏還需要待進一步探討幾個問題。

  1. 超時是如何設置的?
  2. 爲什麼取消會失敗?

超時設置

本文所討論的超時設置是基於spring + mybatis + jdbc 的架構來講。鑑於關於這方面的知識在網絡上已經找到介紹的非常詳盡的文章,所以這裏我們不詳細展開,附上兩個參考地址:
深入理解JDBC超時機制(原文翻譯)
深入理解JDBC超時機制(英文原文)

基於本文所論述內容的需求,從以上文章中摘取了部分圖文。

應用與數據庫間的timeout層級

timeout層級

transaction timeout設置

事務的超時時間只存在於高層框架層面,jdbc裏沒有這個概念。如果使用spring框架,那麼可以通過xml或者註解的方式進行配置。
1、針對部分方法生效(3秒超時)

<tx:method name=“…” timeout=“3″/> 

2、針對某個類或者類的某個方法生效 (3秒超時)

@Transactional(timeout=3)

事務超時針對的是整個事務的執行時間,這裏面就不單單包括數據庫的操作時間,其他的業務處理也算在內。事務時間=statement時間*n + 雜七雜八的時間。

jdbc的statement timeout設置

statement timeout用來限制statement的執行時長,timeout的值通過調用JDBC的java.sql.Statement.setQueryTimeout(int timeout) API進行設置。因我們使用mybatis框架,所以一般可以通過兩種方式配置該超時時間。
1、全局配置
可以通過設置全局的defaultStatementTimeout進行配置,以下配置片段中設置了3秒超時。

<configuration>
	<settings>
		<!-- 省略了其他配置 -->
		<setting name="defaultStatementTimeout" value="3" />
	</settings>
	
	<mappers>
		<!-- 省略了其他配置 -->
	</mappers>
</configuration>

2、單獨配置
可以在具體的mapper文件的指定語句上配置timeout。以下配置片段中爲單個sql設置了1秒超時。

<select id="getListFromGroupBy" resultType="java.util.Map" timeout="1">
     select c_name,c_code,d_name,d_code,count(*) from t_xxx_xxx group by c_name,c_code,d_name,d_code  order by rand() limit 100
</select>

MySQL JDBC Statement的QueryTimeout處理過程(5.0.8)

mysql jdbc 5.0.8的處理流程

jdbc的socket timeout設置

我們目前所使用的jdbc的實現(mysql-connector-java-XXX.jar)底層是通過socket與數據庫進行通信,不同的數據庫廠商針對自己的不同數據庫提供不同的jdbc實現。基於socket通信如果不設置超時時間,很有可能在出現網絡問題時產生無限等待,最終耗盡系統資源。socket timeout 可以解釋爲傳輸(讀寫)超時,和其形影不離的還有一個 connect timeout,可以解釋爲建立連接超時。鑑於我們使用DBCP來配置數據庫連接池,一般把參數配置寫到prop文件中,再通過xml配置文件引用。
1、properties配置
設置了連接超時爲1秒,讀寫超時爲5秒

jdbc.connectionProperties=connectTimeout=1000;socketTimeout=5000;useUnicode=true;characterEncoding=utf8[;**key=**val]

2、xml配置

<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" abstract="true">
    <!-- 省略了其他配置 -->
    <property name="connectionProperties" value="${jdbc.connectionProperties}"/>
</bean>

在mysql的jdbc實現中,這兩個設置最終都通過ConnectionImpl(繼承了ConnectionPropertiesImpl)傳遞給MysqlIO,MysqlIO封裝了底層的socket操作。那麼如果socket超時了會產生什麼樣的異常呢?
socket timeout 異常堆棧

org.springframework.dao.RecoverableDataAccessException: 
### Error querying database.  Cause: com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure

The last packet successfully received from the server was 120,104 milliseconds ago.  The last packet sent successfully to the server was 120,099 milliseconds ago.
### The error may exist in mapper/filter/XxxMapper.xml
### The error may involve com.xxxx.xxxx_xxxx.filter.dao.XxxDao.findByXxxId-Inline
### The error occurred while setting parameters
### SQL: //此處略去
### Cause: com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:408)
	at com.mysql.jdbc.Util.handleNewInstance(Util.java:404)
	at com.mysql.jdbc.SQLError.createCommunicationsException(SQLError.java:983)
	at com.mysql.jdbc.MysqlIO.reuseAndReadPacket(MysqlIO.java:3457)
	at com.mysql.jdbc.MysqlIO.reuseAndReadPacket(MysqlIO.java:3357)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3797)
	at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:2470)
	at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2617)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2550)
	at com.mysql.jdbc.PreparedStatement.executeInternal(PreparedStatement.java:1861)
	at com.mysql.jdbc.PreparedStatement.execute(PreparedStatement.java:1192)
	at sun.reflect.GeneratedMethodAccessor146.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:483)
	at com.mysql.jdbc.MultiHostConnectionProxy$JdbcInterfaceProxy.invoke(MultiHostConnectionProxy.java:91)
	at com.sun.proxy.$Proxy74.execute(Unknown Source)
	at org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
	at org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
	at org.apache.ibatis.executor.statement.PreparedStatementHandler.query(PreparedStatementHandler.java:62)
	at org.apache.ibatis.executor.statement.RoutingStatementHandler.query(RoutingStatementHandler.java:78)
	at sun.reflect.GeneratedMethodAccessor145.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:483)
	at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:63)
	at com.sun.proxy.$Proxy70.query(Unknown Source)
	at org.apache.ibatis.executor.ReuseExecutor.doQuery(ReuseExecutor.java:59)
	at org.apache.ibatis.executor.BaseExecutor.queryFromDatabase(BaseExecutor.java:303)
	at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:154)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:96)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:82)
	at sun.reflect.GeneratedMethodAccessor156.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:483)
	at org.apache.ibatis.plugin.Invocation.proceed(Invocation.java:49)
	at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:61)
	at com.sun.proxy.$Proxy69.query(Unknown Source)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:120)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:113)
	at sun.reflect.GeneratedMethodAccessor141.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:483)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:358)
	... 104 more
Caused by: java.net.SocketTimeoutException: Read timed out
	at java.net.SocketInputStream.socketRead0(Native Method)
	at java.net.SocketInputStream.read(SocketInputStream.java:150)
	at java.net.SocketInputStream.read(SocketInputStream.java:121)
	at com.mysql.jdbc.util.ReadAheadInputStream.fill(ReadAheadInputStream.java:100)
	at com.mysql.jdbc.util.ReadAheadInputStream.readFromUnderlyingStreamIfNecessary(ReadAheadInputStream.java:143)
	at com.mysql.jdbc.util.ReadAheadInputStream.read(ReadAheadInputStream.java:173)
	at com.mysql.jdbc.MysqlIO.readFully(MysqlIO.java:2946)
	at com.mysql.jdbc.MysqlIO.reuseAndReadPacket(MysqlIO.java:3367)
	... 143 more

  1. 棧頂異常信息翻譯一下
    The last packet successfully received from the server was 120,104 milliseconds ago. The last packet sent successfully to the server was 120,099 milliseconds ago.

最後一次從服務端成功接收的數據包是120104毫秒(120秒多一點)之前,最後一次成功發送到服務端的數據包是120099毫秒(120秒多一點)之前。

  1. 棧底的Caused by: java.net.SocketTimeoutException: Read timed out已經很清楚的說明了引起異常的原因是讀超時。
超時設置總結

給出一個生產系統的真實配置來說明一下各種超時參數該如何配置

  • connect/socket timeout
connectTimeout=1000;socketTimeout=120000
  • statement timeout
<setting name="defaultStatementTimeout" value="3" />
  • transaction timeout
未設置

該系統就是上文提到的一個核心業務系統,需要對外提供高性能的接口,同時也承擔了定時數據刷新任務。(此處先不討論拆分成兩個系統的事情)。該系統所依賴的數據庫集羣每天凌晨都會有大量的etl數據寫操作,屆時數據庫io壓力相當大。

  1. 對外提供高性能接口,大部分操作可命中緩存,少部分請求會穿透到庫,鑑於接口性能要求以及外部代理層的3秒超時設置,也就是說如果sql執行超過3秒就相當於請求失敗。所以把statement timeout 設置爲3秒。
  2. 數據刷新任務的自身邏輯就包含着從庫中刪除老數據,並從外部的nosql數據源抽取新數據寫入到庫中。讀寫操作自身就比較繁忙,若再和etl數據寫入趕到一起的話很容易產生請求阻塞或連接斷開的情況。socket timeout一定要配置的,但是鑑於是處於凌晨的定時任務,超時時間可以設置稍微長一些,可以減少失敗批次的概率。所以設置了120秒(2分鐘),這個值也是根據自身系統情況調整過幾次之後定的值。
  3. 鑑於自身業務提供的高性能接口是隻讀服務、所以無需開啓事務。定時刷新任務存在循環的大批量的寫操作,且還存在高延遲的可能,單批寫操作是針對單表,所以也必要開啓小事務,更不可能採取一個大事務的方式來保證所有批次一起提交,系統層面採用小批次失敗重試機制來保證成功率。

上文 《socket timeout 異常堆棧》中的異常就是真實環境下的異常堆棧。到這裏你是否有個疑問,statement設置了3秒超時,socket設置了2分鐘超時,按理說應該是statement執行sql先超時,應該永遠得到的是statement的超時異常,爲什麼會得到socket read timeout異常呢?

這個可以這樣理解,當socket已經存在阻塞的時候,我們的異步的statement取消任務同樣也要經由socket把kill命令傳輸給數據庫層面,這樣的請求同樣也存在被阻塞的可能,也就是說取消請求同樣也可能得不到響應。即使取消請求很快得到了迴應,因爲主線程socket還在阻塞,得不到mysql線程被kill的消息,也無法繼續往下執行,最終還是會因爲超時異常而繞過後續的和取消任務有關的後置校驗。當然如果主線程阻塞時間超過3秒,但不到2分鐘,且statement取消線程獲取到資源快速得到響應。這樣的場景下我們得到的就是statement超時異常(MySQLTimeoutException)或者取消失敗異常(SQLException)了。而在我們上文提到的兩個場景中,我們得到都是取消失敗異常(SQLException)。

#####爲什麼取消會失敗
我們設置了statement超時,那麼當statement執行時間超過設定值的時候,我們希望他能被kill掉,進而通過業務層面能夠得到一個期待中的異常(MySQLTimeoutException),但是我們卻得到了一個取消失敗異常(SQLException),而且我們上文中也提到,這種情況下statement的sql是沒有被取消掉的,還是正常執行的(執行用時肯定超過了設定的超時時間)。這也就是等於說我們的statement超時設置沒有起到應有的作用。所以取消失敗這個情況很有必要仔細追查一下。

再探Unknown thread id

上文源碼分析中已經提到,這個異常是在執行kill query xxid的時候拋出的,字面意思就是“未知的線程id”,根據字面意思我們可以猜測應該是mysql服務器無法識別該線程id,無法識別很有可能是因爲這個線程id不存在,如果是這個線程id不存在那麼又可能是因爲什麼原因不存在呢?

  1. 從來就沒有存在過(應用層提供的id就是錯誤的)
  2. 線程存在過,因爲某些原因可能已經被銷燬了。

要想得到上面問題的準確答案,我們可以在線下環境嘗試復現一下這個異常場景,進行觀察定位。於是我們需要做以下事情。
1)監控mysql的線程
我們要想監控到mysql服務的線程信息,可以通過information_schema.processlist表查看到當前運行的線程信息

select  *  from    information_schema.processlist

2)準備一個慢sql
需要一個慢sql,起碼要保證大部分情況下執行時間都超過1s,那麼我們就可以把statement timeout設置爲1s,這樣執行該sql的時候就可以觸發超時。
我找了一個測試環境的數據表,表裏有40多萬的數據,然後寫了個稍微複雜點的分組查詢,最後經測試幾乎每次執行耗時都要超過3s。該sql如下:

select c_name,c_code,d_name,d_code,count(*) from t_xxx_xxx group by c_name,c_code,d_name,d_code  order by rand() limit 100

大家不必太關注這個sql的業務邏輯【沒有業務意義的】,僅僅就是爲了造一個慢sql而已。
3)代碼以及配置準備
1、在mapper語句層面配置超時

<select id="getListFromGroupBy" resultType="java.util.Map"
		useCache="false" timeout="1">
		select
		c_name,c_code,d_name,d_code,count(*) from
		t_xxx_xxx group by
		c_name,c_code,d_name,d_code order by rand() limit
		100
	</select>

以上針對那個平均執行耗時超過3s的sql配置查詢超時(statement 超時)爲1s,這樣能夠9成以上概率是可以觸發超時異常的。

2、junit單元測試代碼

@Test
public void testStatementTimeout() {
    List<Map<String, Object>> list = xxxBusiness.getListFromGroupBy(); // 最終會執行那個慢sql
    Assert.assertTrue(list.size() == 100);
}

4)啓動測試
1、啓動單元測試
2、從mysql線程信息中找到我們的sql
由於我所使用的mysql用戶權限較大,爲了能夠直觀明瞭了關注到重點信息,修改了一下查看進行信息的sql

select `ID`,`HOST`,`COMMAND`,`INFO` from information_schema.processlist  where db='db_xxx' and length(info)>0 order by id desc;

這樣我們就僅僅關注了db_xxx庫的所有sql語句線程了。
鑑於我們的業務sql需要3s左右才能執行完成,所以我們可以手動不間斷的刷新這條查詢線程的sql,就能夠發現這個業務sql對應的記錄信息。一旦發現就可以停止刷新了,因爲再刷新可能記錄就不存在了(因爲業務sql執行完成之後線程就銷燬了)。

  • 在單元測試剛啓動,業務sql還沒有執行的時候,我們得到的結果列表如下:
ID HOST COMMAND INFO
553063 192.168.35.194:59007 Query select ID,HOST,COMMAND,INFO from information_schema.processlist where db=‘db_xxx’ and length(info)>0 order by id desc LIMIT 0, 1000
  • 刷新幾次後,業務sql開始執行單未執行完成時,我們得到的結果列表如下:
ID HOST COMMAND INFO
553696 192.168.2.10:40730 Query select c_name,c_code,d_name,d_code,count(*) from t_xxx_xxx group by c_name,c_code,d_name,d_code order by rand() limit 100
553063 192.168.35.194:59007 Query select ID,HOST,COMMAND,INFO from information_schema.processlist where db=‘db_xxx’ and length(info)>0 order by id desc LIMIT 0, 1000

備註:表格裏的ID列對應的就是線程id,HOST列對應的是發起請求的客戶端地址(192.168.35.194是我本機ip)
3、從異常信息中獲取線程id
junit的異常堆棧信息如下

org.springframework.jdbc.UncategorizedSQLException: 
### Error querying database.  Cause: java.sql.SQLException: Unknown thread id: 3805330
### The error may exist in mapper/xxx/XxxMapper.xml
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: select c_name,c_code,d_name,d_code,count(*) from t_xxx_xxx group by c_name,c_code,d_name,d_code  order by rand() limit 100
### Cause: java.sql.SQLException: Unknown thread id: 3805330
; uncategorized SQLException for SQL []; SQL state [HY000]; error code [1094]; Unknown thread id: 3805330; nested exception is java.sql.SQLException: Unknown thread id: 3805330
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:84)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
	// 省略了部分信息
Caused by: java.sql.SQLException: Unknown thread id: 3805330
	at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:959)
	// 省略了部分信息
	at com.mysql.jdbc.StatementImpl$CancelTask$1.run(StatementImpl.java:119)

從異常信息中我們可以得到要取消的線程id爲:3805330


5)分析測試結果
通過以上實驗,我們成功復現了Unknown thread id異常。

通過對mysql線程信息的觀察,我們確定了業務sql對應的線程id是【553696】,但是取消線程要取消的線程id確是【3805330】,二者直觀上看數值就相差懸殊,很顯然我們要取消的是一個不正確的id。通過這樣的現象可以回答我們之前的問題。

線程id不存在是因爲:從來就沒有存在過(應用層提供的id就是錯誤的)。

接下來我們分析爲何應用層提供的id是錯誤的?

還是繼續分析我們測試過程中觀察到的數據。之前說過mysql線程信息中HOST列表示的是通信的客戶端地址,我通過本機的mysql GUI工具連接上遠程數據庫,併發起查詢processlist的請求,所以結果集的第二行中的HOST對應我本機的ip:192.168.35.194。可是我同樣是在本機進行的單元測試,爲什麼業務sql對應的ip是:192.168.2.10而不是192.168.35.194呢?

這是因爲我們用了ProxySQL,ProxySQL是一個高性能的MySQL中間件,提供強大的規則引擎,他的其中一個特性是可幫助我們的應用程序透明的實現讀寫分離。

淺析ProxySQL

正是因爲我們的應用連接的是ProxySQL,而不是真正的mysql,所以我們應用和數據庫之間的請求響應都是由ProxySQL來代理轉發的,所以纔會出現,連接數據庫的客戶端是ProxySQL的所屬主機而不是我本機。

既然我們應用和mysql之間有一層中間件,那麼應用層所得到的錯誤的id,會不會和ProxySQL有關,會不會是ProxySQL中的什麼id呢?

於是在ProxySQL的官網查閱了一番,最後得到了如下有效信息,可以通過以下sql查看ProxySQL的進程(線程)信息

select * from stats_mysql_processlist

鑑於數據庫中間件由DBA維護,研發沒有直接權限,所以求助DBA查詢了一下,通過DBA給出的結果集發現,原來我們得到的【3805330】對應的是ProxySQL的SessionID。

ProxySQL是一定要遵守mysql通信協議的,不然就談不上對請求和接收兩端透明;ProxySQL作爲一個代理層中間件,爲了實現自身的價值,使得他可能需要對通信內容進行部分更改(僅限於部分協議中規定的屬性)以適應自身的功能需要。

在應用層和mysql建立TCP連接後,mysql server端主動發起的握手請求報文中會包含有一個4字節的connection id值,這個值就對應着mysql的線程id,ProxySQL把這個id替換成自己的SessionID返回給應用層。

應用層發起kill命令,附帶的id就是SessionID,但是因爲kill命令屬於請求正文,ProxySQL不能分析和更改請求正文的,所以把kill query SessionID的命令發送到mysql時,mysql里根本找不到對應的ProxySQL的SessionID,所以拋出Unknown thread id異常。這裏還有一個更危險的場景,如果恰好有一個mysql的線程id和SessionID相同,那就會是各種奇異現象了。

所以我們得出的結論是:

ProxySQL作爲一個mysql中間件,對於mysql服務端發送給應用客戶端的握手報文的connection id值進行了替換,導致了應用客戶端在kill場景中,kill了錯誤的id,從而會引發此種Unknown thread id異常。注意:我們的結論是我們的場景下

如何解決

不使用ProxySQL

接上文提到的測試用例,修改數據庫連接配置,改爲直連數據庫。其他配置和代碼一律維持原樣。最終我們得到了如下異常:

org.springframework.dao.QueryTimeoutException: 
### Error querying database.  Cause: com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
### The error may exist in mapper/xxx/XxxMapper.xml
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: select c_name,c_code,d_name,d_code,count(*) from t_xxx_xxx group by c_name,c_code,d_name,d_code  order by rand() limit 100
### Cause: com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
; SQL []; Statement cancelled due to timeout or client request; nested exception is com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
	at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:118)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:73)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
	at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:73)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:371)
	at com.sun.proxy.$Proxy31.selectList(Unknown Source)
	at org.mybatis.spring.SqlSessionTemplate.selectList(SqlSessionTemplate.java:198)
	at org.apache.ibatis.binding.MapperMethod.executeForMany(MapperMethod.java:122)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:64)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:53)
	at com.sun.proxy.$Proxy39.getListFromGroupBy(Unknown Source)
	// 省略了部分信息
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
	at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
	at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:254)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:89)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
	at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:193)
	at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:86)
	at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:459)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:678)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:382)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:192)
Caused by: com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
	at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2765)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2550)
	at com.mysql.jdbc.PreparedStatement.executeInternal(PreparedStatement.java:1861)
	at com.mysql.jdbc.PreparedStatement.execute(PreparedStatement.java:1192)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at com.mysql.jdbc.MultiHostConnectionProxy$JdbcInterfaceProxy.invoke(MultiHostConnectionProxy.java:91)
	at com.sun.proxy.$Proxy165.execute(Unknown Source)
	at org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
	at org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
	at org.apache.ibatis.executor.statement.PreparedStatementHandler.query(PreparedStatementHandler.java:62)
	at org.apache.ibatis.executor.statement.RoutingStatementHandler.query(RoutingStatementHandler.java:78)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:63)
	at com.sun.proxy.$Proxy161.query(Unknown Source)
	at org.apache.ibatis.executor.ReuseExecutor.doQuery(ReuseExecutor.java:59)
	at org.apache.ibatis.executor.BaseExecutor.queryFromDatabase(BaseExecutor.java:303)
	at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:154)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:102)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:82)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.apache.ibatis.plugin.Invocation.proceed(Invocation.java:49)
	at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:61)
	at com.sun.proxy.$Proxy160.query(Unknown Source)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:120)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:113)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	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:358)
	... 42 more
  • spring框架的包裝異常爲:org.springframework.dao.QueryTimeoutException
  • 被spring包裝的cause爲:com.mysql.jdbc.exceptions.MySQLTimeoutException

這也間接證明了我們之前的結論,statement超時場景下是因爲ProxySQL的問題引發了Unknown thread id異常。

不使用ProxySQL可以解決這個問題,但是也達不到我們用ProxySQL的目的了(對應用透明的讀寫分離)

從ProxySql官網尋求答案

經過一番求索,最終在ProxySql的issue板塊中找到了答案。

renecannao commented on 25 Nov 2018
Feature implemented in 1.4.13 and 2.0.0

ProxySql的作者針對類似問題的回覆是在1.4.13版本和2.0.0版本這個問題得到了修復。

於是和DBA確認了下線上環境和測試環境當前用的版本

  • 線上環境是1.3的版本
  • 測試環境是1.4.8的版本
升級ProxySql

爲了驗證官網的說法,DBA協助升級了測試環境的ProxySql版本到1.4.15。
接上文提到的測試用例,修改數據庫連接配置,再改爲連接ProxySql。其他配置和代碼一律維持原樣。最終我們得到了如下異常:

org.springframework.dao.QueryTimeoutException: 
### Error querying database.  Cause: com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
### The error may exist in mapper/xxx/XxxMapper.xml
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: select c_name,c_code,d_name,d_code,count(*) from t_xxx_xxx group by c_name,c_code,d_name,d_code  order by rand() limit 100
### Cause: com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
; SQL []; Statement cancelled due to timeout or client request; nested exception is com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
	at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:118)
//省略了部分信息

可見我們通過升級ProxySql到1.4.13以上版本的時候,unknown thread id的問題得到解決。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章