流实现低内存下读取大量数据和处理并存储大文件

眉间皱痕 提交于 2020-03-01 05:45:06

昨天看到有朋友A在问,“我的程序一旦导出稍微大点的Excel一定会OOM,你们的应用导出多少数据量都没有问题,如何做到的?”。这里涉及到两个问题:读取和写入的内存占用。其实业务很简单:从数据库中读取数据,然后生成Excel。那么我们以解决A的业务问题入手,从而来解决这一类的问题。

        读取:A使用JDBC从数据库中读取数据,当返回结果较多且内存不足以容纳结果时可能导致OOM,这时候只需要增加虚拟机的heap内存即可解决当下问题,但这种方法并不能解决根本问题。在java.sql.Statement中有两个方法:

//设置该Statement生成的所有java.sql.ResultSet可容纳结果的最大行数. 
void setMaxRows(int max) throws SQLException;      

//设置Statement生成的所有java.sql.ResultSet在获取更多行时应从数据库获取的行数
void setFetchSize(int rows) throws SQLException;

可以使用setMaxRows限定结果集数量来控制数据量也是一个不错的办法,但是需要业务上妥协不能使用完整的数据,不太合适。第二个方法,setFetchSize按照一次获取rows条记录放在客户端,客户端处理完成后再去取rows条记录到客户端来,直至数据取完,如果只是为了取数据那么这种方式好像没什么卵用,因为最终数据还是要全取回来的。分析A的业务,他想要的效果就是读取所有数据然后导出。如果以数据一批取回来一批处理掉这样类似于“流”的方式处理这个业务,只把Java程序当做整个过程中的一个可加工数据管道就完美了。想必每个使用Java的同学在学习I/O类库时都知道“流”这个概念,它比较抽象,代表一些能够产出数据的数据源和能够接收数据的接收端对象,在A的业务中,我们需要源和目标同时支持流的方式才能让Java程序成为管道而不是数据中转站。通过数据库的setFetchSize设置每批数据的大小,从而宏观上来看就是成为流的方式,读取没有问题了。理清方案,接下来处理写入。

        写入:目标文件为Excel,目前Java读写Excel较为稳定且一直在更新的类库只有POI http://poi.apache.org/了,我们以POI为依赖去找相关API。在较早的版本中似乎OOM的问题一直存在,仔细寻找发现多了一个Api叫SXSSF (Since POI 3.8 beta3 https://poi.apache.org/spreadsheet/how-to.html#SXSSF+%28Streaming+Usermodel+API%29),开始提供了一组streaming XSSF的Api,其实就是流方式操作Excel。它在初始化时可以设置rowAccessWindowSize:

public SXSSFWorkbook(int rowAccessWindowSize)

这个值和Jdbc中的setFetchSize类似,当数据量达到rowAccessWindowSize时,将Java程序中的数据flush到磁盘中,当rowAccessWindowSize足够小时,宏观上就呈现出流的方式了。

        以上面的方案为依据,做下数据对比 :

实验环境:-Xms256m -Xmx512m -XX:PermSize=128m
数据:select * from `big_table`  //825473行 约100m

实验一:未设置setFetchSize,未使用SXSSF。

现象:运行约3分钟,导出文件失败,读取数据时内存直线飙升,生成XSSF后放缓,最终该线程抛出OOM后挂起,系统强制GC,内存恢复正常水位。

结论:由于没有设置setFetchSize,数据全部拿到客户端,而客户端又要对数据生成XSSF对象,内存中将有原数据至少两倍大小的对象,很快就会出现异常。生产和消费胃口都比较大,最终仓库挂了。

实验二:设置setFetchSize,未使用SXSSF。

现象:运行约4分钟,导出文件失败,读取数据时内存缓慢上升,频繁gc,内存满了以后直至ResultSet无法获取下一批数据后县城hang住,最终OOM。

结论:设置了setFetchSize,内存中的数据全为XSSF对象,由于数据是按照批次来获取,当前批次未结束时内存不会增加,所以整个过程较为缓慢。生产者依赖消费者,消费慢生产就慢最终瘫痪。

实验三:未设置setFetchSize,使用SXSSF。

现象:运行约4分钟,导出文件成功,读取数据时内存上升较快,达到内存峰值时频繁gc,最终文件导出成功,内存释放恢复正常。

结论:未设置setFetchSize,数据被一次性全部捞取,在读取过程中生成SXSSF,由于SXSSF会去生成临时文件,频繁appendRow、flush导致gc频繁,持久战成功。生产者大批数据,消费者不依赖仓库中转,高频运输最终完成工作。如果jvm内存较小时同样会OOM。

实验四:设置setFetchSize,使用SXSSF。这是我们计划的方案。

现象:运行约1分钟,导出文件成功,读取数据时内存有内存起伏,整体稳定,gc相对较少,。

结论:这种方式中Java程序充当了管道的作用,几乎没有内存消耗,且执行速度快。

实验结论:在整个导出文件的逻辑中,任何一个动作都可能成为瓶颈,整条链路都为流模式时,程序只需要充当管道角色即可。Java I/O类库提供了丰富的输入和输出接口,熟知它们的应用场景和使用方法很有必要,在遇到此类问题时多加注意即可避免系统不可用的风险。

 

附录信息:

setFetchSize:不同的数据库类型JDBC的实现大有不同,下面梳理了以下几种数据库类型如何使用流模式:

MySQL:从mysql-connector-java中可以看到StatementImpl包装了这份配置。http://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-implementation-notes.html

/* (non-Javadoc)
 * @see com.mysql.jdbc.IStatement#enableStreamingResults()
 */
public void enableStreamingResults() throws SQLException {
   synchronized (checkClosed().getConnectionMutex()) {
      this.originalResultSetType = this.resultSetType;
      this.originalFetchSize = this.fetchSize;

      setFetchSize(Integer.MIN_VALUE);
      setResultSetType(ResultSet.TYPE_FORWARD_ONLY);
   }
}

Oracle:默认从服务器一次取出fetchSize的数据,从代码中可看到默认为3. 但当遇到大数据量时还需设置ResultSetType为TYPE_FORWARD_ONLY https://docs.oracle.com/cd/B10501_01/java.920/a96654/resltset.htm

PostgreSQL:必须设置autocommit=false,然后设置fetchSize和ResultSetType。 https://jdbc.postgresql.org/documentation/94/query.html#query-with-cursor

SQLServer:需要设置SQLServer驱动下的一个参数setResponseBuffering,然后设置ResultSetType即可。https://msdn.microsoft.com/zh-cn/library/bb879937(v=sql.110).aspx

 

 

 

 

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!