现象
在一次用 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