EasyExcel SAX 流式读取 + 有界线程池 + CallerRunsPolicy——一个 Java 后端实习生的"导入三件套"实战

副标题:10w 行 Excel 不爆内存、不丢任务、自动降速:组合拳就这么打


一、开场引入:为什么"Excel 导入"对一个后端实习生是噩梦?

实习期间,业务让我做一个"线索批量导入"功能——用户上传 5w 行 Excel,导入到系统里。

我第一版用最简单的 EasyExcel.read().head().sheet().doReadSync() —— OOM 了

同事看了一眼:

“你这是一次性把整张表读到内存,5w 行 × 200 字段 = 千万级对象,JVM 顶不住。”

我一脸懵:“那咋办?”

mentor 抛下一句话:

Excel 导入三大难题:内存爆掉、并发失控、失败回滚。靠单个组件都解决不了,必须三件套组合——SAX 流式读取 + 有界线程池 + CallerRunsPolicy。”

回去翻了项目代码,发现:

  • 业务代码几乎不用 EasyExcel 链式 API,而是基于 POI 的 XSSFSheetXMLHandler 自封装 SAX 流式读取
  • 导入校验环节用 ThreadFactoryBuilder 命名 + ArrayBlockingQueue(100) 有界 + CallerRunsPolicy 三件套
  • 二十几个 Listener 子类都按这套模板来

这次我学到:EasyExcel 链式 API 不够用,必须配合自封 SAX + 线程池三件套

今天这篇博客,我就把项目里的 “导入三件套” 掰开揉碎讲透,让更多像我一样的后端新人少走弯路。

💡 金句Excel 导入不是"调个 API 就行",是"组合拳工程"


二、概念扫盲:Excel 导入的三大难题

2.1 三大难题

Excel 导入
3 大难题

内存

一次性读全表

OOM

并发

任务堆积

无界队列爆炸

失败

丢任务

不一致

2.2 为什么单个组件解决不了

组件单独使用的问题
EasyExcel.read() 链式一次性读全表,5w 行 OOM
普通线程池(无界队列)任务堆积到 100w,OOM
默认 AbortPolicy队列满就抛异常,任务丢失 / 接口报错

所以——EasyExcel 不够,线程池不够,CallerRunsPolicy 不够——三个组合起来才够

2.3 三件套组合思路

解决

解决

解决

SAX 流式读取
不爆内存

有界线程池
防任务堆积

CallerRunsPolicy
防丢任务 + 背压

内存难题

并发难题

失败难题

💡 金句SAX 解决"读",线程池解决"算",CallerRunsPolicy 解决"稳"——三件套各司其职

2.4 整体架构图

错误

用户上传 Excel

SAX 流式读取
逐行回调 invoke()

基础校验
Listener 内 invoke

批量拆分 BATCH=200

有界线程池
10 线程 + 100 队列

CallerRunsPolicy
背压

跨表校验 / 落库

错误行 → SXSSF
写标黄 Excel

返回 fileId


三、精讲 1:EasyExcel SAX 流式读取 ⭐ 重头

3.1 一句话定义

SAX(Simple API for XML)= 逐行扫描 Excel XML 流,不一次性加载到内存

3.2 链式 API vs SAX 流式

✅ SAX 流式读取

读一行 → 回调 invoke()

读一行 → 回调 invoke()

读一行 → 回调 invoke()

内存只占当前行

❌ EasyExcel.read() 链式 API

一次性读全表

5w 行 * 200 字段

OOM 💥

3.3 SAX 原理(一句话)

概念解释
DOM一次性把 XML 全加载到内存,构建对象树
SAX边读边触发回调(invoke / invokeHead),不构建对象树

Excel 本质是 XML 文件,POI 的 XSSFSheetXMLHandler 就是 SAX 实现。

3.4 项目里的真 SAX 抽象(脱敏)

// ✅ 项目自封的 SAX 抽象基类(简化版)
public abstract class AbstractImportExcelSaxReadParse<T> {

    // 核心:走 POI SAX,不读全内存
    public List<T> importExcel(File file, String templatePath, String json) throws Exception {
        InputStream in = Files.newInputStream(file.toPath());
        // 底层: XSSFReader + SAXHelper.newXMLReader()
        // 不会把整张 sheet 加载到内存
        return parserSheet(in, "8", templatePath, null, json);
    }

    // 子类实现:每行校验逻辑
    protected abstract void verify(T data, StringBuilder errorMsg);

    // 子类实现:批量跨表校验
    protected abstract void validateImportList(List<T> list, Map<Integer, StringBuilder> errorMap);
}

优点

  • 十几万行 Excel 也不爆内存
  • ✅ 解析过程可控(每行回调)

3.5 三层 Listener 抽象(项目新派写法)

AbstractBaseReadListener
3 套子类

AbstractFixedReadListener

列固定

严格模板

AbstractChangeReadListener

列变化

灵活业务

AbstractDynamicReadListener

动态列

KPI 场景

三段式处理流程

数据库校验线程池ListenerSAX 解析器用户上传数据库校验线程池ListenerSAX 解析器用户上传错误行 → errorMap正确行 → dataList1. 上传 Excel2. invokeHead(表头)3. invoke(每行)4. doAfterAllAnalysed()5. validateImportList()6. 跨表查询7. 校验结果回灌8. 错误 fileId / 成功

3.6 错误行就地回写(项目亮点)

// 解析时收集错误
protected Map<Integer, StringBuilder> errorMap = new LinkedHashMap<>();

// 解析完写"标黄版 Excel"
public String uploadFile(OssHelper oss, File file, String userCode) {
    // 用 SXSSF 在错误行追加一列"错误信息"
    // 错误单元格变黄(背景色)
    // 上传到 OSS,返回 fileId
}

好处:用户下载"标黄版 Excel"自查,比文本提示友好太多

3.7 关键点

  • 真 SAX 不爆内存(不是 EasyExcel 链式 API)
  • 三层 Listener 抽象:Fixed / Change / Dynamic 覆盖 90% 业务
  • 错误就地回写 → 友好用户提示
  • ❌ 不要在 Listener 里写重业务逻辑(应该只做基础校验)
  • ✅ 重业务(跨表 / 权限)拆到专门的校验线程池

💡 金句SAX 解决"读",线程池解决"算",CallerRunsPolicy 解决"稳"——三 件套各司其职


四、精讲 2:有界线程池 ⭐ 重头

4.1 一句话定义

有界线程池 = 任务队列容量有限的线程池,避免任务堆积导致 OOM。

4.2 为什么必须有界?

无界队列
LinkedBlockingQueue()

任务堆积到 100w

JVM 内存爆炸 OOM 💥

队列类型容量风险
LinkedBlockingQueue()无界(Integer.MAX_VALUE)⚠️ 高风险 OOM
LinkedBlockingQueue(1000)有界✅ 推荐
ArrayBlockingQueue(100)严格有界✅✅ 适合导入校验

4.3 项目里的"导入校验线程池"模板(脱敏)

// ✅ 项目里内置的有界线程池(最贴博客主题的代码)
private static final int  BATCH_SIZE       = 200;   // 每批 200 行
private static final int  MAX_THREADS      = 10;    // 最多 10 个线程
private static final int  QUEUE_SIZE       = 100;   // 有界队列
private static final long KEEP_ALIVE_TIME  = 0L;    // 固定池

private ThreadPoolExecutor createBoundedThreadPool(int corePoolSize) {
    return new ThreadPoolExecutor(
        corePoolSize, corePoolSize,                  // ★ 固定大小线程池
        KEEP_ALIVE_TIME, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(QUEUE_SIZE),       // ★ 严格有界队列(防 OOM 关键)
        new ThreadFactoryBuilder()
            .setNameFormat("xxx-validate-pool-%d")    // ★ 命名线程,方便排查
            .setDaemon(true)                          // 守护线程
            .setUncaughtExceptionHandler((t, e) ->
                log.error("线程 {} 发生未捕获异常", t.getName(), e))
            .build(),
        new ThreadPoolExecutor.CallerRunsPolicy()    // ★ 背压到提交线程
    );
}

每个参数都有讲究

参数为什么这么选
corePoolSize == maximumPoolSize10固定大小,导入校验 CPU 不重,不需要弹性扩容
ArrayBlockingQueue(100)100严格有界,防 OOM;100 比 LinkedBlockingQueue 更安全
keepAliveTime = 00固定池不需要回收,简单可控
ThreadFactoryBuilder 命名xxx-validate-pool-%d线程名一眼看出业务,日志排查快 10 倍
daemon = truetrue守护线程,JVM 退出时不阻塞
CallerRunsPolicy-背压到提交线程(见精讲 3)

4.4 项目里的"工具类工厂"(团队约定)

// ✅ 项目里所有 Spring Bean 线程池都走这个工厂
public static ThreadPoolExecutor createThreadPool(
        int corePoolSize, int maximumPoolSize, int queueCapacity,
        long keepAliveTime, String namePrefix) {

    return new ThreadPoolExecutor(
        corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(queueCapacity),    // 有界
        new ThreadFactory() {
            private final AtomicInteger t = new AtomicInteger(1);
            @Override public Thread newThread(Runnable r) {
                Thread th = new Thread(r);
                th.setName(namePrefix + "-" + t.getAndIncrement());
                return th;
            }
        },
        new ThreadPoolExecutor.CallerRunsPolicy()    // 团队默认
    );
}

4.5 用 CompletableFuture 提交任务(实战)

// 数据量小 → 单线程,避免线程切换
if (totalSize <= BATCH_SIZE) {
    validateBatch(list, list, 0, errorMap, ...);
    return;
}

// 数据量大 → 计算最优线程数
int optimalThreads = Math.min((totalSize + BATCH_SIZE - 1) / BATCH_SIZE, MAX_THREADS);
ThreadPoolExecutor executor = createBoundedThreadPool(optimalThreads);
AtomicInteger completedBatches = new AtomicInteger(0);
int totalBatches = (totalSize + BATCH_SIZE - 1) / BATCH_SIZE;

try {
    List<CompletableFuture<Void>> futures = new ArrayList<>();

    for (int i = 0; i < totalSize; i += BATCH_SIZE) {
        final int start = i;
        final List<DTO> batch = list.subList(start, Math.min(i + BATCH_SIZE, totalSize));

        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            validateBatch(list, batch, start, errorMap, ...);
            completedBatches.incrementAndGet();
        }, executor);                                 // ★ 用有界线程池
        futures.add(future);
    }

    // 限时等待所有批次完成
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                     .get(60, TimeUnit.MINUTES);     // ★ 超时兜底
} finally {
    gracefulShutdown(executor, 60, TimeUnit.MINUTES); // ★ 优雅停机
}

4.6 关键点

  • ArrayBlockingQueue 严格有界,比 LinkedBlockingQueue 更安全
  • ThreadFactoryBuilder 命名 + 守护 + 全局异常处理
  • 固定大小线程池core == max)适合导入校验
  • CompletableFuture + 超时避免永久阻塞
  • 优雅停机shutdown() → 等超时 → shutdownNow()

💡 金句线程池不是 Executors.newFixedThreadPool() 一行搞定,7 个参数每个都有讲究


五、精讲 3:CallerRunsPolicy ⭐ 重头

5.1 一句话定义

CallerRunsPolicy = 队列满时,由提交任务的线程自己执行任务。

5.2 4 种内置拒绝策略对比

策略行为适用场景
AbortPolicyRejectedExecutionException⚠️ 默认,业务可能崩
CallerRunsPolicy提交线程自己跑✅ 业务型(导入 / 提交流程)
DiscardPolicy悄悄丢弃⚠️ 通知型(丢一条不要紧)
DiscardOldestPolicy丢最老的任务⚠️ 看场景

5.3 CallerRunsPolicy 的反压语义

工作线程队列(已满)线程池提交线程工作线程队列(已满)线程池提交线程队列堆积到 100⚠️ 提交线程自己执行 task101submit(task1)派给工作线程submit(task101)队列满!拒绝CallerRunsPolicy主线程被卡住(背压生效)

核心效果:上游"提交者"自动降速 → 自然限流

5.4 项目里的"业务型 vs 通知型"分类

项目所有线程池

业务型
导入 / 合同 / 合同组

通知型
机器人通知

CallerRunsPolicy
慢但不能丢

DiscardPolicy
丢一条不能阻塞

业务类型选择理由
导入校验CallerRunsPolicy丢一行整批要回滚
合同提交CallerRunsPolicy关键业务不能丢
通用业务池CallerRunsPolicy团队约定
机器人通知DiscardPolicy丢一条不阻塞主流程

5.5 为什么业务型选 CallerRunsPolicy?

理由解释
不丢任务即使队列满,任务也会执行(虽然慢)
天然限流上游自动降速
失败友好CallerRuns 让提交者同步执行,错误能立刻被发现
❌ 副作用调用者线程会被卡住(所以上游要有超时控制)

5.6 为什么通知型选 DiscardPolicy?

理由解释
不阻塞丢一条消息比阻塞主流程划算
业务容忍通知类业务允许少量丢失(用户没收到会再发)
❌ 副作用任务悄悄没了,监控必须补上

5.7 关键点

  • 业务型 → CallerRunsPolicy(项目团队约定)
  • 通知型 → DiscardPolicy
  • 不要无脑用 AbortPolicy(默认是它,但容易崩)
  • ✅ CallerRunsPolicy 的"提交者卡住"是特性不是 bug

💷 金句CallerRunsPolicy = “不让任务丢,让提交者慢”。通知型才用 DiscardPolicy


六、3 大组件的关系图

不爆内存

防任务堆积

防丢任务

SAX 流式读取

有界线程池

CallerRunsPolicy

整体 Excel 导入

金句SAX 解决"读",线程池解决"算",CallerRunsPolicy 解决"稳"——三件套各司其职,合起来才是完整的 Excel 导入方案


七、5 个常见踩坑(过来人的血泪教训)

#踩坑现象解法
1EasyExcel.read() 链式5w 行 OOM用 SAX 流式
2LinkedBlockingQueue() 无参任务堆积 100w必须指定容量
3默认 AbortPolicy任务一多就抛异常业务型改 CallerRunsPolicy
4Executors.newFixedThreadPool线程名 pool-1-thread-1看不出业务ThreadFactoryBuilder 命名
5不优雅停机发版时任务丢一半waitForTasksToCompleteOnShutdown=true

每个坑我都真实踩过。把这些记下来,能让你的 Excel 导入从"5w 行 OOM"升级到"10w 行稳如老狗"


八、6 步落地路径

如果你想自己落地这套三件套,按这个顺序来:

第 1 步
用 SAX
替换链式 API

第 2 步
抽抽象
Listener 基类

第 3 步
配置有界队列
ArrayBlockingQueue

第 4 步
CallerRunsPolicy
替换默认 Abort

第 5 步
ThreadFactory
命名线程

第 6 步
CompletableFuture
+ 优雅停机

  1. 第 1 步:用 SAX 替换 EasyExcel.read() 链式 API
  2. 第 2 步:抽抽象 Listener 基类(继承 ReadListener)
  3. 第 3 步:配置有界队列 ArrayBlockingQueue(100)
  4. 第 4 步:业务型线程池改 CallerRunsPolicy
  5. 第 5 步ThreadFactoryBuilder 命名 + 守护 + 异常处理
  6. 第 6 步CompletableFuture.allOf + .get(timeout) + 优雅停机

九、结尾

写到这里,EasyExcel SAX + 有界线程池 + CallerRunsPolicy 三件套就讲完了。

Excel 导入看似简单,实则是Java 后端工程化的缩影——内存管理、并发控制、失败处理,三个维度都要考虑。

把今天这篇博客收藏起来,写导入模块时翻一翻——你的代码会从"5w 行 OOM"升级到"10w 行稳如老狗"。

剩下的 30%?SXSSF 标黄细节 / OSS 上传 / 异步回调,等你工作 3 年再学不迟。先把基础打牢,三件套吃透,再去啃细节。


写在最后

如果这篇博客对你有帮助,请:

  • 点赞——你的点赞是我继续写下去的动力
  • 📌 收藏——下次写 Excel 导入翻出来看看
  • 关注我——后续会更新 异步回调 / 模板校验 / 重试机制 系列
  • 💬 评论区留言——告诉我你想看什么主题,我安排!

我是 程序员小八777,一名 Java 后端实习生,把实习中学到的"踩坑经验"写成博客,分享给更多和我一样在成长路上的同学们。

我们下期见!🚀

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐