使用 MyBatis 设置数据库超时

为什么在 MyBatis 的配置文件中设置了 defaultStatementTimeout 的值,但是却不管用呢?

一、MyBatis defaultStatementTimeout 工作机制

MyBatis 执行语句的默认超时时间是 25 秒:

1
2
3
<settings>
<setting name="defaultStatementTimeout" value="25"/>
</settings>

官方文档这样解释这个值的含义:

defaultStatementTimeout: Sets the number of seconds the driver will wait for a response from the database.

然而在实际使用 MyBatis 进行数据库查询的时候,其超时时间却远比这个大,像这篇文章中描述的情况似的,给人一种程序假死的感觉。通过 IDEA 的 debug 运行程序可以知道最终这个值是这么使用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// org.apache.ibatis.executor.statement.BaseStatementHandler
//
protected void setStatementTimeout(Statement stmt, Integer transactionTimeout) throws SQLException {
Integer queryTimeout = null;

if (mappedStatement.getTimeout() != null) {
queryTimeout = mappedStatement.getTimeout();
} else if (configuration.getDefaultStatementTimeout() != null) {
queryTimeout = configuration.getDefaultStatementTimeout();
}

// 设置超时
if (queryTimeout != null) {
stmt.setQueryTimeout(queryTimeout);
}

StatementUtil.applyTransactionTimeout(stmt, queryTimeout, transactionTimeout);
}

而运行时的 stmt 的值是 PreparedStatementLogger:

PreparedStatementLogger

PreparedStatementLogger 在内部封装了 PreparedStatement 对象,通过使用动态代理拦截方法的调用,来主要进行一个日志的记录与打印,其实际的方法调用都是作用在 PreparedStatement 这个对象上的。由上图也可以看出 PreparedStatement 的值实际上是 MySQL JDBC (项目中使用的是 5.1.40 版本) 驱动提供的类 JDBC42PreparedStatement

所以我们可以确定,MyBatis 本身对于 SQL 语句的执行并未作任何干预,而是直接交给了各个数据库驱动厂商来实现的。

二、MySQL Statement Timeout 工作机制

查看 MySQL 的超时机制我们发现其实际上是通过定义了一个 TimerTask 来实现的超时检测机制,其实超时之后运行的任务如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// com.mysql.jdbc.StatementImpl.java
//
class CancelTask extends TimerTask {

long connectionId = 0L;
StatementImpl toCancel;

CancelTask(StatementImpl cancellee) {
this.toCancel = cancellee;
}

@Override
public void run() {
Thread cancelThread = new Thread("Cancel Thread") {

@Override
public void run() {
// ...

String command = "KILL QUERY " + connectionId;
toCancel.execute(command);

// ...
}

};

cancelThread.start();
}

}

在执行一条 SQL 语句的时候,如果用户配置了 queryTimeout 的话,那么 MySQL 驱动会创建一个上述的 CancelTask,并安排这个任务在用户配置的超时时间后开始运行。代码如下所示:

1
2
3
4
5
6
7
8
9
10
// com.mysql.jdbc.StatementImpl.java
//
private boolean executeInternal(String sql, boolean returnGeneratedKeys) throws SQLException {
// ...

timeoutTask = new CancelTask(this);
locallyScopedConn.getCancelTimer().schedule(timeoutTask, this.timeoutInMillis);

// ...
}

客户端通过发送 KILL QUERY 命令可以提前终止运行在服务器端的 MySQL Server 当前正在执行的指定语句。然而,发送命令的方法 execute 是一个底层依赖网络的方法。客户端所有执行的 SQL 语句最后都是通过组装成合适的符合 MySQL 协议 (即能被 MySQL Server 解析) 的数据格式,然后通过 Socket 发送给服务器进行执行。SQL 语句最终发送给服务器是在这个函数里面进行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// com.mysql.jdbc.MysqlIO.java
//
final Buffer sendCommand(int command, String extraData, Buffer queryPacket, boolean skipCheck, String extraDataCharEncoding, int timeoutMillis)
throws SQLException {

// ...

this.sendPacket.writeByte((byte) command);
this.sendPacket.writeStringNoNull(extraData);

// ...

send(this.sendPacket, this.sendPacket.getPosition());

// ...

Buffer returnPacket = checkErrorPacket(command);
return returnPacket;

}

send 方法直接依赖 socket 的输出流,将一条命令发送给发送给服务器端:

1
2
3
4
5
6
7
8
9
10
11
// com.mysql.jdbc.MysqlIO.java
//
private final void send(Buffer packet, int packetLen) throws SQLException {
// ...

Buffer packetToSend = packet;
this.mysqlOutput.write(packetToSend.getByteBuffer(), 0, packetLen);
this.mysqlOutput.flush();

// ...
}

checkErrorPacket 方法也直接依赖 socket 的输入流,来读取 SQL 语句在服务器端返回的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// com.mysql.jdbc.MysqlIO.java
//
private final Buffer reuseAndReadPacket(Buffer reuse, int existingPacketLength) throws SQLException {

// ...
// Read the data from the server
int numBytesRead = readFully(this.mysqlInput, reuse.getByteBuffer(), 0, packetLength);

// ...

}

private final int readFully(InputStream in, byte[] b, int off, int len) throws IOException {
if (len < 0) {
throw new IndexOutOfBoundsException();
}

int n = 0;

while (n < len) {
int count = in.read(b, off + n, len - n);

if (count < 0) {
throw new EOFException(Messages.getString("MysqlIO.EOF", new Object[] { Integer.valueOf(len), Integer.valueOf(n) }));
}

n += count;
}

return n;
}

我们看到上述 MySQL 命令的发送过程,其都依赖操作系统提供的最底层的 I/O 流处理函数 read()write()。而问题也在这里,当客户端尝试执行 CancelTask 的时候,由于 execute 依赖 I/O 流,所以当网络不好的时候,就有可能一直阻塞在等待数据写入或者读出的状态上。由此会造成, CancelTask 一直处于阻塞状态。在这种情况下,开发者看到的状态是,程序不往下运行,程序没有结束等表象状况。

三、参考

推荐文章