以前我们项目汇总的导出:直接查询数据库,生成Excel然后返回给前端。
简单的做以下优化项(实际上都没使用):
1:如果共用界面查询的分页接口,可以去掉分页的总条数查询,pageSize传大点
2:Spring MVC 的 Controller 返回 Callable,实现异步请求,防止长时间的文件导出任务一直占用tomcat线程
3:如果没有共用分页查询,可以使用mysql的流式查询,在回调接口中一条条的返回数据,可以用一条丢一条,及时释放内存减小内存压力
4:前端自己做分页请求,生成文件,这样后端改动最小(项目中真遇到过这样的界面,性能不太好)
改造之前,我们项目中用的最多的就是第一种方式,带来的问题就是,数据量过大,内存飙升,也发生过几次oom,用户体验方面,导出任务比较慢的情况,用户不能离开当前界面需要保持连接,还很可能超时。至于慢sql这种问题,需要开发者自己优化了
现在我们的微服务项目改成了统一的导出模式可以避免上面说的那些问题。整体流程如下图:
整体概述就是所有的请求全部请求同一个微服务的Controller,然后用户立即收到请求成功信息,并在统一的任务界面查看导出进度。
再解析url找到对应的微服务应用,由Ribbon负责负载均衡分页请求,然后处理返回数据,解析后的数据写入临时文件(我这里使用的是 EasyExcel,api简单一点)。最后上传到 OSS文件服务器,更新下载url到数据库,展示给用户,并且重复下载直接从文件服务器下载。
图中标记1处:
1:可以用线程池,也可以用MQ,此处可以统计当前进行中的任务数量,比如限制50个任务同时导出,超出最大任务量直接提示 “系统繁忙,请稍后尝试”
2:这里我还加了个规则。根据返回的总条数计算,如果发现超过了5页,就同时启动两个线程并发查询。
3:异步操作,别忘了事务问题(数据库的事务还没提交,线程池中的任务已经开始执行,查不到数据,然后报错)
图中标记2处:
1: 前端传入查询数据的链接和参数(导出列表数据的情况下,url和参数一般和界面的列表查询接口相同,这样可以减少一个专门的导出接口,在返回对象中添加对应的Excel注解用于生成表头和数据转换),构建请求信息去调用其他服务的查询接口,一定要带上用户信息,因为大部分系统都有数据权限,防止用户看到无权查看的内容。
构建分页查询参数时,根据自己的内存使用情况,我这里是默认每次查询400条,最多50000条数据,当然这些参数也可以由前端提供
2:尽量全部查询接口用分页查询,当时我做这个的时候为了兼容老接口,支持不分页(囧),然后有个接口一次返回了几万条,线上观察到内存突增(大量的Excel cell对象和json解析相关的对象 char[] 等。具体不记得了,,,,)
3:查询分页接口,如果要做进度展示,可以优化一下分页查询的count()查询。sql 的优化不多说了,我们java代码层面可以控制只请求一次count就行(如果不需要查看进度,完全去掉count查询也挺好),可以自定义mybatisplus的分页拦截器处理,测试了发现PageHelper 和 mybatisplus自带的IPage<T>都能正常拦截
//先创建拦截器
public class TestPaginationInnerInterceptor extends PaginationInnerInterceptor {
public TestPaginationInnerInterceptor(DbType dbType) {
super(dbType);
}
@Override
public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
// 这里根据请求方发送的默认参数判断,如果是第一次请求就分页
//SysExcelUtils.EXPORT_SEARCH_COUNT 是一个ThreadLocal 不详细解释了
if (Boolean.FALSE.equals(SysExcelUtils.EXPORT_SEARCH_COUNT.get())) {
return true;
}
return super.willDoQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
}
}
//然后在配置类中注册
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TestPaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
图中标记3处:
我们是在线程中分页循环请求数据,返回的数据需要进行转换,这里的转换我只写了三种,
数据字典注解 比如@DicMap("test")(1:线上配置的数据字典,最好缓存起来 2:枚举 3:自定义key,value)
具体实现:略
数字格式化注解 解析 com.alibaba.excel.annotation.format.NumberFormat
直接找到EasyExcel的源码抄过来就行
GlobalConfiguration config = new GlobalConfiguration();
NumberDataFormatterUtils.format(new BigDecimal(String.valueOf(o)), (short) 0, headDTO.getNumberFormat(), config );
日期格式化注解 解析 com.alibaba.excel.annotation.format.DateTimeFormat
把以上对应的数据解析到新的结果集,再写入临时文件
我们可以做个小优化,数据转换后,原来的几百行数据就没用了,这时手动把引用设置为null,原因如下图中JDK源码注释:
如果写入临时文件后,没有文件服务器上传怎么办?那就老老实实的用文章开头的哪几种办法吧,再加个文件压缩什么的,或者直接配置springMVC 的静态目录,然后文件生成到静态目录。
图中标记4处:
用户查看进度,这里有条件的花,使用webSocket挺好,我们项目商量了下,没必要为导出专门加上websocket,所以用的就是最原始的定时任务请求,
具体实现:进入首页会请求一次,发现没有进行中的任务就不再请求。返回数据时顺便检测进行中的任务是否长时间没有更新,有时服务发版或者崩溃,会导致进行中的任务中断,所以这里判断长时间没有更新状态,就把这个任务设置为“失败”,防止前端一直请求
除了前面两种方案,我们也可以借鉴其他框架的长轮询方案,大概为:后端Controller 为异步请求,前端发起请求后,后端不会立即返回;因为是异步请求,所以tomcat线程也不会一直被占用,10秒内,导出任务有状态变化就返回给前端,没有变化就返回无变化即可,前端接着开启下次长轮询
界面上可以设置失败重试和重新下载按钮。失败重试:根据数据库中保存的url,请求参数还原请求,直接开始导出任务。 重新下载则直接把OSS上的url地址给前端,直接下载
最后:
现有代码改造,需要前后端一同修改,前端把调用接口全部统一格式发送到固定接口,然后用户等待异步返回结果就行。后端改造需要把原来的同步生成Excel文件接口改成统一的JSON返回格式用于数据解析,并且把批量查询改成分页查询。
查询接口返回的统一结果对象 Result(可能是其他名字,这里只是举例)对象中,添加一个方法 public Result<T> addExcelHeaderClass(Class<?> clazz), clazz参数就表示需要解析的excel头对象,判断只有导出任务才需要解析,只需要解析一次,然后缓存起来。
动态列表的导出可能需要单独把header头信息传过去,具体实现就不写了。
最后效果是后端的分页查询接口可以直接作为导出接口使用。
我们当前的项目有70多个导出接口,,,,写完代码,加每个导出接口挨个修改,大概8个左右工作日就没了