一次 mysql jdbc 的 Unknown thread id: 的问题排查

700 阅读2分钟

现象

在一次用 jdbc 来进行数据查询,为了避免 sql 耗时过长,给 statement 设置了 timeout,在超时后正常来说应该是会返回 MySQLTimeoutException。 java.sql.Statement#setQueryTimeout

com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
	at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2827)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2483)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2441)
	at com.mysql.jdbc.StatementImpl.executeQuery(StatementImpl.java:1381)
	at com.alibaba.druid.pool.DruidPooledStatement.executeQuery(DruidPooledStatement.java:308)

但有时候会出现 Caused by: java.sql.SQLException: Unknown thread id: xxx,并且查询线程一直没释放,还在阻塞中。

java.sql.SQLException: Unknown thread id: 85601322
	at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:964)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3973)
	at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3909)
	at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:2527)
	at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2680)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2483)
	at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2441)
	at com.mysql.jdbc.StatementImpl.executeInternal(StatementImpl.java:845)
	at com.mysql.jdbc.StatementImpl.execute(StatementImpl.java:745)
	at com.mysql.jdbc.StatementImpl$CancelTask$1.run(StatementImpl.java:120)

异常分析

从日志和堆栈可以看出,抛出 Unknown thread id: xxx 不是在查询的线程里面的,所以并不会释放查询的线程。

根据异常的堆栈,翻看 jdbc 里面代码,发现上面的堆栈是一个定时任务,并且 execSql 并非要查询的 sql,而是 KILL QUERY XXX。

class CancelTask extends TimerTask {
    SQLException caughtWhileCancelling = null;
    StatementImpl toCancel;
    Properties origConnProps = null;
    String origConnURL = "";
    long origConnId = 0;

    CancelTask(StatementImpl cancellee) throws SQLException {
        this.toCancel = cancellee;
        this.origConnProps = new Properties();

        Properties props = StatementImpl.this.connection.getProperties();

        Enumeration<?> keys = props.propertyNames();

        while (keys.hasMoreElements()) {
            String key = keys.nextElement().toString();
            this.origConnProps.setProperty(key, props.getProperty(key));
        }

        this.origConnURL = StatementImpl.this.connection.getURL();
        this.origConnId = StatementImpl.this.connection.getId();
    }

    @Override
    public void run() {

        Thread cancelThread = new Thread() {

            @Override
            public void run() {

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

                try {
                    MySQLConnection physicalConn = StatementImpl.this.physicalConnection.get();
                    if (physicalConn != null) {
                        if (physicalConn.getQUERYTimeoutKillsConnection()) {
                            CancelTask.this.toCancel.wasCancelled = true;
                            CancelTask.this.toCancel.wasCancelledByTimeout = true;
                            physicalConn.realClose(false, false, true,
                                    new MySQLStatementCancelledException(Messages.getString("Statement.ConnectionKilledDueToTimeout")));
                        } else {
                            synchronized (StatementImpl.this.cancelTimeoutMutex) {
                                if (CancelTask.this.origConnURL.equals(physicalConn.getURL())) {
                                
                                    // 这里会创建新的连接
                                    cancelConn = physicalConn.duplicate();
                                    cancelStmt = cancelConn.createStatement();
                                    // 会执行下面的 sql 来关闭对应的查询
                                    cancelStmt.execute("KILL QUERY " + physicalConn.getId());
                                } 

jdbc 通过启动一个定时线程,会通过 physicalConn.duplicate() 创建了一个新的连接,新的连接再发送 KILL QUERY xx 命令,mysql kill 了查询,把 statement 的wasCancelled 和 wasCancelledByTimeout 置为 true。并会马上回复查询线程。查询线程判断 wasCancelled 和 wasCancelledByTimeout 的状态被改了,知道这次请求因为超时没有结果,因此抛了 MySQLTimeoutException。

// com.mysql.jdbc.StatementImpl#executeQUERY
// 省略了部分逻辑

synchronized (this.cancelTimeoutMutex) {
    if (this.wasCancelled) {
        SQLException cause = null;

        if (this.wasCancelledByTimeout) {
            cause = new MySQLTimeoutException();
        } else {
            cause = new MySQLStatementCancelledException();
        }

        resetCancelledState();

        throw cause;
    }
}

从上面代码看,如果是调用 StatementImpl#cancel,则抛出 MySQLStatementCancelledException。

结论

那为什么有时候能KILL 成功,有时候又不行呢? 实际上这里的 jdbc 连的是从库,而从库虽然域名只有一个,但实际上连接的时候通过负载均衡会被分到不同的库中,这样导致查询的 connectId 和 KILL query 的 connectId 所对应的库是不一样的,从而 KILL 失败了。只要连接多几次,并执行 show processlist 就会发现连接的 ip和端口是不同,这是因为可能被分配到不同的库执行 show processlist ,所以结果当然不同。

在多从库的场景无法限制查询时间,那可以怎么做呢? mysql 提供了 max_execution_time 来限制查询时间,max_execution_time 是从服务端进行限制,从而避免了发送 Kill QUERY 的问题。虽然只支持 select 查询,并且会对整个connect 进行限制,但对于限制慢查询也够了。可以在 url 上面个加上 &sessionVariables=max_execution_time=10 即可。

MySQL :: MySQL 8.4 Reference Manual :: 7.1.8 Server System Variables

image.png