一. 问题概述
老师的一个学生入职了杭州中通全球创研中心,最近他给老师分享一个他们公司解决OOM问题的案例,老师觉得十分有趣,特意把这个案例记录下来,日后我会做成教学案例分享给学生。这个问题发生的背景如下:
【在物流领域,针对各个下级网点而言,每月1日~9日是进行财务月结的重要时间节点。在这个关键节点上,各个网点需要使用导出功能输出寄派件、费用客户信息等多种信息进行汇总结算】。也就是说,在月初的时候,每个网点都要统计一个月的各种流水(寄件,收件等),最后再以excel表格的形式下载给客户。
那么在这个业务中为什么很容易发生OOM异常呢?这是因为平均1个网点1个月的流水数据大约在30w行左右,根据计算得出大约500行数据就会占用1M内存,而1个站点把30万行一股脑地读到内存中,就会占用600M内存。试想一下,如果全国的网点都在月初集中下载报表的话,JVM是很容出现内存溢出的问题的!
二. 解决方案
那么这样一个棘手的问题,如果我们只用一个单一的解决方案是不够的,老师根据学生的描述,建议该学生主要采取以下几种解决方案。
2.1 用硬盘空间置换内存空间
如果我们在接到统计数据请求的时候,一次性把30w条数据从数据库读取到一个List集合中,这显然是不合理的,因为这样一个List集合就会占用600M内存。所以我们可以进行分页查询,每次查询1000条数据,然后往硬盘里写,多读取几次,一点一点的把所有的数据都读出来,再一点一点的往硬盘中写。这样在这个过程中,占用的内存就会少很多,主要变成了对硬盘空间的占用。而我们操作excel的技术,可以选择阿里巴巴的easyexcel。
2.2 使用Mybatis的流式查询
我们可以使用Mybatis的【流式查询】查询技术,在查询成功后返回的是一个迭代器而不是一个集合,应用每次都从迭代器中获取一条查询结果,能够降低内存的使用。试想一下,如果我们不使用流式查询,而想要一次性从数据库中读取30万条数据,内存是根本不够用的!这时我们只能选择分页查询,而分页查询的性能又取决于表设计以及索引的设计,大量数据分页查询的性能是很低的。老师对比了使用流式查询和分页查询的两种方案,得到的结论是取30万条数据时,流式查询的速度大约是分页查询的4~5倍左右。
2.3 使用redission信号量限流
生成一个月的流水报表是一个非常耗时的操作,用户也不可能马上就要结果,所以我这个学生的公司对同时生成报表的请求数量做了限制,同时只能处理10个报表的生成。在这期间如果再有生成报表的请求,我们将会让这些请求排队,等到前面的报表生成完毕后,再处理后面的请求。报表生成成功后,再通知客户主动去下载,老师建议这里使用redisson分布式锁的信号量来限制同时创建报表的线程数量。
2.4 MQ解耦+微服务拆分
本次业务中,读数据库,编写excel文件,上传到文件服务器这三个操作都非常耗时,学生的公司使用了MQ解耦,并把这次请求拆分成3个微服务,这样读、写、上传就不会相互影响了。
三. 流式查询
在这篇文章中,老师只给大家分享一下Mybatis流式查询的实现方法,其他的解决方案以后会在其他的文章中给大家呈现。
3.1 概念
流式查询就是查询成功后返回的是一个迭代器而不是一个集合,应用每次都从迭代器中获取一条查询结果,这样能够降低内存的使用。
3.2 Mybatis实现流式查询
接下来就是实现流失查询的具体过程。
在mapper映射文件中,编写流式查询的逻辑。
<!--
1: fetchSize: 官方文档建议设置成Integer.MIN_VALUE
2: resultSetType="FORWARD_ONLY" 返回一个只向前的游标
3:注意我把表一次性查出,并没有使用分页逻辑,依靠流式查询一行一行得到结果
-->
<select id="selectFetchSize" fetchSize="-2147483648" resultSetType="FORWARD_ONLY" resultType="com.qf.shop.cms.entity.TContent">
select * from t_content
</select>
在mapper接口文件中添加selectFetchSize方法。
// 参数 ResultHandler 是一个回调接口,也就是从游标中获得一条数据就会回调接口中的方法
void selectFetchSize(ResultHandlerhandler);
自己编写一个类实现ResultHandler接口,在该接口中定义从游标获得一条数据后的回调逻辑。
/**
* 通过流式查询每获得一条数据的回调类
*/
public class TContentResultHandler implements ResultHandler{
/**
* 这里每集满1000条数据 往硬盘的excel文件中追加一次数据
*/
private final static int BATCH_SIZE = 1000;
/**
* 计数器
*/
private int size=0;
/**
* 存储每批数据的临时容器
*/
private ListtContents = new ArrayList<>();
/**
* 每从流式查询中获得一行结果,就会调用一次这个方法
* @param resultContext
*/
@Override
public void handleResult(ResultContext resultContext){
// 这里获取流式查询每次返回的单条结果
TContent resultObject = resultContext.getResultObject();
// 你可以看自己的项目需要分批进行处理或者单个处理,这里以分批处理为例
tContents.add(resultObject);
size++;
if (size == BATCH_SIZE) {
// 如果集满1000条就往文件中写一次
handle();
}
}
/**
* 集满1000条 执行一次的逻辑
*/
private void handle() {
try {
// 在这里可以对你获取到的批量结果数据进行需要的业务处理
// 这里的业务是 往文件中写一次
} finally {
// 处理完每批数据后后将临时清空
size = 0;
tContents.clear();
}
}
/**
* 这个方法给外面调用,用来完成最后一批数据处理
*/
public void end(){
handle();// 处理最后一批不到BATCH_SIZE的数据
}
}
在业务逻辑(service)层调用流式查询方法。
@Autowired
private TContentMapper contentMapper;
public void streamQuery(){
// 生成流式查询的回调对象
TContentResultHandler tContentResultHandler = new TContentResultHandler();
// 调用流式查询
contentMapper.selectFetchSize(tContentResultHandler);
// 执行完最后一批数据的逻辑
tContentResultHandler.end();
}
四. 后话
老师前面已经说到,为了解决本次产生的OOM问题,老师给大家列举了非常多的解决方案,但本篇文章介绍的流式查询只是其中的方案之一。至于其他的解决方案,老师将在后续的文章中为大家一一揭晓,敬请各位继续关注本公众号,如有问题,可以在评论区给我们留言哦。