0%

ResultSet初探

简介

ResultSet 是一个接口,具体操作由jdbc驱动实现,本文以MySQL Driver为例分析 ResultSet 的实现。

实验

测试 executeQuery 查询结果大于 jvm 内存时会发生什么。

步骤

  1. 创建数据库 db_research

    1
    create database db_search;
  2. 创建表 table0, 具有两个字段 name varchar(255), file longblob

    1
    create table table0(name varchar(255), file longblob);
  3. 插入 64file 大小为 1024*1024 B = 1 MB 的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    pStat = conn.prepareStatement("insert into table0 values(?, ?);");
    for (int i=0; i<64; i++) {
    pStat.setString(1, "hello");
    pStat.setBinaryStream(2, new ByteArrayInputStream(
    new String(new char[1024*1024]).replace('\0', 'a').getBytes(StandardCharsets.UTF_8)));
    pStat.addBatch();
    }

    int ret[] = pStat.executeBatch();
    pStat.close();
  4. table0 中取所有数据,在默认虚拟机参数下该操作会正常执行

    1
    2
    3
    4
    5
    6
    pStat = conn.prepareStatement("select * from table0;");
    ResultSet rs = pStat.executeQuery();
    while (rs.next()) {
    System.out.println(rs.getString("name"));
    System.out.println(rs.getBinaryStream("file").read());
    }
  5. 限制 jvm 内存为 16MB,再次执行上面的 select 操作,则会由于内存不足而抛出错误,由此推测普通的查询操作试图将所有查询结果缓存到本地供用户使用

    1
    2
    3
    4
    5
    6
    7
    Exception in thread "main" java.sql.SQLException: Java heap space
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129)
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97)
    at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
    at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953)
    at com.mysql.cj.jdbc.ClientPreparedStatement.executeQuery(ClientPreparedStatement.java:1003)
    at Main.main(Main.java:46)

结论

根据上面的测试推测,Mysql Driver试图缓存所有查询结果到本地供 ResultSet 使用(具体存放位置暂时不知道,反之是本地,占用当前进程的jvm空间),并没有在内存不足时采取一些措施来避免出错,经过查阅资料,得知可以采用流式查询来避免查询量过大时爆内存的问题

1
2
3
4
5
6
7
8
pStat = conn.prepareStatement("select * from table0;", 
ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
pStat.setFetchSize(Integer.MIN_VALUE);
ResultSet rs = pStat.executeQuery();
while (rs.next()) {
System.out.println(rs.getString("name"));
System.out.println(rs.getBinaryStream("file").read());
}

分析

经过查阅资料,得知 MySQL JDBC Driver 实际上有三种查询方式,普通查询,流式查询,基于游标的查询。下面的是最新源代码,参考链接中作者使用的是旧版源代码。

普通查询和流式查询

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
 public <T extends Resultset> T readAllResults(int maxRows, boolean streamResults, NativePacketPayload resultPacket, boolean isBinaryEncoded,
ColumnDefinition metadata, ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory) throws IOException {

resultPacket.setPosition(0);
T topLevelResultSet = read(Resultset.class, maxRows, streamResults, resultPacket, isBinaryEncoded, metadata, resultSetFactory);

if (this.serverSession.hasMoreResults()) {
T currentResultSet = topLevelResultSet;
if (streamResults) {
currentResultSet = readNextResultset(currentResultSet, maxRows, true, isBinaryEncoded, resultSetFactory);
} else {
while (this.serverSession.hasMoreResults()) {
currentResultSet = readNextResultset(currentResultSet, maxRows, false, isBinaryEncoded, resultSetFactory);
}
clearInputStream();
}
}

if (this.hadWarnings) {
scanForAndThrowDataTruncation();
}

reclaimLargeReusablePacket();
return topLevelResultSet;
}

代码分析

上面代码中,对分析流式查询和普通查询最关键的代码如下,大概可以判断出,代码的意思:

若当前模式为流式查询,那么只读取(从哪里读暂不清楚)下一条存入 ResultSet;若为普通查询,则读取所有结果放入 ResultSet

1
2
3
4
5
6
7
8
9
10
11
12
if (this.serverSession.hasMoreResults()) {
T currentResultSet = topLevelResultSet;
// 流式查询,只读下一条,否则,将读取所有的结果
if (streamResults) {
currentResultSet = readNextResultset(currentResultSet, maxRows, true, isBinaryEncoded, resultSetFactory);
} else {
while (this.serverSession.hasMoreResults()) {
currentResultSet = readNextResultset(currentResultSet, maxRows, false, isBinaryEncoded, resultSetFactory);
}
clearInputStream();
}
}

能否在MySQL连接关闭后继续使用 ResultSet

下面的错误是我试图执行完 executeQuery之后关闭 MySQL服务来探究是否支持在连接断开后继续使用 ResultSet 的结果。

  1. 结果显示使用流式查询时,若连接断开(关闭MySQL服务器),将不能再使用 ResultSet
  2. 而使用普通查询时,在断开MySQL连接之后,依旧可以持续使用 ResultSet
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
32
33
34
35
36
37
38
39
Exception in thread "main" java.sql.SQLException: Error retrieving record: Unexpected Exception: java.io.EOFException message given: Can not read response from server. Expected to read 1,048,586 bytes, read 573,436 bytes before connection was unexpectedly lost.

Nested Stack Trace:


** BEGIN NESTED EXCEPTION **

java.io.EOFException
MESSAGE: Can not read response from server. Expected to read 1,048,586 bytes, read 573,436 bytes before connection was unexpectedly lost.

STACKTRACE:

java.io.EOFException: Can not read response from server. Expected to read 1,048,586 bytes, read 573,436 bytes before connection was unexpectedly lost.
at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:67)
at com.mysql.cj.protocol.a.SimplePacketReader.readMessage(SimplePacketReader.java:108)
at com.mysql.cj.protocol.a.SimplePacketReader.readMessage(SimplePacketReader.java:45)
at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readMessage(TimeTrackingPacketReader.java:57)
at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readMessage(TimeTrackingPacketReader.java:41)
at com.mysql.cj.protocol.a.MultiPacketReader.readMessage(MultiPacketReader.java:61)
at com.mysql.cj.protocol.a.MultiPacketReader.readMessage(MultiPacketReader.java:44)
at com.mysql.cj.protocol.a.ResultsetRowReader.read(ResultsetRowReader.java:75)
at com.mysql.cj.protocol.a.ResultsetRowReader.read(ResultsetRowReader.java:42)
at com.mysql.cj.protocol.a.NativeProtocol.read(NativeProtocol.java:1583)
at com.mysql.cj.protocol.a.result.ResultsetRowsStreaming.next(ResultsetRowsStreaming.java:193)
at com.mysql.cj.protocol.a.result.ResultsetRowsStreaming.next(ResultsetRowsStreaming.java:62)
at com.mysql.cj.jdbc.result.ResultSetImpl.next(ResultSetImpl.java:1738)
at Main.main(Main.java:54)

** END NESTED EXCEPTION **

at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129)
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97)
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
at com.mysql.cj.jdbc.result.ResultSetImpl.next(ResultSetImpl.java:1753)
at Main.main(Main.java:54)
Disconnected from the target VM, address: '127.0.0.1:8753', transport: 'socket'

Process finished with exit code 1

普通查询和流式查询的网络占用情况对比

通过Wireshark抓包(npcap txdy,winpcap不支持localhost抓包),发现:

  1. 流式传输时,会在每一次调用 rs.next 之后发生一次小规模的网络传输,推测可能是传输本次所需数据
  2. 普通查询时,只会在一开始调用 execQuery 时,发生一次超大规模的网络传输,推测可能是传输完所有所需数据

普通查询和流式查询的内存占用情况对比

  1. 使用普通查询时,若查询结果数据量较大,则程序对应的java进程内存占用量会明显增加
  2. 使用流式查询时,程序对应的java进程内存占用量与所读取的数据量没有明显关联

游标查询与另外两种的区别

游标查询源码暂未分析,根据查询到的资料,结合上面的测试,三种查询的异同如下:

  1. 流式查询普通查询 的区别发生在 客户端,服务端对这两种查询的反应都是一次查询出所有所需数据,然后试图全部发送给客户端。但是 流式查询 时,客户端在调用 rs.next() 才给服务端返回 ack(TCP协议) 以接收下一条数据,导致服务端不能一直发送数据,也就达到了 流式传输 的目的,而普通查询会在 execQuery() 时配合服务端接受所有的数据
  2. 流式查询游标查询 的区别发生在 服务端。服务端在流式传输中的行为上面已经说明;而服务端在游标查询中的行为是:按照 fecthSize 的指示查询出所需数目的数据并发送给客户端,这时客户端会直接接收 fetchSize 条数据(相比之下,流式查询客户端 一次从服务端接收一条),如果客户端需要更多数据,则会再次向服务端发送请求

参考资料

[1] mysql-connector-java 8.0.22 源代码
[2] https://www.jianshu.com/p/c7c5dbe63019