放弃使用POI导出Excel

项目中E端有一个订单导出的功能能(导出销售订单或者销售退单,导出列颇多,且必须满足实时数据)。我们使用POI导出数据,并且后端加了熔断措施,导出限流,大促期间导出开关控制。相对来说有了这些机制线上应用不会因为导出操作流量过大内存爆掉,也保证了应用安全稳定的运行,但是最近监控发现导出操作性能急剧下降(数据量已经超过3百万),先看看监控。

再来看看使用POI导出本地jvisualvm的内存动态变化图。

分析问题

  1. 导出请求耗时,排查SQL语句以及数据量。
  2. 应用内存居高不下(内存在新生代未被回收掉进入了老年代,老年代full GC的时候这些对象才能被销毁释放内存)。

这里我们主要分析第二个点,对于第一点大家都清楚如何解决问题。首先应该思考为什么使用POI导出的时候内存飙升的那么快呢?

结合Thread Dump可以看出,导出的时候内存增长过快,在数据量大和请求量过大的情况下,内存极速增长,然而这个过程中大量对象存活在年轻代,在年轻代无法被回收直接进入老年代。总体来说POI使用XMLBean处理Dom写Excel文件,内存占用过大,耗费资源;并且导出速度满,占用内存资源时间过长,导致一系列恶性循环。

如何解决

既然POI导出有这些不足之处,如何解决这样的问题呢?思路很简单,不再使用POI导出。降低服务端资源占用。后端服务可以只查询JSON数据,导出的工作交给客户端,这样完全屏蔽掉了使用POI导出的问题,可以想象,这样做就是一个简单的restful列表查询接口。

具体实现

思路

  • js使用JSON数据写Excel文件。可以使用SheetJS。使用还是比较简单的,前端看看demo就实现了。
  • 后端restful接口分批吐数据,如同分页查询,一次性吐1000条数据,后端所有数据吐完之后,前端处理合并数据再写Excel。

后端实现

  • 我们使用一个uuid来标志一次导出,这个uuid作为key存放在redis,并且设置过期时间,对应的value存放导查询次数,用户在点击导出按钮的时候,先使用一个接口申请这个uuid,服务端存储这个uuid。
    1
    2
    3
    4
    String uuid = UUID.randomUUID().toString();
    RedisAtomicLong counter = new RedisAtomicLong(ORDER_EXPORT_UUID + uuid, cacheRedisTemplate.getConnectionFactory());
    counter.set(0);
    counter.expire(60, TimeUnit.SECONDS);
    
  • 导出接口每次请求需要使用uuid作为参数,后端再去redis读取uuid对应value,这也是一个分页设计,这一次查询就只吐对应页的数据就可以了。接口返回的数据,每次返回额外给前端一个字段用来标示,是否还有下一页数据(判断当前查询的list是否小于1000,小于说明数据已经查询完毕)。
    1
    2
    3
    4
    public class ExportDTO<T> {
      private Boolean HasNext = true;
      private T data;
    }
    
  • 具体实例代码 ```` public boolean existKey(String uuid) { return cacheRedisTemplate.getExpire(ORDER_EXPORT_UUID + uuid) > 0; }

public void deleteUUID(String uuid) { cacheRedisTemplate.delete(ORDER_EXPORT_UUID + uuid); }

public long incrementAndGetUUIDValue(String uuid) { RedisAtomicLong counter = new RedisAtomicLong(ORDER_EXPORT_UUID + uuid, cacheRedisTemplate.getConnectionFactory()); return counter.incrementAndGet(); }

public List getSaleOrderData(String uuid, ExportOrderQueryDTO orderQuery, boolean showPhoneNumFlag) { if (!existKey(uuid)) { return null; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
long exportCount = incrementAndGetUUIDValue(uuid);
//导出次数限制(这里一次查询1000条,最多查询30次,导出最大值为3万)
if (exportCount > EXPORT_MAX_PAGE) {
    deleteUUID(uuid);
    return null;
}

//验证最大导出值
if (exportCount == 1) {
    int totalCount = exportMapper.countSaleOrders(orderQuery);
    Preconditions.checkArgument(totalCount < EXPORT_ONCE * EXPORT_MAX_PAGE, "最多导出%s条数据", EXPORT_ONCE * EXPORT_MAX_PAGE);
}

orderQuery.setOffset((exportCount - 1) * EXPORT_ONCE);
orderQuery.setLimit(EXPORT_ONCE);

List<Long> ids = exportMapper.listSaleOrderIds(orderQuery);

if (CollectionUtils.isEmpty(ids)) {
    deleteUUID(uuid);
    return null;
}


List<SalesOrderExportDTO> orderExportDTOS = exportMapper.listSaleOrders(ids); } ````

改造后的效果

  • 下图是使用js的导出效果图,可以看出和POI导出的差异有多大。
  • 生产环境服务器内存变化图

经过这么多天的线上应用内存观察,前端导出Excel的有点真的是毋庸置疑,减轻了后端服务的压力,后端服务性能飙升。