今天我们来说说工作中遇到的一个真实案例,由于使用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 list);
}
3. mybatis 生成的Mapper xml文件, 其中贴出了只是 批量插入 的方法
StudentMapper.xml
insert into student
(id, name, age)
values
(#{item.id,jdbcType=BIGINT}, #{item.name,jdbcType=VARCHAR}, #{item.age,jdbcType=INTEGER})
4. java service层批量插入数据
StudentServiceImpl.java
@Service
public class StudentServiceImpl implements StudentService {
// service 层代码,参数是 StudentEntity list 数据,待插入到数据库中的数据
@Override
public void batchCreateStudent(List 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 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 void splitBatchInsert(List list, Function insertFunc) {
Integer batchSize = 1000;
List 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框架来实现批量插入,分而治之的思想,是否也可以拿来解决该问题呢, 也值得我们探讨。