事情的起点:导出接口把堆打满了
这次改造没有什么技术规划做铺垫,起点就是一个生产问题。
系统里有一批 Excel 导出接口,客户列表、拜访记录这些,全部是用 POI 的 XSSF 写的。平时单发请求没什么动静,坏就坏在导出请求一集中------接口开始超时,网关那边一片红。
排查的路子不新鲜:先看 GC 日志,发现超时的时间点和一条接一条的 Full GC 正好对上;再看堆里装的是什么,导出请求攒出来的大对象赫然在列。因果链很清楚:高并发导出 → 老年代被填满 → 频发 Full GC → 接口超时。
当时摆在面前的第一个诱惑是"加内存"。堆调大确实能让 Full GC 来得晚一点,但导出的内存需求是随行数和并发数线性涨的,堆总有上限,这不是解决,是推迟。所以还是得回到问题本身:POI 的导出,为什么这么吃内存?
为什么 POI 会把堆打满
把 XSSF 的写入模型摊开看,答案就出来了。
XSSFWorkbook 是 DOM 模型------整个工作簿在内存里是一张完整的对象图:一行数据对应一个 XSSFRow,一个单元格对应一个 XSSFCell,外加样式对象、共享字符串表,层层引用。每个 Cell 对象自身都有开销(对象头、字段、样式引用),而 XSSF 底层还挂着一套 XML DOM 结构,等于同一份数据在堆里存了差不多两份。
更要命的是这份对象图是"一次性交付"的:从第一个单元格写到写完最后一个单元格之前,它整体是活的,一行都释放不掉。行数涨,内存线性涨。
高并发场景把这个问题做成了乘法:N 个导出请求同时进来,就是 N 份完整的对象图在堆里同时构建。老年代很快被填满,Full GC 跟着来;更难受的是 GC 完了也回收不了多少------导出还在进行,那些对象图还活着,于是 Full GC 连着 Full GC,接口就超时了。
第一刀:换成 EasyExcel 的流式写入
知道原因之后找方案,方向就明确:别在内存里攒完整的对象图。
调研时先看到 POI 自家其实有答案------SXSSF,流式写入,内存里只留一个滑动窗口,窗口之外的行直接刷到磁盘临时文件。姿势是对的,但 API 还是 createRow/createCell 那一套,表头、类型转换、样式全要手工拼,用起来和 XSSF 一样费劲。
EasyExcel 做的事,是把"流式"变成默认姿势,外面再包一层 DTO 映射。业务侧只给一个 List<DTO> 和一个 DTO 类,剩下的它来:
java
// DTO 上的注解定义列名和格式
@Data
public class CustomerExportDTO {
@ExcelProperty("客户名称")
@ColumnWidth(20)
private String name;
@ExcelProperty("邮箱")
private String email;
@ExcelProperty("最近拜访时间")
@DateTimeFormat("yyyy-MM-dd HH:mm")
private LocalDateTime lastVisitTime;
@ExcelIgnore // 内部字段,不导出
private String internalRemark;
}
// 写出就一行
EasyExcel.write(response.getOutputStream(), CustomerExportDTO.class)
.sheet("客户")
.doWrite(dataList);
对比老代码:new XSSFWorkbook() → 循环里 createRow → createCell → setCellValue → 最后 workbook.write(out) 加 close()。同样是导出,一个在堆里攒完整对象图,一个逐行转换、写完就扔。
这里有个我一开始理解错、后来才掰过来的点:EasyExcel 的写入底层用的还是 POI------走的就是 SXSSF 那条流式通道 。所以这次换库的本质不是"POI 有毒,EasyExcel 有解药",而是把 SXSSF 这个正确的姿势从"需要开发者自觉选用"变成了"默认行为",顺手把 DTO 映射也做了。EasyExcel 有个 inMemory 开关,打开会退回 XSSF 全内存模式(为了支持某些复杂特性),默认不开------别手贱去开它。
改完之后看数字:单次请求的堆内存降了 85%。
这个 85% 是从哪来的?主要是 POI 对象图整个消失了,流式写入的内存占用只剩一个滑动窗口加上正在转换的当前批次。但要说透还有一件事:查询结果集本身还是内存大头------service 一次性把全量数据查成 List 返回,这块 EasyExcel 管不着。如果导出量级特别大,下一步是分页查询加 ExcelWriter 分批 write,让"查"和"写"都流起来。我们目前的量级下,POI 对象图消失后瓶颈已经不在查询侧,这一步先没做。
第二刀:样板代码组件化
换完库,新的导出接口长这样:查数据 → 设置响应头 → 调 EasyExcel 写出 → 处理异常。四个步骤里,后三个每个接口都一模一样。
第一个模块这么写没感觉。到第二个模块开始复制粘贴第一段导出代码的时候,我意识到该组件化了------照这个抄法,每多一个导出接口就多一份将来要同步维护的样板。既然"怎么导出"是固定的,"导出什么"才是业务的事,那就把前者抽走。
最后落地的结构是三层:注解 + AOP + EasyExcel,各管一段:
- 注解层:声明"这个接口是导出",携带文件名、sheet 名这些元数据
- AOP 层:拦截注解方法,执行业务逻辑拿数据,设置响应头,调 EasyExcel 写到响应流
- EasyExcel 层 :DTO +
@ExcelProperty定义列,专心干活
核心代码的骨架(简化示意,省掉了防御性判断和日志):
java
// 1. 注解:导出声明的元数据
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelExport {
String fileName(); // 文件名,不含扩展名
String sheetName() default "Sheet1";
}
// 2. 切面:导出流程的全部"怎么导出"
@Aspect
@Component
public class ExcelExportAspect {
@Around("@annotation(excelExport)")
public Object around(ProceedingJoinPoint pjp, ExcelExport excelExport)
throws Throwable {
// 先执行业务方法,拿到数据
Object result = pjp.proceed();
// 泛型擦除,List<T> 里的 T 要从方法签名解析
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
Class<?> dtoClass = ResolvableType.forMethodReturnType(method)
.getGeneric(0).resolve();
// 拿 response,设置响应头(xlsx 的 MIME + 编码过的文件名)
ServletRequestAttributes attrs = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
HttpServletResponse response = attrs.getResponse();
response.setContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode(
excelExport.fileName() + ".xlsx", "UTF-8");
response.setHeader("Content-Disposition",
"attachment;filename=" + fileName);
// EasyExcel 写到响应流
@SuppressWarnings("unchecked")
List<Object> data = (List<Object>) result;
EasyExcel.write(response.getOutputStream(), dtoClass)
.sheet(excelExport.sheetName())
.doWrite(data);
return null; // 响应已写完,不再走返回值处理
}
}
业务侧用起来是这样:
java
@ExcelExport(fileName = "客户列表", sheetName = "客户")
@GetMapping("/customers/export")
public List<CustomerExportDTO> export(@RequestParam String keyword) {
return customerService.listForExport(keyword);
}
一个注解,一个返回 List 的普通查询方法。这就是"声明式导出"------业务代码里只剩"导出什么","怎么导出"整个消失在切面里。
封装过程中有三个点值得记下来,都是"写的时候才发现"的:
泛型解析。 切面里拿到的是 Object result,直接强转 List<CustomerExportDTO> 没问题,但 EasyExcel 需要那个 DTO 类来读注解上的列定义。运行时泛型是擦掉的,要用 Spring 的 ResolvableType 从方法返回类型里把 List<T> 的 T 解析出来。
文件名编码。 文件名是中文的话,Content-Disposition 里直接放原始字符串,部分浏览器会乱码甚至截断,必须 URLEncoder.encode 一遍。
异常时机。 响应头一旦设置,这次响应就"定型"了------EasyExcel 往响应流写的过程中如果抛异常,没法再回退成 JSON 错误响应,前端拿到的是一个残缺的文件。所以参数校验这类能提前失败的逻辑要放在 proceed() 之前,写入阶段的失败只能靠日志兜底。
组件抽成了独立模块,各个业务方引依赖。到这一步,新增一个导出接口的成本 = 一个 DTO + 一个查询方法 + 一个注解,原来那段响应头、表头、循环写行、异常处理的样板整体下岗。
效果盘点
- 单次请求堆内存:降 85%。 消失的是 POI 的完整对象图,流式写入下内存占用不再随总行数线性涨。
- Full GC:导出高峰期没再出现。 老年代不再被导出对象图周期性填满,接口超时随之消失。
- 导出相关代码量:少约 40%。 以整体导出代码口径算------每个接口省掉的是那段固定样板,留下的是 DTO 和查询。
- 多模块复用。 客户、拜访记录这些模块的导出先后迁了过来,新接口直接按声明式写。
几点体会
库的"默认姿势"决定内存形态。 XSSF 不是不能用,是它的默认用法就是全内存;SXSSF 是正确姿势,但要开发者自觉去选。回头看这次问题的根源,不是 POI 有多糟,而是"大家随手抄来的那段导出代码"恰好是它最坏的用法。选型时值得多问一句:普通用法下,这个库会把内存变成什么样?
样板复制到第二个模块,就是组件化的信号。 注解 + AOP 的组合本质上是把横切逻辑从业务代码里挪出去。判断标准很简单:第二个人开始复制你第一段代码的时候,就该动手了,等第五个人复制完再抽,迁移成本完全不是一个量级。
想明白数字从哪来的,才知道下一步往哪走。 85% 的下降来自对象图的消失,而不是 EasyExcel 有什么魔法------查询结果集那块内存现在还好好地待在堆里。数据量再涨一个量级,就该轮到分页查询加分批写入了。目前还没做,但路径是清楚的。
还没做的
组件目前只覆盖了最常见的一类导出:固定表头、单 sheet、全量查询。几个已知没做的:上面说的分页流式查询;动态表头和多 sheet 的注解参数还没暴露;样式定制直接用的 EasyExcel 默认值,复杂的合并单元格场景没支持。这些等真的有需求再说------目前各模块的导出需求,一层注解确实够用。