EasyExcel导出的文件为空/损坏

5,501 阅读5分钟

一、问题描述

同一个页面对系统数据进行多次导出时,经常导出空的excel文件(文件名正确)。最后,对导出功能进行测试后,发现每次刷新页面后,第一次总能导出正确的excel文件,后面的导出随机出现空excel文件,并且当数据量大时发生更为频繁。

二、分析过程

1.查看系统日志,导出时并未发生异常日志,review导出代码也未发现吃掉异常的逻辑,排除由于异常引起。 2.在查看日志的时候发现,导出的log日志还未打印时,导出文件已经输出(虽然日志异步打印,但是延迟不至于这么高吧),所以怀疑文件输出流(OutputStream)在什么地方被提前关闭(调用了close方法)了,大概有了方向。 3.本地启动服务对问题进行复现及调试,并在文件输出流位置进行断点调试。使用Postman模拟前端接口调用。第一次导出时,OutputStream是被正常调用(ExcelWriter#finish方调用)并关闭,导出数据也是正确的。 调用链如下,ExcelUtil为项目封装的导出工具类:

image.png

4.进行第二次导出时,等待一段时间后,断点情况如下:

image.png

5.发现ExcelWriter#finish方被调用了,根据调用链看出是被finalize方法调用(此方法是在Object中被定义,在垃圾回收阶段,在对对象进行可达性分析中,标记对象是否可以被回收时被调用),那就说明此时发生了垃圾回收GC。放过此断点及后续断点继续执行,此时发现文件已经被输出,并且为空文件。但是代码逻辑并未执行完毕,说明流被提前关闭。如下为此次请求中最后一次调用ExcelWriter#finish的断点情况,从调用链可以看出最后一次关闭输出流的调用链和第一次导出请求的调用链一致。我们期望的是此次关闭流后,文件才输出。

image.png

6.从上面三张截图可以看出。关闭输出流(OutputStream)在Easyexcel框架中的起始位置为ExcelWriter#finish。最终逻辑位置为WriterContextImpl#finish方法,方法逻辑如下:

image.png

7.从步骤6的截图可以看出,WriterContextImpl#finish方法的代码逻辑只会执行一次,也就是其中的关闭输出流的操作也只会执行一次。那就说明,我们一次请求,实例化了多个ExcelWriter实例,但是我们在请求结束时,未全部调用这些ExcelWriter实例对象的finish方法。导致垃圾回收时,最终执行了关闭输出流的操作。最后发现项目封装的导出工具类ExcelUtil中,创建了两个ExcelWriter实例,但是请求结束时,只调用了其中一个实例的finish方法。 8.验证:通过打印多个请求的输出流对象,发现在不刷新页面的情况下,多个请求的输出流确实为同一对象(HTTP1.1协议有对TCP连接复用的机制,则输出流对象也为同一个)。并且证实,在文件提前输出的请求中输出流与前一次请求的输出流为同一对象。多次导出,发现不发生垃圾回收或不复用同一连接时,导出正常。至此,问题已经定位。 9.最后发现,封装ExcelUtil的同学在获取WriterSheet时,使用了如下方式:

image.png

调试代码可以发现,在ExcelWriterBuilder#sheet方法中会创建一个ExcelWriter实例,ExcelWriterBuilder#doWrite方法会调用ExcelWriter#finish方法。但是后面的代码并未调用ExcelWriterBuilder#doWrite方法,导致没有执行这里创建的ExcelWriter实例的finish方法。

10.最后通过如下方式获取WriterSheet,解决该问题。

image.png

可以发现EasyExcel#writerSheet中并未创建ExcelWriter实例。

三、问题前因后果总结

ExcelWriter重写了Object#finalize方法并在其中调用了finish方法,finish方法会关闭输出流(一个ExcelWriter实例只会执行该操作一次)。当同一页面多次请求时,由于使用了HTTP1.1协议,多次请求使用了同一个连接,则输出流也为同一个。当在导出数据的过程中,由于JVM内存不足,导致垃圾回收,可达性分析第二阶段调用了可达性分析第一阶段被标记为可回收对象的finalize方法。由于前一次请求创建了两个ExcelWriter实例,在前一次请求中只执行了一个ExcelWriter实例的finish方法。由于本次请求发生了垃圾回收,调用了前一次请求生成的ExcelWriter对象的finish方法,最后调用了OutputStream#close方法,导致意外关闭了本次请求的输出流。最终导致输出了空文件/损坏的文件。 四、解决方案 1.前端每次调用时,新建连接,不复用上一次的连接(禁止,增加服务器压力和请求时间) 2.创建了多少个ExcelWriter实例对象,在try-finally中将其全部关闭。(推荐) 3.一次excel写入使用同一个ExcelWriter实例对象,并且在try-finally中关闭。(推荐,本次bug修复就是使用这种方式)