最近有个学弟找到我,跟我描述了如下场景:java
他们公司内部管理系统上有不少报表,报表数据都有分页显示,浏览的时候速度还能够。可是每一个报表在导出时间窗口稍微大一点的数据时,就异常缓慢,有时候多人一块儿导出时还会出现堆溢出。mysql
他知道是由于数据所有加载到jvm内存致使的堆溢出。因此只能对时间窗口作了限制。以免因导出过数据过大而引发的堆溢出。最终拍脑壳定下个限制为:导出的数据时间窗口不能超过1个月。web
虽然问题解决了,可是运营小姐姐不开心了,跑过来和学弟说,我要导出一年的数据,难道要我导出12次再手工合并起来吗。学弟心想,这也是。系统是为人服务的,不能为了解决问题而改变其本质。sql
因此他想问个人问题是:有没有什么办法能够从根本上解决这个问题。数据库
所谓从根本上解决这个问题,他提出要达成2个条件服务器
我听完他的问题后,我想,他的这个问题估计不少其余童鞋在作web页导出数据的时候也确定碰到过。不少人为了保持系统的稳定性,通常在导出数据时都对导出条数或者时间窗口做了限制。但需求方确定更但愿一次性导出任意条件的数据集。多线程
鱼和熊掌可否兼得?异步
答案是能够的。jvm
我坚决的和学弟说,大概7年前我作过一个下载中心的方案,20w数据的导出大概4秒吧。。。支持多人同时在线导出。。。ide
学弟听完表情有些兴奋,可是眉头又一皱,说,能有这么快,20w数据4秒?
为了给他作例子,我翻出了7年前的代码。。。花了一个晚上把核心代码抽出来,剥离干净,作成了一个下载中心的例子
先不谈技术,先看效果,(完整案例代码文末提供)
数据库为mysql(理论上此套方案支持任何结构化数据库),准备一张测试表t_person
。表结构以下:
CREATE TABLE `t_person` ( `id` bigint(20) NOT NULL auto_increment, `name` varchar(20) default NULL, `age` int(11) default NULL, `address` varchar(50) default NULL, `mobile` varchar(20) default NULL, `email` varchar(50) default NULL, `company` varchar(50) default NULL, `title` varchar(50) default NULL, `create_time` datetime default NULL, PRIMARY KEY (`id`) );
一共9个字段。咱们先建立测试数据。
案例代码提供了一个简单的页面,点如下按钮一次性能够建立5w条测试数据:
这里我连续点了4下,很快就生成了20w条数据,这里为了展现下数据的大体样子,我直接跳转到了最后一页
而后点开下载大容量文件
,点击执行执行按钮,开始下载t_person
这张表里的所有数据
点击执行按钮以后,点下方刷新按钮,能够看到一条异步下载记录,状态是P
,表示pending
状态,不停刷新刷新按钮,大概几秒后,这一条记录就变成S
状态了,表示Success
而后你就能够下载到本地,文件大小大概31M左右
看到这里,不少童鞋要疑惑了,这下载下来是csv?csv实际上是文本文件,用excel打开会丢失格式和精度。这解决不了问题啊,咱们要excel格式啊!!
其实稍微会一点excel技巧的童鞋,能够利用excel导入数据这个功能,数据->导入数据,根据提示一步步,当中只要选择逗号分隔就能够了,关键列能够定义格式,10秒就能完成数据的导入
你只要告诉运营小姐姐,根据这个步骤来完成excel的导入就能够了。并且下载过的文件,还能够反复下。
是否是从本质上解决了下载大容量数据集的问题?
学弟听到这里,很兴奋的说,这套方案能解决我这里的痛点。快和我说说原理。
其实这套方案核心很简单,只源于一个知识点,活用JdbcTemplate
的这个接口:
@Override public void query(String sql, @Nullable Object[] args, RowCallbackHandler rch) throws DataAccessException { query(sql, newArgPreparedStatementSetter(args), rch); }
sql就是select * from t_person
,RowCallbackHandler
这个回调接口是指每一条数据遍历后要执行的回调函数。如今贴出我本身的RowCallbackHandler
的实现
private class CsvRowCallbackHandler implements RowCallbackHandler{ private PrintWriter pw; public CsvRowCallbackHandler(PrintWriter pw){ this.pw = pw; } public void processRow(ResultSet rs) throws SQLException { if (rs.isFirst()){ rs.setFetchSize(500); for (int i = 0; i < rs.getMetaData().getColumnCount(); i++){ if (i == rs.getMetaData().getColumnCount() - 1){ this.writeToFile(pw, rs.getMetaData().getColumnName(i+1), true); }else{ this.writeToFile(pw, rs.getMetaData().getColumnName(i+1), false); } } }else{ for (int i = 0; i < rs.getMetaData().getColumnCount(); i++){ if (i == rs.getMetaData().getColumnCount() - 1){ this.writeToFile(pw, rs.getObject(i+1), true); }else{ this.writeToFile(pw, rs.getObject(i+1), false); } } } pw.println(); } private void writeToFile(PrintWriter pw, Object valueObj, boolean isLineEnd){ ... } }
这个CsvRowCallbackHandler
作的事就是每次从数据库取出500条,而后写入服务器上的本地文件中,这样,不管你这条sql查出来是20w条仍是100w条,内存理论上只占用500条数据的存储空间。等文件写完了,咱们要作的,只是从服务器把这个生成好的文件download到本地就能够了。
由于内存中不断刷新的只有500条数据的容量,因此,即使多线程下载的环境下。内存也不会所以而溢出。这样,完美解决了多人下载的场景。
固然,太多并行下载虽然不会对内存形成溢出,可是会大量占用IO资源。为此,咱们仍是要控制下多线程并行的数量,能够用线程池来提交做业
ExecutorService threadPool = Executors.newFixedThreadPool(5); threadPool.submit(new Thread(){ @Override public void run() { 下载大数据集代码 } }
最后测试了下50w这样子的person数据的下载,大概耗时9秒,100w的person数据,耗时19秒。这样子的下载效率,应该能够知足大部分公司的报表导出需求吧。
学弟拿到个人示例代码后,通过一个礼拜的修改后,上线了页面导出的新版本,全部的报表提交异步做业,你们统一到下载中心去进行查看和下载文件。完美的解决了以前的2个痛点。
但最后学弟还有个疑问,为何不能够直接生成excel呢。也就是说在在RowCallbackHandler
中持续往excel里写入数据呢?
个人回答是:
1.文本文件流写入比较快
2.excel文件格式好像不支持流持续写入,反正我是没有试成功过。
我把剥离出来的案例整理了下,无偿提供给你们,但愿帮助到碰到相似场景的童鞋们。
关注公众号「元人部落」回复”导出案例“得到以上完整的案例代码,直接能够运行起来,页面上输入http://127.0.0.1:8080就能够打开文中案例的模拟页面。
一个只作原创的技术科技分享号