大数据量查询导致 OOM 从 mybatis 源码角度分析以及解决方案

这里写目录标题

    • 前言
    • Mybatis 源码系列文章地址
    • CompletableFuture 常用方法简单介绍不做文本重点
    • 用 50 mb 内存查出 50 w数据方案介绍
    • 使用 mybatis 大数据量查询为什么会导致 oom?
    • FetchSize 参数原理说明
    • 用 50 mb 内存查出 50 w数据方案实现
    • 总结

前言

正所谓前人,栽树后人乘凉。随着项目业务逻辑越来越复杂,屎山代码越积越高,部分接口的响应速度开始变的有点慢,于是乎想着用 CompletableFuture 去将代码去优化一下。但是优化着优化着就感觉有点不对劲了,电脑温度蹭蹭的往上涨,一看内存都要爆了。oom一下子就出现了。

Mybatis 源码系列文章地址

点击查看 Mybatis 源码文章

CompletableFuture 常用方法简单介绍不做文本重点

创建一个异步任务并执行返回值为1

CompletableFuture<Integer> task1 = CompletableFuture.supplyAsync(() -> {return 1;});

阻塞主线程,等待 task1、task2 都执行完毕

 CompletableFuture.allOf(task1,task2).join();

获取 task1 任务的返回值,task1 未执行完成前主线程将一直阻塞

task1.get()

用 50 mb 内存查出 50 w数据方案介绍

可能很多人第一想到的是使用 limit 关键字分多批次去查询数据,最后将数据汇总。但是这里处理不好的话还是会存在问题的,首先你要考虑游标的问题,如果表结构 id 是自增的还好办,如果是非自增的有够你头疼的,而且你是将多批次查询得到的数据全部汇总到一个容器还是多个容器?不管是一个容器还是多个容器,最终是不是都是 50 w的数据打到了服务器内存上,避免不了 oom。业务代码中大致写了这么一条sql,后续业务逻辑需要根据数据做统计,导致一次性把50w的数据从数据库查出来了,一下子就把内存打满了,直接 oom。

  1. 方法一:想着用多线程去拆分,每次取的数据量小一点然后最后结果用CompletableFuture合并来着,但是写着写着进行测试的时候,每次取数据量这个值设置多少是个问题。
  2. 方法二:直接写复杂sql脚本逻辑封装在sql里面减少数据量的返回。(但是sql执行的慢呀,整体接口查询还是慢)
  3. 方法三:通过设置 FetchSize 参数流逝查询数据,这样内存将平稳,不会一下子就给他打满(优选
select * from user

使用 mybatis 大数据量查询为什么会导致 oom?

由于之前看过 mybatis 的源码,知道他其实就是对 jdbc 的封装而已,我这里就不卖关子的直接定位到问题代码。由于 mybatis 在解析结果集的时候,会将解析好的数据全部都放到 resultContext 容器中。最终调用 Mybatia 默认的 DefaultResultSetHandler 将数据存起来,一块返回给用户。注意由于是一次性全部解析好且全部数据一次性反给用户,这才造成了 OOM。为了避免 OOM 的产生,我们手动实现 Mybatis 为我们提供的扩展接口 ResultHandler 就行,每查出一部分数据,就 GC 一下,这样内存不就趋于平稳了。

  private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping)
    throws SQLException {
    DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
    ResultSet resultSet = rsw.getResultSet();
    skipRows(resultSet, rowBounds);
    //while循环直至解析完从数据库中返回的所有数据
    while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) {
      ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null);
      //根据 resultSet 获取每一行数据
      Object rowValue = getRowValue(rsw, discriminatedResultMap, null);
      //将解析好的每一条数据逐个存储在一个容器(resultContext)中,最终返回给我们
      storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
    }
  }


以上源码可以简化成以下这个代码,50w条数据不断的被解析放到 list 中,list 占用内存一直在蹭蹭的往上涨。当大到服务器最大内存时 oom 可不就出现了吗。这也是为什么大家一开始学 java 的时候,少使用 select * 的原因,查出一些无用字段,这些字段都是要消耗内存的,少给服务器增加压力了,而且 sql 执行效率也会变的慢。

import java.util.ArrayList;
import java.util.List;

public class OOMExample {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            byte[] bytes = new byte[1024 * 1024]; // 每次分配1MB的内存
            list.add(bytes);
        }
    }
}

知道了为什么使用 mybatis 查询大量数据会导致 oom 的原因后,那有没有什么办法可以做到用很小的内存就查出所有的数据吗?答案肯定是有的,不是有一个叫做 GC 的东西吗。垃圾回收呀。
每查定量的数据后,回收内存不就好了。

FetchSize 参数原理说明

我用的是 mysql 这里先简单介绍一下 FetchSize 参数的作用,当我们为 Statement 设置了 FetchSize 值为 10 后,假设下面这条 SQL 可以查出来 1000 条数据,那么 ResultSet 将需要进行 100 次网络传输从 MYSQL 中去取数据,每次取 10 条存储至 ResultSet 。当我们不设置 FetchSize 时,默认就是一次网络传输返回所有的数据。

  • 前者花费的时间 = 10 次网络传输 + MYSQL 执行语句的时间。
  • 后者花费的时间 = 1 次网络传输 + MYSQL 执行语句的时间。

但是后者一次性存储 1000 条数据,由于服务器内存过小可能会造成 OOM。这个 OOM 是 JDBC获取大量数据一次性存储至 ResultSet 产生的 OOM。Mybatis 也考虑到了这种情况就是可以让开发者自定义此次查询的 FetchSize 的值,以及提供了 ResultHandler 扩展接口,让开发者可以自定义处理结果集。接下来用代码给读者举例!

  Class.forName(driver);
  conn = DriverManager.getConnection(url, username, password);
  stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
  stmt.setFetchSize(Integer.MIN_VALUE);
  String sql = "select * from service_outsourcing_implement";
  stmt.setFetchSize(10);
  rs = stmt.executeQuery(sql);
  int count = 0;
  while (rs.next()) {
    count++;
  }
  System.out.println(count);

用 50 mb 内存查出 50 w数据方案实现

首先我们自定义一个实现 ResultHandler 接口用于接收 Mybatis 解析好结果集的每一条数据。

public class CusResultInfoHandler implements ResultHandler<ServiceOutsourcingImplement> {
  //存储每批数据的临时容器
  private List<Object> resultInfoList = new ArrayList<>();

  public List<Object> getResultInfoList() {
    return resultInfoList;
  }

  @Override
  public void handleResult(ResultContext<? extends ServiceOutsourcingImplement> resultContext) {
    ServiceOutsourcingImplement custTaskResultInfo = resultContext.getResultObject();
    resultInfoList.add(custTaskResultInfo);
    if (resultInfoList.size() == 10000) {
      //
      System.err.println("根据流逝查询的10000条数据做业务逻辑处理");
      //gc
      resultInfoList.clear();
    }
  }
}

第二步开启流逝查询支持(数据库 url 末尾拼接)

&useCursorFetch=true

第三步创建测试用例并设置最大运行内存 50 mb

-Xmx50m

测试用例如下,一次性查出 20 万条数据。我这里利用到的是 Mybatis 里面的 SimpleExecutor 执行的查询操作(比较原生一点),各位编写普通的 Service 查询代码也一样。


第四步设置 FeactSize 的大小,由于之前研究 Mybatis 源码的时候,把整个代码克隆到了本地,我就直接在源码里面修改了。依次设置 FetchSize 为 1,100,1000看看他的执行效率如何。

当设置 FetchSize 为 1时耗时 5844 ms


当设置 FetchSize 为 1000 时耗时 2134 ms,速度直接快了一倍

当设置 FetchSize 为 10000 时耗时 2325 ms,可见 FetchSize 的值并不是设置的越大越好。

最终结果就是使用 50 mb 内存在几秒时间内,成功的查出来几十万条数据,当然数据的流逝的查出来的,各位的业务代码最终肯定是要考虑将结果合并的。如果是做统计功能的话,将多批次得到的统计结果,最终合并就好了。如果是做数据迁移的话,改成流逝迁移就好了,这样内存将趋于平稳。不会一下子 OOM。

总结

本文从 Mybatis 解析结果集源码剖析了,造成 OOM 的原因。

  1. 当没有设置 FetchSize 的值的时候,数据库默认一次性将所有数据返回至 ResultSet,这时候还没有涉及到 Mybatis 的事就已经造成了 OOM,
  2. 设置了 FetchSize 的值,但是流逝处理数据的过程中,你并没有手动的去 GC 回收内存,一样也会 OOM

补充如果当你的 Sql 过长也就是写出了这种 Sql ,当 ids 过多时,Mybatis 会用容器存储所有的 ids ,然后依次遍历 ids 容器进行拼装 Sql ,这种时候也会造成 OOM。到此本文完结撒花!!!!!!!

select * from user where id in
 <foreach collection="ids" index="index" item="item" open="(" separator="," close=")">
        #{item}
    </foreach>

你可能感兴趣的:(mybatis,java,开发语言,OOM)