Stream ResultSet实现原理与应用实践

上一篇博客中我们分析了两种MySQL client避免内存溢出的方法和各自优劣,并分析了cursor方式的实现原理,文章最后根据测试和比较质疑了文档中对stream resultset的描述。

这篇我们来深入聊聊stream resultset的实现原理和应用实践。

原理解析

stream resultset的实现方式MySQL官方文档中没有说明,这里我们只有自食其力阅读源码了。

首先和组内MySQL大牛一起看了MySQL select过程源码,这里就不贴代码了,直接说结论:MySQL中没有任何对stream resultset的特别处理,直接说MySQL根本不知道有stream resultset这个东西也不足为过。

MySQL Server处理Select语句的过程如下:

  1. 调用存储引擎获取下一行记录
  2. 将获取的记录行按照MySQL通信协议编码到一个buffer(byte[])中
  3. 编码完这一行数据,如果buffer的length > max_buffer_length,则将这个buffer flush到网络中
  4. 回到第1个步骤

上述过程中,没有任何状态或标志表示是否需要stream resultset,可以确定,stream resultset不像cursor,它的实现中没有MySQL通信协议支持。MySQL仅仅是按照自己的节奏不断的把buffer写回网络中。但是需要注意的是,第三个步骤中,flush buffer的过程是阻塞式的,也就是说如果网络中发生了拥塞,send buffer被填满,会导致buffer一直flush不出去,那MySQL的处理线程会一直卡在步骤3上,从而避免数据把客户端内存撑爆。

send buffer等相关概念参考以下资料:

真相似乎已经浮出水面,下面我们来看看JDBC Connector/J中的相关代码:

ResultSetImpl.next()

public boolean next() throws SQLException {
  synchronized (checkClosed().getConnectionMutex()) {
    if (this.onInsertRow) {
      this.onInsertRow = false;
    }
    if (this.doingUpdates) {
      this.doingUpdates = false;
    }
    boolean b;
    if (!reallyResult()) {
      throw SQLError.createSQLException(
          Messages
              .getString("ResultSet.ResultSet_is_from_UPDATE._No_Data_115"),
          SQLError.SQL_STATE_GENERAL_ERROR, getExceptionInterceptor()); //$NON-NLS-1$
    }
    if (this.thisRow != null) {
      this.thisRow.closeOpenStreams();
    }
    if (this.rowData.size() == 0) {
      b = false;
    } else {
      this.thisRow = this.rowData.next();	
      if (this.thisRow == null) {
        b = false;
      } else {
        clearWarnings();		
        b = true;	
      }
    }
    setRowPositionValidity();
    return b;
  }
}

public boolean next ( ) throws SQLException {

synchronized ( checkClosed ( ) . getConnectionMutex ( ) ) {

if ( this . onInsertRow ) {

this . onInsertRow = false ;

}

if ( this . doingUpdates ) {

this . doingUpdates = false ;

}

boolean b ;

if ( ! reallyResult ( ) ) {

throw SQLError . createSQLException (

Messages

. getString ( "ResultSet.ResultSet_is_from_UPDATE._No_Data_115" ) ,

SQLError . SQL_STATE_GENERAL_ERROR , getExceptionInterceptor ( ) ) ; //$NON-NLS-1$

}

if ( this . thisRow != null ) {

this . thisRow . closeOpenStreams ( ) ;

}

if ( this . rowData . size ( ) == 0 ) {

b = false ;

} else {

this . thisRow = this . rowData . next ( ) ;     

if ( this . thisRow == null ) {

b = false ;

} else {

clearWarnings ( ) ;         

b = true ;     

}

}

setRowPositionValidity ( ) ;

return b ;

}

}

上述ResultSetImpl.next()的逻辑,ResultSetImpl每次从rowData中获取下一行记录,RowData的继承关系如下:

RowData继承关系

RowData继承关系

默认情况下,ResultSet会采用RowDataStatic实例,在生成RowDataStatic对象时就会把ResultSet中所有记录读到内存里,之后通过next()再一条条从内存中读。

RowDataCursor用在Server-Size cursor场景下,RowDataCursor首先会查看自己缓冲区有没有数据没有返回完,如果有,则返回下一行数据,如果都读完了,就触发一个COM_STMT_FETCH request给MySQL Server,并从返回的结果集中读出fetch_size行数据到缓冲区中,然后返回第一行数据。

当采用stream resultset后,ResultSet使用的是RowDataDynamic对象,而这个对象next()的逻辑如下:

RowDataDynamic

public ResultSetRow next() throws SQLException {
  nextRecord();
  if (this.nextRow == null && !this.streamerClosed && !this.moreResultsExisted) {
    this.io.closeStreamer(this);
    this.streamerClosed = true;
  }
  if (this.nextRow != null) {
    if (this.index != Integer.MAX_VALUE) {
      this.index++;
    }
  }
  return this.nextRow;
}

public ResultSetRow next ( ) throws SQLException {

nextRecord ( ) ;

if ( this . nextRow == null && ! this . streamerClosed && ! this . moreResultsExisted ) {

this . io . closeStreamer ( this ) ;

this . streamerClosed = true ;

}

if ( this . nextRow != null ) {

if ( this . index != Integer . MAX_VALUE ) {

this . index ++ ;

}

}

return this . nextRow ;

}

篇幅所限,这里就不给出更深入的代码了,有兴趣的同学可以自己跟一下,对比这三个实现类在next()方法上的不同,RowDataStatic一次读入所有数据,RowDataCursor一次读fetch_size行数据,而RowDataDynamic在调用next()时只会从IO中读取一行记录。

到此真相水落石出,stream resultset本身并没有特殊的实现,只是client在收到MySQL Server返回的数据包后一行一行地读到内存里,后面的数据读到内存里,前面的数据就可以被GC了,另外,由于client receive buffer的读入速度取决于应用调用next()的速度,再配合TCP拥塞控制机制,达到了通过调用next() 速度来决定MySQL Server发包速度。

回过头来看看MySQL官方对stream resultset的描述:

http://dev.mysql.com/doc/connector-j/en/connector-j-reference-implementation-notes.html By default, ResultSets are completely retrieved and stored in memory. In most cases this is the most efficient way to operate, and due to the design of the MySQL network protocol is easier to implement. If you are working with ResultSets that have a large number of rows or large values, and cannot allocate heap space in your JVM for the memory required, you can tell the driver to stream the results back one row at a time.

文档这里没有说明stream the results back one row at a time具体是怎样做的,很容易让人理解成每次向MySQL Server请求一行记录。事实上上篇博客中的文档就是这样理解错了,我也翻了很多中文资料,也有很多这样的说法。 如果在stream the results back one row at a time后面加一句from socket receive buffer就不会容易让人误解了 。

在上一篇中就有提到:stream resultset方式下扫描数据过程中可能因为通信问题而阻塞,如果是 select for update则增大了加锁时发生死锁的概率 。这点需要注意。

一个疑虑

可能会有同学有这样的疑虑:

如果MySQL Socket的send buffer因为网络拥塞而塞满了,会不会导致其他连接的数据也被卡住?

之前我也进入过这样的思维误区,因为我们连接MySQL的时候总是连接一个固定的IP和端口,也就是一个ServerSocket,而send buffer size是socket的一个option,给人的感觉就像所有的MySQL连接都共用一个send buffer,其实有这种想法正是我们对网络系统概念不熟的表现。

首先来验证这个猜想是错的:

依旧是上篇博客中的测试程序,在stream resultset方式下,next()后面设置断点,在第一次调用next()后停止step,使程序卡在第一次next()后,相应的MySQL Server则卡在flush buffer上。

用MySQL命令行尝试连接该MySQL,发现能连通,运行show processlist,如下:

show processlist after streaming blocked

show processlist after streaming blocked

说明MySQL Server确实有连接卡在写网络上,但是并不影响其他MySQL客户端和MySQL的交互,同理也不会影响一个程序中其他连接的交互。

虽然send buffer size是socket的一个option,但send buffer本身是面向连接的,对一个ServerSocket来说,多个连接共享它对send buffer的配置,但这些连接的send buffer是不同的,互不影响(如果共享send buffer,并发和指针冲突岂不是很麻烦?)。

一个坑

stream resultset看起来十分美好,既没有性能问题,也不会导致连接之间相互影响,那为什么不把它作为默认的获取ResultSet方式呢?

因为stream resultset方式下,应用调用next()时,可能还有大量数据存在于client receive buffer,MySQL send buffer,而且MySQL的select处理很可能也没有结束,此时这个连接是不能做任何其他事情的。如果在没有把流数据读完的情况下,在这个连接上发起一个新的query请求,Connector/J会返回异常如下:

Streaming result set com.mysql.jdbc.RowDataDynamic@4447393f is still active. No statements may be issued when any streaming result sets are open and in use on a given connection. Ensure that you have called .close() on any active streaming result sets before attempting more queries.

上述的异常我们称之为“流方式异常”,意思是只有先调用上个resultset.close(),将所有流数据读完并丢弃,才能发起下个query请求(场景请参考finalize实现原理与引发的血案一文)。

而普通resultset方式下,应用在调用executeQuery结束时,结果集就已经缓存在client内存中了,连接处于完全空闲的状态,这时候是可以用这个连接做其他事情的,这个resultset是否已经next()完不影响这个连接的复用。

stream resultset的这个特性要求我们在使用时必须时刻不能忘记关闭resultset,否则会直接导致相关连接不可用,一般APP和DDB Proxy都配有连接池,一旦连接不可用,只能该连接丢弃,创建新连接,打穿一旦发生,连接池的存在就无意义了。普通resultset则无此顾虑。

另外,一个连接中不能同时有多个stream resultset,这给DDB union和子查询等复杂查询的实现带来了困难,为了同时维护到一个数据库节点的多个stream resultset,需要引入多个Connection。

一个深坑

Connector/J的stream resultset还有一个深坑。

问题要追溯到DDB Proxy刚上线时,有一台机器的网络在周末经常出现瞬断,瞬断后DDB Proxy的日志中出现了大量流方式异常,客户端的很多请求也都被返回了流方式异常。如果不重启Proxy进程,异常会持续出现。

Proxy在网络瞬断时,对处于调用过程中的各个底层数据库的Statement,会并发调用它们的cancel()方法,于是我写了一个小程序来模拟这种场景:

程序意思很简单,主线程向mysql请求一个会执行很长时间的select,并使用stream resultset方式,在调用executeQuery()后,用另一个线程去cancel它。之后主线程循环地用这个连接再次执行同样的逻辑。在执行每个循环的逻辑之前,先执行”select 1″检测连接是否可用,如果不可用直接抛出异常,如果可以用,顶多会打出一些”SQLException caught”的信息,并最终完成10次循环:

StreamBugTest.java

public class StreamBugTest {

  public static void main(String[] args) throws SQLException, ClassNotFoundException {
    Connection con = null;
    try {
      Class.forName("com.mysql.jdbc.Driver");
      con = DriverManager.getConnection("jdbc:mysql://ddbtest0.server.163.org:3306/dbn1?characterEncoding=utf-8", "qs", "123456");
      for (int i = 0; i < 10; i++) {
        System.out.println("round " + (i + 1));
        con.createStatement().executeQuery("select 1"); // test connection is available
        final PreparedStatement pstmt = con.prepareStatement("select id from BlogTest where id < 200000");
        pstmt.setFetchSize(Integer.MIN_VALUE);
        new Thread() {
          public void run() {
            try {
              Thread.sleep(10);
              pstmt.cancel();
            } catch (Exception e) {
            }
          }
        }.start();
        ResultSet rs = null;
        try {
          rs = pstmt.executeQuery();
        } catch (SQLException e) {
          System.out.println("SQLException caught");
        } finally {
          if (rs != null) {
            try {
              rs.close();
            } catch (SQLException e) {
              System.out.println("SQLException caught");
            }
          }
        }
      }
    } finally {
      if (con != null)
        con.close();
    }
  }
}

public class StreamBugTest {

public static void main ( String [ ] args ) throws SQLException , ClassNotFoundException {

Connection con = null ;

try {

Class . forName ( "com.mysql.jdbc.Driver" ) ;

con = DriverManager . getConnection ( "jdbc:mysql://ddbtest0.server.163.org:3306/dbn1?characterEncoding=utf-8" , "qs" , "123456" ) ;

for ( int i = 0 ; i < 10 ; i ++ ) {

System . out . println ( "round " + ( i + 1 ) ) ;

con . createStatement ( ) . executeQuery ( "select 1" ) ; // test connection is available

final PreparedStatement pstmt = con . prepareStatement ( "select id from BlogTest where id < 200000" ) ;

pstmt . setFetchSize ( Integer . MIN_VALUE ) ;

new Thread ( ) {

public void run ( ) {

try {

Thread . sleep ( 10 ) ;

pstmt . cancel ( ) ;

} catch ( Exception e ) {

}

}

} . start ( ) ;

ResultSet rs = null ;

try {

rs = pstmt . executeQuery ( ) ;

} catch ( SQLException e ) {

System . out . println ( "SQLException caught" ) ;

} finally {

if ( rs != null ) {

try {

rs . close ( ) ;

} catch ( SQLException e ) {

System . out . println ( "SQLException caught" ) ;

}

}

}

}

} finally {

if ( con != null )

con . close ( ) ;

}

}

}

程序运行了5次,每次都在进入第二次循环后抛出流方式异常。因为这个异常的异常码为0,没有什么优雅的方式来识别,因此Proxy对这个异常的态度是防范于未然,然而一旦出现,由于连接是不断复用的,有问题的连接就会不断抛出流方式异常,导致相关请求无法执行。

因为之前一直不愿意认定Connector/J会有这样的问题,所以查明原因绕了好久。目前我们的解决方案是,在网络瞬断调用cancel后,关闭并丢弃所有相关连接。虽然会发生连接打穿,但是网络瞬断并不是经常发生,这点代价可以接受。

这个问题在写完这篇博客后向官方反馈,希望可以在新版本中得到解决。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值