最近在做一个计算相关的功能,大体就是有很多条SQL,每条SQL都涉及复杂地运算,最后要将所有计算结果进行合并分析。经初步测试,每个SQL起码会查出几十万条记录,我们现在有毛毛多的这种SQL。

最大的问题不在于速度,毕竟涉及运算的功能,想要从速度入手就得靠中间件和算法了。内存占用才是我们最需要注意的,一旦数据量很大且一次性冲入Java堆内存,程序会直接OOM然后离开人世。比如使用非分页的普通查询,这张表1000w条数据你有多少要多少,除非你设置-xms 128g,否则程序是一定会死的。

当然平时我们一般都会指定分页参数,但遇到大数据量查询时,为了内存的身体健康,还是需要一些特殊的方式——流式查询与游标查询。

1 流式查询

采用传统的Stream流式思想,将直接提供数据替换成提供获取数据的管道,客户端读取数据时直接从管道中遍历获取;整个读取的过程需要客户端保持和服务端的连接,也很好理解,它实际是一个管道,管道得通着才能取数据。

流式查询有两种使用方式,一种是用Cursor作为返回值,对数据进行遍历操作;一种是不设置返回值,在入参中传入一个ResultHandler作为回调处理数据。本文将基于Mybatis具体介绍使用方法。

这两种返回值的使用方式是相似的,唯一区别就是返回值不同。Mybatis查询有两种方式,一种是基于注解加在Mapper接口上方,一种是写在xml文件中,主要需要设置以下几个属性:

ResultSetType结果集读取方式
FetchSizeMySQL服务端单次发送至客户端的数据条数
ResultType这个眼熟吧,设置返回实体类映射

ResultType没什么好说的,一般Mybatis查询都会用到,我们着重介绍一下ResultSetType和FetchSize。ResultSetType有4种可选项,我们点进去看看,DEFAULT不谈,主要是下面三种。

DEFAULT(-1),FORWARD_ONLY(1003),SCROLL_INSENSITIVE(1004),SCROLL_SENSITIVE(1005);
  • FORWARD_ONLY,顾名思义只能向前,即数据只能向前读取,是不是就类似一个流水的管道,读一条就相当于水流过去一些。也是我们需要选用的。
  • SCROLL_INSENSITIVE,不敏感滚动,和下面那个差不多,都是可以向后读或向前读;这意味着已读取过的数据不能丢掉,要继续保存在内存中,因为有可能会回去再次读取他们。
  • SCROLL_SENSITIVE,敏感滚动,和上面那个差不多。

这么一比较就看得出来,当选的一定是FORWARD_ONLY,我们亟需解决的就是大数据量对内存的影响,再用后面两个还是会放在内存中。

再看FetchSize,这个概念在许多服务中都有提及,例如RabbitMQ中是消费者取过来预处理的消息数量,但在MySQL中完全不是一个概念。MySQL的数据传输是基于C/S的阻塞机制,即Client设置FetchSize = 1000,而Server查出来10000条数据,按照常理应该是Server智能地使用分页策略1000条1000条取;实际不是,Server查出来多少就是多少,他会放在自己特定的内存空间内,只是会根据FetchSize的大小一点一点传送给Client——利用C/S的通讯阻塞,发1000条、堵一下、发1000条、堵一下……。

话又说回来,怎么配置这个FetchSize呢?JDBC官方给出的答案是设置为“Integer.MIN_VALUE”,具体原因不清楚,但我猜是为了和游标查询区分开,因为一会你会发现流式查询和游标查询唯一的区别就是FetchSize的大小。

设置好这3个参数以后,就可以用Cursor或者ResultHandler来处理返回值了。有如下几种写法。

(1)注解式

@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)@ResultType(ResultVo.class)@Select("SELECT *, 0 orderType FROM `table`\n" +"WHERE username = #{userName}")Cursor listOrders(@Param("userName") String userName);@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)@ResultType(ResultVo.class)@Select("SELECT *, 0 orderType FROM `table`\n" +"WHERE username = #{userName}")void listOrders2(@Param("userName") String userName, ResultHandler handler);

使用Mybatis的注解,在@Options中指定查询配置参数,在@ResultType中指定返回值类型,在@Select中指定查询语句。最后用Cursor接收返回值,Cursor是可遍历的,所以直接Foreach遍历即可;或者用ResultHandler处理数据回调,在调用方式时传入new ResultHandler并写明处理逻辑。

(2)xml方式

SELECT *, 1 stuffCount, 1 orderType FROM `table`WHERE username = #{userName}

本质是相同的,只不过是将注解中的内容放进了标签中,返回值和数据处理方式也一致。

需要注意的是,不可以注解 + xml混合使用,比如注解指定fetchSize,xml只写查询语句,这种只有xml语句会生效!!!要不全用注解,要不全用xml!!!

2 游标查询

游标查询主要依靠FetchSize属性,指定Server每次传输给Client数据的条数。这种方式由于我没有实验就不卖弄了,使用方式和流式查询基本一致,只是FetchSize不能是Integer.MIN_VALUE,而是一个真实的数字,不能太大也不能太小,太大了内存受不了,太小了客户受不了。

不过JDBC查询默认是不支持FetchSize属性的,需要在JDBC连接URL后面加上“useCursorFetch=true”。这就不是很理想了,你说你写个代码还要改数据库连接的属性,万一后面出任何查询上的问题,都有可能追溯到你头上,所以我没敢用游标查询。

3 注意点

流式查询由于需要保持客户端与服务端的连接,而一般查询提交完连接就会关闭;因此我们需要保持事务开启,否则会报“A Cursor is already closed.”,即Cursor已经关闭,没法再读取了。最简单的方法就是在方法上加@Transactional,在查询完毕以前事务会一直持有这个数据库连接,但我们在使用完毕后也要自行关闭连接,显式调用Cursor.close(),或者用try with resource语句。

还要知道如何判断自己是否使用了流式查询或游标查询,下面是几个数据集的对应关系:

查询方式结果集类型行数据类型
普通分页ResultsetRowsStaticRowDataStatic
流式查询ResultsetRowsStreamingRowDataDynamic
游标查询ResultsetRowsCursorRowDataCursor

我使用到的流式查询,debug到DefaultResultSetHandler.class中,显示rsw中确实存储了ResultSetRowsStreaming。

4 优劣分析

这3种查询方式,常规非大数据模式下普通查询最快,其次是流式查询,最次是游标查询。

主要是由于游标查询需要和数据库进行多次网络交互,Client处理完这部分后再拉取下一部分数据,因此会比较慢。但是流式查询又会长时间占用同一个数据库连接,因此要取舍一下是能接受连接一直持有但是可能会堵住导致响应慢,还是可能占用较多连接数但单次响应快。