大数据量操作的场景大致如下:
-
数据迁移
-
数据导出
-
批量处理数据
在实际工作中当指定查询数据过大时,我们一般使用分页查询的方式一页一页的将数据放到内存处理。但有些情况不需要分页的方式查询数据或分很大一页查询数据时,如果一下子将数据全部加载出来到内存中,很可能会发生OOM(内存溢出);而且查询会很慢,因为框架耗费大量的时间和内存去把数据库查询的结果封装成我们想要的对象(实体类)。
举例:在业务系统需要从 MySQL 数据库里读取 100w 数据行进行处理,应该怎么做?
做法通常如下:
常规查询:
传入自定义Page<T>,循环分页获取
流式查询:
指的是查询成功后不是返回一个集合而是返回一个迭代器,应用可以通过迭代器每次取一条查询结果。流式查询的好处是能够降低内存使用。
MyBatis 中使用流式查询避免数据量过大导致 OOM ,但在流式查询的过程当中,数据库连接是保持打开状态的,因此要注意的是:
-
执行一个流式查询后,数据库访问框架就不负责关闭数据库连接了,需要应用在取完数据后自己关闭。
-
必须先读取(或关闭)结果集中的所有行,然后才能对连接发出任何其他查询,否则将引发异常。
MyBatis 流式查询接口
MyBatis 提供了一个叫 org.apache.ibatis.cursor.Cursor
的接口类用于流式查询,这个接口继承了 java.io.Closeable
和 java.lang.Iterable
接口,由此可知:
-
Cursor 是可关闭的;
-
Cursor 是可遍历的。
除此之外,Cursor 还提供了三个方法:
-
isOpen(): 用于在取数据之前判断 Cursor 对象是否是打开状态。只有当打开时 Cursor 才能取数据;
-
isConsumed(): 用于判断查询结果是否全部取完。
-
getCurrentIndex(): 返回已经获取了多少条数据
使用流式查询,则要保持对产生结果集的语句所引用的表的并发访问,因为其查询会独占连接,所以必须尽快处理
为什么要用流式查询?
如果有一个很大的查询结果需要遍历处理,又不想一次性将结果集装入客户端内存,就可以考虑使用流式查询;
分库分表场景下,单个表的查询结果集虽然不大,但如果某个查询跨了多个库多个表,又要做结果集的合并、排序等动作,依然有可能撑爆内存;详细研究了sharding-sphere
的代码不难发现,除了group by
与order by
字段不一样之外,其他的场景都非常适合使用流式查询,可以最大限度的降低对客户端内存的消耗。
1.配置游标分页
在MyBatis Plus中,使用游标分页需要在mybatis-config.xml或application.yml 中添加配置:
<!-- mybatis-config.xml -->
<configuration>
<!-- ...其他配置... -->
<settings>
<!-- 开启游标分页 -->
<setting name="useCursorFetch" value="true"/>
</settings>
</configuration>
或者在application.xml中:
mybatis-plus:
configuration:
settings:
useCursorFetch: true
2.使用Cursor接口
在DAO接口中,使用 Cursor接口进行游标分页查询:
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.cursor.Cursor;
public interface MyMapper {
Cursor<MyEntity> selectByCursor(Page<MyEntity> page);
}
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
@RequiredArgsConstructor
public class MyService {
private final MyMapper myMapper;
public void processData() {
int pageSize = 1000; // 指定每页数据量
int currentPage = 1;
Page<MyEntity> page = new Page<>(currentPage, pageSize);
Cursor<MyEntity> cursor = myMapper.selectByCursor(page);
while (cursor.isOpen() && cursor.hasNext()) {
MyEntity entity = cursor.next();
// 处理数据
}
cursor.close();
}
}
游标查询:
和流式一样,通过 fetchSize 参数,控制一次读取多少条数据(多次获取,一次多行),可以节省内存的消耗,而且还不需要一次性取出所有数据,可以进行逐条处理或逐条取出部分批量处理。一次查询指定 fetchSize
的数据,直到把数据全部处理完。
Mybatis 的处理加了两个注解:@Options
和 @ResultType
@Mapper
public interface SearchMapper extends BaseMapper<SearchEntity> {
// 方式一 多次获取,一次多行
@Select("SELECT bds.* FROM big_data_search bds ${ew.customSqlSegment} ")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 100000)
Page<SearchEntity> pageList(@Param("page") Page<SearchEntity> page, @Param(Constants.WRAPPER) QueryWrapper<SearchEntity> queryWrapper);
// 方式二 一次获取,一次一行
@Select("SELECT bds.* FROM big_data_search bds ${ew.customSqlSegment} ")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 100000)
@ResultType(SearchEntity.class)
void listData(@Param(Constants.WRAPPER) QueryWrapper<SearchEntity> queryWrapper, ResultHandler<SearchEntity> handler);
}
@Options
-
ResultSet.FORWORD_ONLY
:结果集的游标只能向下滚动 -
ResultSet.SCROLL_INSENSITIVE
:结果集的游标可以上下移动,当数据库变化时,当前结果集不变 -
ResultSet.SCROLL_SENSITIVE
:返回可滚动的结果集,当数据库变化时,当前结果集同步改变 -
fetchSize
:每次获取量
@ResultType
-
@ResultType(BigDataSearchEntity.class)
:转换成返回实体类型
注意:返回类型必须为 void ,因为查询的结果在
ResultHandler
里处理数据,所以这个 hander 也是必须的,可以使用 lambda 实现一个依次处理逻辑。
注意:
虽然上面的代码中都有 @Options
但实际操作却有不同:
-
方式一是多次查询,一次返回多条;
-
方式二是一次查询,一次返回一条;
原因:
Oracle 是从服务器一次取出 fetch size
条记录放在客户端,客户端处理完成一个批次后再向服务器取下一个批次,直到所有数据处理完成。
MySQL 是在执行 ResultSet.next()
方法时,会通过数据库连接一条一条的返回。flush buffer
的过程是阻塞式的,如果网络中发生了拥塞,send buffer
被填满,会导致 buffer 一直 flush 不出去,那 MySQL 的处理线程会阻塞,从而避免数据把客户端内存撑爆。
非流式查询和流式查询区别:
-
非流式查询:内存会随着查询记录的增长而近乎直线增长。
-
流式查询:内存会保持稳定,不会随着记录的增长而增长。其内存大小取决于批处理大小
BATCH_SIZE
的设置,该尺寸越大,内存会越大。所以BATCH_SIZE应该根据业务情况设置合适的大小。
另外要切记每次处理完一批结果要记得释放存储每批数据的临时容器。*.clear()
for (CardUpdateVO cardUpdateVO : cursor) {
updateVOList.add(cardUpdateVO);
if (updateVOList.size() == BATCH_SIZE) {
batchUpdateCardStatusAsync(buId, updateVOList, cardReqVO, isFirstIteration);
updateVOList.clear();
}