后台系统里,数据导出这功能看着简单,在线上却是个定时炸弹。动不动百万、千万行数据,按传统同步全量拉取再塞给 poi,oom 告警和网关超时是家常便饭。最近刚把公司这套导出服务重做完,借着复盘的机会,把 spring boot + easyexcel 配合 jdbc 流式查询、异步编排和云存储这套生产可用的方案捋一遍。不整虚的,直接上实战细节。
传统同步全量导出的三个致命坑
刚接手导出需求时,很多团队的代码长这样:
list<order> list = ordermapper.selectbycondition(query); // 全量加载 workbook wb = new xssfworkbook(); // poi 创建 // 遍历 list 填充 sheet // response.getoutputstream() 写回浏览器
这套代码在测试环境跑几千条没问题,一上生产,数据量上来基本必踩这三个坑:
jvm 堆内存直接打满:poi 的 xssfworkbook 走的是 dom 模型,会把整个 excel 文档结构塞进内存。一百万行数据,光 java 对象开销就几百兆,再加上 poi 内部缓存和 string 对象复用失效,young gc 根本扛不住,分分钟触发 full gc 甚至直接 oom。
数据库连接和游标卡死:selectbycondition 默认是 resulttype=default,mysql jdbc 驱动会傻傻地把全量结果集拉到客户端内存里。网络带宽和客户端内存双重吃紧,慢查询日志里全是这类拖垮 db 的语句。
http 同步阻塞拖垮网关:nginx 或者网关的超时一般就 30 到 60 秒。导几百万数据没个两三分钟下不来,同步请求肯定 504。更麻烦的是 servlet 线程一直被占着,线程池一爆,整个 web 服务的核心接口跟着挂。
线上能稳的导出,必须把“查、写、传”拆开。核心就一条:流式读、分批写、异步跑、文件扔对象存储。
jdbc 流式查询的原理与落地
流式查询说白了,就是别让结果集一次性进 jvm,而是让驱动按需、逐块地从数据库拉数据。
mysql jdbc 想开启真正的服务端游标,连接串里得带上参数:
spring.datasource.url=jdbc:mysql://host/db?usecursorfetch=true&defaultfetchsize=1000
在 mybatis 里配合 @options 用起来很顺手:
@select("select id, order_no, amount, create_time from t_order where status = 1")
@options(resultsettype = resultsettype.forward_only, fetchsize = integer.min_value)
cursor<order> streamorderquery(@param("status") integer status);
这里得重点提一嘴 fetchsize = integer.min_value。这是 mysql 驱动认的一个魔数,告诉底层用 type_forward_only 模式流式读,每次网络包默认走 4kb。cursor 本质上就是 java.sql.resultset 的一层薄封装。遍历完千万记得调 cursor.close(),不然服务端的连接句柄一直悬着,数据库报 too many connections 是迟早的事。
实际写代码的时候,强烈建议用 try-with-resources 把游标包起来,或者在 easyexcel 的自定义 writehandler 里统一管生命周期。别把 cursor 跨方法传来传去,漏关一次,线上排查连接泄漏能查掉半条命。
easyexcel 分批写入与内存控制
easyexcel 底层走的是 sax 模式,写文件时默认不缓存完整文档结构,这已经比 poi 友好太多了。但如果不控制分批节奏,内存照样能飙上去。直接看生产里常用的分批写法:
try (excelwriter writer = easyexcel.write(filepath, orderexportdto.class).build()) {
writesheet writesheet = easyexcel.writersheet("订单明细").build();
int batchsize = 2000;
list<order> batch = new arraylist<>(batchsize);
try (cursor<order> cursor = mapper.streamorderquery(1)) {
for (order order : cursor) {
batch.add(order);
if (batch.size() == batchsize) {
writer.write(batch, writesheet); // 刷入底层缓冲
batch.clear(); // 释放引用,等 gc 回收
}
}
if (!batch.isempty()) {
writer.write(batch, writesheet);
}
}
}
几个容易忽略的细节:
inmemory(true)这个配置默认就是关的,千万别手滑去开它,开了就退化成 poi 的内存模型了。- 临时文件默认会落到操作系统的 tmp 目录。数据量大的时候,磁盘空间要盯紧点,别等磁盘满了才反应过来。
- 样式对象别在循环里 new。poi 里
cellstyle有数量上限,easyexcel 内部虽然做了缓存池,但你自己写writehandler处理动态列宽、特殊单元格样式的时候,还是尽量复用。
实测下来,batchsize 设在 2000 到 5000 之间比较均衡。太小了网络交互和系统调用频繁,cpu 开销大;太大了内存压力又上来了,具体看你们服务器的 io 吞吐和 jvm 堆配置。
独立线程池与异步编排
导出任务绝对不能用 tomcat 的主线程池。一次慢查询或者大文件生成,能把整个 web 容器的线程吃光,到时候用户登录接口都进不来。
我们线上配了一个独立的线程池,大概长这样:
@bean("exportexecutor")
public threadpooltaskexecutor exportexecutor() {
threadpooltaskexecutor executor = new threadpooltaskexecutor();
executor.setcorepoolsize(4);
executor.setmaxpoolsize(16);
executor.setqueuecapacity(200); // 缓冲队列防瞬时洪峰
executor.setthreadnameprefix("async-export-");
executor.setrejectedexecutionhandler(new threadpoolexecutor.callerrunspolicy()); // 背压兜底
executor.settaskdecorator(r -> new mdccontexttask(r)); // 透传 traceid
executor.initialize();
return executor;
}
队列容量设了 200,防一手瞬时洪峰。拒绝策略用了 callerrunspolicy,这是生产里最稳的兜底方案。队列满了就让提交任务的 controller 线程自己跑,相当于自动背压。虽然请求会慢点,但总比直接把任务扔了或者 oom 强。
任务编排上,completablefuture 足够用了:
public completablefuture<exportresult> asyncexport(exportrequest req) {
return completablefuture
.supplyasync(() -> querycursorandwritefile(req), exportexecutor)
.thenapplyasync(file -> uploadtooss(file, req.gettaskid()), clouduploadexecutor)
.whencomplete((result, ex) -> updatetaskstatus(req.gettaskid(), result, ex))
.exceptionally(ex -> handleexportexception(req.gettaskid(), ex));
}
注意 thenapplyasync 里上传 oss 那一步,最好再分一个独立的 io 线程池(比如 clouduploadexecutor),别跟读写文件、查数据库的逻辑混在一个池子里,不然 io 阻塞会拖慢查询线程。mdc 透传用 taskdecorator 处理,日志里才能顺着 traceid 把导出任务的全链路串起来,排查问题省事很多。
进度追踪、断点续导与文件归档
导出跑起来之后,前端总得知道进度,万一中途重启了还得能续上。
进度追踪这块,redis 是最轻量的方案。用 hash 结构存任务状态:
export:task:{taskid} -> {status: "running", current: 150000, total: 2000000}
在分批写入的循环里定期更新 current。别每写一条都调 redis,网络开销太大,攒个几百条或者按时间间隔(比如每秒一次)刷一次就行。前端轮询 /api/export/progress/{taskid} 拿进度和实时速度。
千万级数据导出,服务重启或者网络抖动是常事,断点续导得做。数据库游标没法序列化保存,所以续导一般不用原生 cursor,而是退化成基于主键的范围查询(where id > lastid order by id asc limit batchsize)。在 t_export_task 表里记个 last_processed_id 和 local_file_path,任务中断重启后,直接读这个 offset 接着查库写文件,逻辑上无缝衔接。
文件最终肯定不能落本地。我们现在的做法是,easyexcel 把数据分批刷到本地临时文件后,立刻调 ossclient.putobject 上传。文件特别大的话,走分片上传(multipart upload),每凑够一个 chunk 就调 uploadpart,最后合并。传完马上 files.delete(tempfile),别给运维留清理磁盘的坑。
动态表头与公式单元格处理
有些报表要求表头是动态的,或者单元格带 excel 公式。easyexcel 都能搞定,但写法有点讲究。
动态表头直接传 list<list<string>> 就行,easyexcel 会自动做单元格合并:
list<list<string>> head = new arraylist<>();
head.add(arrays.aslist("基本信息", "订单号"));
head.add(arrays.aslist("基本信息", "创建时间"));
head.add(arrays.aslist("财务数据", "应付金额"));
head.add(arrays.aslist("财务数据", "已付金额"));
// easyexcel 会自动合并相同前缀的单元格
公式导出稍微麻烦点。easyexcel 3.x 支持直接写公式字符串,但要注意,excel 引擎不会在生成时帮你算值,它只认公式串。
@data
public class orderexportdto {
@excelproperty("订单号")
private string orderno;
@excelproperty("小计")
private double amount;
@excelproperty("合计(公式)")
public string gettotalformula() {
// 生产环境需通过自定义 writehandler 注入准确行列号
return "sum(b2:c2)";
}
}
硬编码行号(比如 sum(b2:c2))在动态分页里很容易错乱。更稳妥的做法是自定义 writehandler,在 aftercelldispose 回调里,根据当前写入的实际行号动态拼接公式字符串,然后塞进 writecelldata。虽然多写点代码,但线上跑起来绝对不出错。
线上踩坑记录与调优经验
最后聊聊线上踩过的坑和调优经验。这块没有标准答案,全是真金白银砸出来的教训。
第一个大坑就是大事务绑着流式查询。很多人习惯性在导出方法上挂 @transactional,结果 spring 会一直持有数据库连接直到方法返回。游标遍历加上文件 io,连接可能占着几分钟不释放,连接池瞬间打满。解法很简单,导出方法用 @transactional(propagation = propagation.not_supported) 关掉事务,查询基础配置数据时再手动开短事务。
gc 停顿也是个头疼事。导出时疯狂创建临时 dto 和 string 对象,young gc 会非常频繁。我们线上 jvm 参数配合 g1 调了一下 -xx:maxgcpausemillis=200,如果数据量实在离谱,直接上 jdk 17+ 的 zgc,暂停时间能压到毫秒级,基本感知不到卡顿。导出期间尽量避免 full gc,否则业务接口直接卡死。
并发导出必须限流。运营同学手快一点,或者批量跑定时任务,十个导出请求一起打过来,线程池直接塞满。我们在入口处接了 sentinel 做了按租户/用户的令牌桶限流,同时给 completablefuture 加了 ortimeout,单个任务跑 20 分钟强制超时清理,防止僵尸任务堆积。
关于性能基线,我们压测过几套不同配置的机器,大概摸出个规律:100 万行数据,p99 耗时控制在 5 分钟以内算健康。这要求 fetchsize 别设太小,1000 到 5000 比较合适,能减少网络往返次数。堆内存峰值尽量压在 70% 以下,靠的就是前面说的分批 clear() 和及时释放引用。线程池拒绝率在生产环境必须是 0,靠合理设置队列和背压策略兜底。数据库连接持有时间必须压在 10 秒以内,游标用完立刻关。调优的时候多盯着 arthas 的 dashboard 和 grafana 的 directmemory,瓶颈十有八九卡在本地磁盘 io 慢,或者上传 oss 的网络带宽打满了。
把这套方案跑通之后,后台导出基本就稳了。核心就一句话:别跟内存和连接池较劲,该流的流,该批的批,该异步的异步。
线上系统怕的不是功能多,而是细节没兜住。一个没关的游标、一个隐式长事务、一次没限流的并发,分分钟能把服务拖垮。建议团队把导出能力抽成独立组件,把线程池参数、限流阈值、进度上报和异常重试统一封装好。后续新业务接导出,配个 sql 和映射关系就能用,既省开发成本,也好统一做监控和容灾。
以上就是springboot+easyexcel实现海量数据异步导出与流式报表的详细内容,更多关于springboot easyexcel数据异步导出与流式报表的资料请关注代码网其它相关文章!
发表评论