今天我们来说说工作中遇到的一个真实案例,由于使用mybatis的批量插入功能,导致系统内存溢出OOM(Out Of Memory), "java.lang.OutOfMemoryError: Java heap space"的报错,导致服务出现短暂的服务不可用,大概一两分钟不可用。这其实是个非常危险的故障,可能在高峰期导致整个系统瘫痪,服务不可用,产品不可用。

为了方便大家理解,我们就设想 这是一个学生表(student),我们要往这个表批量写入数据.


1. StudentEntity.java
Student对象实体

@Data
public class StudentEntity {
	private Long id ;
	private String name;
	private int age;
}


2. mybatis 生成的Mapper文件, 其中贴出了只是 批量插入 的方法
StudentMapper.java


public interface StudentMapper {

 /**
     * This method was generated by MyBatis Generator.
     * This method corresponds to the database table 'Student'
     *
     * @mbg.generated Mon Oct 24 16:35:18 CST 2022
     */
    int batchInsert(@Param("list") List<StudentEntity> list);	
}

    
3. mybatis 生成的Mapper xml文件, 其中贴出了只是 批量插入 的方法

StudentMapper.xml

	<insert id="batchInsert" keyColumn="id" keyProperty="id" parameterType="map" useGeneratedKeys="true">
    <!--
      WARNING - @mbg.generated
      This element is automatically generated by MyBatis Generator, do not modify.
      This element was generated on Mon Oct 24 16:35:18 CST 2022.
    -->
    insert into student 
		(id, name, age)
    values
    <foreach collection="list" item="item" separator=",">
      (#{item.id,jdbcType=BIGINT}, #{item.name,jdbcType=VARCHAR}, #{item.age,jdbcType=INTEGER})
    </foreach>
  </insert>
  

4.  java service层批量插入数据

StudentServiceImpl.java

    @Service
    public class StudentServiceImpl implements StudentService {

        // service 层代码,参数是 StudentEntity list 数据,待插入到数据库中的数据
        @Override
        public void batchCreateStudent(List<StudentEntity> studentEntities) {
            // other code  ...
            
            studentMapper.batchInsert(studentEntities);
            // other code  ...
            log.info("  batchCreate success!! studentEntities size:{}", studentEntities.size());
        }
    }

到这里就出现问题了,就是上面这句  studentMapper.batchInsert(studentEntities); 导致服务出现 OOM的故障, java.lang.OutOfMemoryError: Java heap space. 

我相信很多人看到这个问题,也知道问题的原因是什么,其实就是mybatis 插件自动生成的批量插入语句是“有问题”的,通过该方法一次性插入 20万个对象,可能就会导致内存溢出,OOM了。 但是这个问题显然也不能怪mybatis,数据量太大,导致 Mapper类调用batchInsert()方法的时候,mybatis组装sql语句的过程中,堆内存溢出。

~~  mybatis 表示委屈,背不起这个锅 ~

最终组装后的sql语句是这样的:

 insert into t_student

        (id ,name, age)
 values

        (1, '张无忌', 22),(2, '宋青书', 23) ... 
   ;

由于有20万个对象,在组装sql的过程中需要遍历这20万个对象,耗时特别长, 很容易导致api超时 timeOut,另外也会导致这个sql语句会特别的长。最终由于大数据量导致java堆内存空间溢出,超出了java heap space的空间大小。

· 其实这里的批量插入sql语句是有很多点可以拿出来说的,涉及到数据库批量插入空间配置,java堆空间的配置... 等等,今天我们就不深入说这个问题

 insert into t_student

        (id ,name, age)
 values

        (1, '张无忌', 22),(2, '宋青书', 23) ... 
   ;

快速解决办法:

既然是数据量太大,导致一次性批量插入内存溢出了,那很简单,我们就在应用里把数据拆分成小块数据,然后在分批的将小块数据插入到数据库中。就像 切分蛋糕,切分成一小块,一小块的。

 
  5.  java service层批量插入数据,改进后,通过切分成小块数据,然后批量插入到数据库 

// service 层代码,参数是 StudentEntity list 数据,待插入到数据库中的数据

public void batchCreateStudent(List<StudentEntity> studentEntities) {
        // other code ...
        // studentMapper.batchInsert(studentEntities);

        // 先拆分成小块数据,再批量插入数据库中
        this.splitBatchInsert(studentEntities, list -> {
            studentMapper.batchInsert(list);
            return true;
        });
        // other code ...
        log.info("  batchCreate success!! studentEntities size:{}", studentEntities.size());
}
	
   
		
// 大数据量split成小数据量,进行小批量插入数据库中
private <T> void splitBatchInsert(List<T> list, Function<List, Boolean> insertFunc) {
        Integer batchSize = 1000;
        List<T> batch = new ArrayList<>();
        for (int i = 0; i < list.size(); i++) {
            batch.add(list.get(i));
            if (batch.size() == batchSize || i == list.size() - 1) {
                insertFunc.apply(batch);
                batch.clear();
            }
        }
    }

到这里,Java 堆内存溢出的这个问题应该就可能解决。 但是还有很多细节值得去思考,考虑。下面简单提下, 这篇文章就不展开说了:

假如改成上面拆分后插入还是内存溢出OOM的错误:

a. 一方面可以将上面的 阈值 1000 再改小,比如改成500 ;

b. 另外一方面 也可以将你的服务启动时,堆内存空间调大, 比如 改成 1500M, -server -Xmx1536m 。

ENV JAVA_OPTS="-server -Xmx1536m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/jvm-logs/${APP_NAME}_jvm_dump.log "

最后将一些细节,可以在思考如何去不断的优化这个批量插入 的过程。又快又好又节省空间的方式

1) 上面写的阈值1000 ,需要根据自己的业务场景来设置,比如你插入对象简单,对象属性不读,也不复杂,单个对象的大小也不超过1kb,那你这里的阈值可以设置大些,比如2000, 甚至 可以是 10000;

2) mybatis生成的批量插入方法,可以继续优化,改用其他的批量插入方式,batchInsert();

3) Java内存空间的调整,到底要调整多大合适,如果真的需要调整,可能还需要谨慎操作;

4) 拆分成小块数据后批量插入,这个过程可以利用Java8 中的 parallelStream(), parallel()  也就是 Fork/Join框架来实现批量插入,分而治之的思想,是否也可以拿来解决该问题呢, 也值得我们探讨。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐