这是「ForgeAdmin 框架源码拆解」系列第 7 篇。上一篇拆
forge-starter-auth(认证链路 4091 行)时,结尾预告了要扒 Excel 模块------因为里面有个"看起来完全没问题"的经典陷阱。
一、起因:一个"已经解决了"的问题
财务同事要导一个月的账单,12 万条。
点了导出,页面转圈。转了三分钟,白屏。
她以为崩了,又点了一次,又点了一次。
三个人同时导,服务器直接卡死。
我去查的时候第一反应是"数据量太大",但 12 万条对导出来说真不算多。然后我想起来:我们做过异步导出的啊。
代码里确实有:提交任务 → 返回任务 ID → 后台慢慢跑 → 前端轮询状态 → 好了再下载。
流程一套一套的,看着很完整。
然后我打开 AsyncExportServiceImpl,看到了这行:
java
// 异步执行导出
executeExportAsync(taskId, configKey, queryParams);
就这一行,三个问题。
二、先看清楚这条链路
Excel 模块一共 4223 行 / 30 个类,导出这块的结构是这样的:
scss
POST /excel/export/submit
│
▼
┌──────────────────────────────────────────┐
│ ExcelEnhancedController │ 228 行
│ submitExport() → 返回 taskId │
│ getExportTaskStatus() → 轮询状态 │
│ downloadExportFile() → 下载 │
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ AsyncExportService(接口) │
│ ├ submitExportTask() │
│ ├ getTaskStatus() │
│ ├ downloadFile() │
│ └ cleanupExpiredTasks() │
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ AsyncExportServiceImpl │ 178 行
│ ├ taskStore: ConcurrentHashMap │ ← 本地内存
│ ├ submitExportTask() │
│ │ └─ executeExportAsync(...) ★★★ │ ← 问题在这
│ └ executeExportAsync() @Async │
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ DynamicExportEngine │ 803 行
│ exportToStream(outputStream, ...) │
└──────────────────────────────────────────┘
配置类开了 @EnableAsync:
java
@Configuration
@EnableAsync
public class ExcelAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public AsyncExportService asyncExportService(DynamicExportEngine dynamicExportEngine) {
return new AsyncExportServiceImpl(dynamicExportEngine);
}
// ...
}
@EnableAsync 有,@Async 有,任务模型有,状态枚举有,轮询接口有。
一样都不缺,就是没生效。
三、@Async 到底是怎么失效的(三重原因)
先把那段代码完整贴出来:
java
@Override
public String submitExportTask(String configKey, Map<String, Object> queryParams, String fileName) {
String taskId = UUID.randomUUID().toString().replace("-", "");
AsyncExportTask task = new AsyncExportTask();
task.setTaskId(taskId);
// ... 设置各种字段
taskStore.put(taskId, task);
// 异步执行导出
executeExportAsync(taskId, configKey, queryParams); // ← ①
log.info("提交异步导出任务:taskId={}, configKey={}", taskId, configKey);
return taskId;
}
@Async
protected void executeExportAsync(String taskId, String configKey, Map<String, Object> queryParams) { // ← ②③
// ...
}
三个问题叠在一起,任何一个单独存在都足以让异步失效:
① 同类内部调用,不走代理
executeExportAsync(...) 没有前缀,等价于 this.executeExportAsync(...)。
this 是目标对象本身,不是代理对象 。Spring 的 @Async 是靠 AOP 代理实现的------调用代理对象的方法时,代理先拦下来,把方法提交到线程池,然后立刻返回。
直接调 this.xxx(),等于绕过了代理,方法就在当前线程老老实实执行完了。
这是最广为人知的一条,但下面两条才是这个项目真正独有的:
② 方法是 protected 的
@Async 标注在 protected 方法上。
CGLIB 子类代理理论上能覆盖 protected 方法,但 Spring 对 @Async 的推荐和绝大多数实践都要求 public。更关键的是------
③ 这个方法不在接口里
看 AsyncExportService 接口,只有 4 个方法:
java
public interface AsyncExportService {
String submitExportTask(String configKey, Map<String, Object> queryParams, String fileName);
AsyncExportTask getTaskStatus(String taskId);
byte[] downloadFile(String taskId);
void cleanupExpiredTasks();
}
没有 executeExportAsync。
而 Spring 默认的 AOP 策略是:目标类实现了接口 → 用 JDK 动态代理;否则才用 CGLIB。
AsyncExportServiceImpl implements AsyncExportService,所以这里用的是 JDK 动态代理。JDK 代理对象只包含接口里声明的方法。
executeExportAsync 是 protected 且不在接口里 → 它压根不在代理对象上。
所以这个 bug 有多彻底?
就算你把 ① 改掉------比如注入自己、或者从 Controller 层调用------只要方法还是 protected 且不在接口里,异步依然不会生效。
三条里必须全改,缺一条都不行。
为什么没人发现
因为它不报错、不告警、日志完全正常,而且功能是对的------文件确实生成了、状态确实变成"完成"了、下载也能下载。
唯一的区别是耗时长。而"导出慢"这个现象太容易被归因成"数据量大"了。
我们线上卡了三个月,一直以为是数据量问题。
四、为什么单测也没抓到
这个项目里其实有 异步导出的测试,叫 AsyncExportSecurityContractTest:
java
AsyncExportServiceImpl service = new AsyncExportServiceImpl(failingEngine);
String taskId = service.submitExportTask("users", Map.of(), "users.xlsx");
AsyncExportTask storedTask = service.getTaskStatus(taskId);
assertThat(storedTask.getStatus()).isEqualTo(AsyncExportStatus.FAILED.getCode());
看出问题了吗?
new AsyncExportServiceImpl(...) ------ 直接 new 出来的,根本没经过 Spring 容器,没有代理对象。
所以在这个测试里,executeExportAsync 是同步执行的,任务状态立刻变成 FAILED,断言通过。
而在生产环境里,它也同步执行------只是没人断言过"这应该是异步的"。
这个测试测不出异步失效,因为它在测试里和生产里表现一模一样。
这是我觉得最值得记住的一点:如果一个功能的正确性依赖于容器行为(代理、事务、AOP),那么用 new 出来的对象做单测,永远测不出它坏了。
五、另外四个坑
坑 2:cleanupExpiredTasks() 从来没有被调用过
方法写得很完整:
java
@Override
public void cleanupExpiredTasks() {
LocalDateTime now = LocalDateTime.now();
taskStore.entrySet().removeIf(entry -> {
AsyncExportTask task = entry.getValue();
if (task.getExpireTime() != null && task.getExpireTime().isBefore(now)) {
if (task.getFilePath() != null) {
try {
Files.deleteIfExists(Paths.get(task.getFilePath()));
} catch (IOException e) {
log.warn("删除过期文件失败:{}", task.getFilePath(), e);
}
}
return true;
}
return false;
});
}
我全项目搜了一遍 cleanupExpiredTasks 的调用点:
csharp
AsyncExportService.java:40 void cleanupExpiredTasks(); ← 接口声明
AsyncExportServiceImpl.java:132 public void cleanupExpiredTasks() { ← 实现
AsyncExportSecurityContractTest.java:76 public void cleanupExpiredTasks() { ← 测试 mock
没有任何 @Scheduled,没有任何定时任务,没有任何地方调它。
后果是双重的:
- 磁盘:每个任务都会在
/tmp/forge-excel-export/留一个 .xlsx,永不删除 - 内存:
taskStore这个 Map 只增不减,永不清理
跑一年,磁盘和堆都会被慢慢吃光。而且这是慢性病,平时看不出来。
顺带说:
AsyncExportTask里设了expireTime = now + 24小时,说明设计者想到了要有过期时间。但清理逻辑写了,调度没接上------这和上一篇 auth 里那个"声明了但没写入的死常量"是同一类病。
坑 3:任务存在本地 Map 里,多实例直接废
java
/**
* 任务存储(生产环境建议用 Redis)
*/
private final Map<String, AsyncExportTask> taskStore = new ConcurrentHashMap<>();
注释很诚实------"生产环境建议用 Redis"。
但它就这么留在生产代码里了。后果:
| 场景 | 表现 |
|---|---|
| 多实例部署 | A 机器提交任务,轮询打到 B 机器 → 查不到任务,前端一直转圈 |
| 服务重启 | 所有任务丢失,正在导出的也没了 |
| 大量导出 | Map 无容量上限,直到 OOM |
而且因为坑 1(异步没生效),现在是同步执行------反而掩盖了这个坑。等哪天把异步修好了,多实例下立刻就会暴露"提交成功但永远查不到任务"的诡异现象。
坑 4:下载把整个文件读进内存
java
@Override
public byte[] downloadFile(String taskId) {
// ...
Path path = Paths.get(task.getFilePath());
return Files.readAllBytes(path); // ← 一次性读入
}
Files.readAllBytes 会把整个文件读成 byte[] 返回。
一个 200MB 的导出文件,就是 200MB 的堆内存,而且是连续的大数组。几个并发下载就能把堆打满。
更糟的是返回 byte[] 之后,Controller 层还要再交给 Spring 的 HttpMessageConverter 处理一次,中间还可能再拷贝一份。
正确做法是流式写响应:
java
@Override
public void downloadFile(String taskId, OutputStream out) {
AsyncExportTask task = taskStore.get(taskId);
if (task == null || !AsyncExportStatus.COMPLETED.matches(task.getStatus())
|| task.getFilePath() == null) {
throw new RuntimeException("任务不存在或文件未生成");
}
try (InputStream in = Files.newInputStream(Paths.get(task.getFilePath()))) {
in.transferTo(out); // 8KB 缓冲,恒定内存
} catch (IOException e) {
throw new RuntimeException("读取文件失败", e);
}
}
坑 5:两个"写了但没用上"的东西
dataCount 是死字段。 AsyncExportTask 里声明了,Controller 里读了:
java
response.put("dataCount", task.getDataCount());
但全项目没有任何一处 setDataCount(...) 。所以它永远是 null,前端永远显示空。
MockHttpServletResponse 是死类。 类注释写着"模拟 HttpServletResponse 用于捕获输出",但方法体里用的是:
java
dynamicExportEngine.exportToStream(outputStream, configKey, queryParams);
那个私有静态内部类从头到尾没被引用过一次------大概率是重构时换方案留下的残骸。
六、它做得对的地方
扒了这么多坑,也得说公道话。这个模块有几个设计我认为值得学:
安全契约测试:不泄露内部信息
AsyncExportSecurityContractTest 这个名字不是白叫的,它测的是一件很实在的事:
java
String internalMessage = "jdbc:mysql://db.internal/forge?password=secret";
// ... 让导出引擎抛出这个异常
assertThat(storedTask.getErrorMessage())
.isEqualTo(AsyncExportTask.PUBLIC_FAILURE_MESSAGE)
.doesNotContain(internalMessage);
不管内部抛了什么(可能含数据库地址、密码、堆栈路径),对外只返回一句固定的:
java
public static final String PUBLIC_FAILURE_MESSAGE = "导出失败,请稍后重试或联系管理员";
服务器文件路径也用 @JsonIgnore 挡住了:
java
@JsonIgnore
private String filePath;
这个意识在很多项目里是缺的------异常信息直接往外抛,别人能从报错里读出你的表结构、文件路径、甚至连接串。值得抄。
SPI 全部可选,不强迫业务方
java
@Autowired(required = false)
private ExcelMetadataProvider metadataProvider;
@Autowired(required = false)
private ExcelConfigProvider configProvider;
@Autowired(required = false)
private DictValueProvider dictValueProvider;
元数据从哪来、列配置从哪来、字典怎么翻译------全部是可选 SPI,不实现也能跑,实现了就增强。
配合 ExcelAutoConfiguration 里每个 Bean 都带 @ConditionalOnMissingBean,业务方想覆盖任何一环都可以。
这是框架代码该有的样子:不替业务做决定。
七、怎么修
@Async 那个问题,三种改法:
方案 A:拆到独立的 Bean(最推荐)
java
@Service
@RequiredArgsConstructor
public class ExportTaskExecutor {
private final DynamicExportEngine dynamicExportEngine;
private final Map<String, AsyncExportTask> taskStore; // 换成 Redis 更好
@Async
public void execute(String taskId, String configKey, Map<String, Object> queryParams) {
// ... 原来的逻辑
}
}
java
@Service
@RequiredArgsConstructor
public class AsyncExportServiceImpl implements AsyncExportService {
private final ExportTaskExecutor executor; // 注入另一个 Bean
@Override
public String submitExportTask(String configKey, Map<String, Object> queryParams, String fileName) {
// ...
taskStore.put(taskId, task);
executor.execute(taskId, configKey, queryParams); // 走代理了
return taskId;
}
}
好处 :跨 Bean 调用必然经过代理;方法是 public,无论 JDK 代理还是 CGLIB 都拦得住;职责也拆清楚了。
方案 B:注入自己(能用但不优雅)
java
@Service
public class AsyncExportServiceImpl implements AsyncExportService {
@Lazy
@Autowired
private AsyncExportService selfProxy; // 注入代理后的自己
public String submitExportTask(...) {
// ...
((AsyncExportServiceImpl) selfProxy).executeExportAsync(taskId, configKey, queryParams);
return taskId;
}
@Async
public void executeExportAsync(...) { ... } // 必须改成 public
}
需要 @Lazy 打破循环依赖。能用,但把"我依赖我自己的代理"写进代码里,读起来拧巴。
方案 C:AopContext.currentProxy()(不推荐)
java
((AsyncExportServiceImpl) AopContext.currentProxy()).executeExportAsync(...);
需要 @EnableAspectJAutoProxy(exposeProxy = true),把代理暴露到 ThreadLocal,侵入性强,且调用点丑陋。除非改不动结构,否则别用。
顺带把调度接上
java
@Scheduled(fixedDelay = 60 * 60 * 1000) // 每小时跑一次
public void scheduledCleanup() {
cleanupExpiredTasks();
}
别忘了在主配置加 @EnableScheduling。
验证口诀:打一行线程名
改完怎么确认真的异步了?在方法里加一行:
java
log.info("导出线程:{}", Thread.currentThread().getName());
- 打出来是
http-nio-8080-exec-1或XNIO-1 task-1→ 还是同步,没走代理 - 打出来是
task-1、SimpleAsyncTaskExecutor-1、export-pool-1→ 真异步了
这比看代码快,也比猜靠谱。
八、五个坑速查表
| # | 坑 | 现象 | 根因 | 解法 |
|---|---|---|---|---|
| 1 | @Async 完全失效 |
"异步导出"实为同步,接口卡死 | 同类自调用 + protected + 不在接口(JDK 代理看不到),三重叠加 |
拆到独立 Bean,public 方法,跨 Bean 调用 |
| 2 | 清理逻辑没有调度 | 临时文件和任务 Map 永不释放 | cleanupExpiredTasks() 全项目无调用点,缺 @Scheduled |
加定时任务 + @EnableScheduling |
| 3 | 任务存本地 Map | 多实例查不到任务、重启丢全部 | ConcurrentHashMap 是 JVM 内存,且无容量上限 |
换 Redis(带 TTL),或至少加容量上限 |
| 4 | 下载整个文件进内存 | 大文件 OOM | Files.readAllBytes + 返回 byte[] |
改成流式 transferTo,不返回 byte\[\] |
| 5 | 两个死物件 | dataCount 永远空;MockHttpServletResponse 从未使用 |
重构残留,没有清理 | 要么补上 setter,要么删掉 |
九、可带走的 5 条诀窍
-
@Async失效要查三件事,不是一个。 大部分文章只讲"自调用",但实际项目里protected修饰符和"方法不在接口里(触发 JDK 代理)"同样致命,而且更隐蔽------因为这两个改起来要动方法签名。 -
依赖容器的能力,不能用
new出来做单测。 代理、事务、AOP 全是容器行为,new出来的对象没有这些。要想覆盖,就得用@SpringBootTest或者至少手动套一层代理。否则测试通过只是"碰巧和生产一样坏"。 -
清理逻辑写完不等于会执行。 方法写好了、过期时间设了,但没有调度 = 没有。这类"慢性病"在线上潜伏几个月才发作,排查成本极高。写完清理逻辑,第一件事是确认谁调它。
-
修 A 坑可能引爆 B 坑。 这个模块现在是"同步执行",反而掩盖了"任务存本地 Map"在多实例下的问题。等异步修好了,多实例部署立刻会出现"提交成功但永远查不到"。改一个坑之前,先想想它当前压住了谁。
-
异常信息要分级。 内部异常可以很详细(打日志),对外的必须是一句固定文案。这个模块用
PUBLIC_FAILURE_MESSAGE+@JsonIgnore挡路径的做法很标准,值得直接抄。
十、最后
这个功能从代码上看是完整的:
有 @EnableAsync、有 @Async、有任务模型、有状态枚举、有提交接口、有轮询接口、有下载接口、有过期时间、有清理逻辑、还有个安全契约测试。
Code Review 也过得去。
唯一的问题是,它从第一天起就是同步的。
而验证它只需要 30 秒:在方法里打一行线程名。
我后来越想越觉得,这类 bug 的共同点不是"难发现",而是**"看起来不需要验证"**------代码写得这么完整,功能又是对的,谁会去怀疑"异步"这个最基础的假设?
所以这篇如果你只带走一件事:给所有"异步"的地方打一行线程名日志。 花 30 秒,能省三个月。
如果这篇对你有启发,点个赞吧,这是系列第 7 篇,每一篇都得真读几千行源码。
下一篇我们扒 forge-starter-websocket------实时推送这块,连接管理、心跳、断线重连、消息队列堆积,坑的密度比 Excel 还高。
评论区聊聊:你有没有遇到过"加了 @Async 但其实是同步"的情况? 我赌一半人踩过,而且大部分是查了很久才发现。
系列导航
- 数据权限拦截器:SQL 改写那些坑
- 多租户 Starter 源码拆解
- 多租户 × 数据权限共存:拦截器注册顺序
- 幂等 Starter:1279 行里的 5 个隐蔽坑
- 操作日志 Starter:1383 行里的 5 个反直觉设计
- 认证链路:4091 行,账号锁定为什么完全失效
- Excel 模块:4223 行,
@Async为什么完全没生效(本篇)
项目地址
- Gitee:
https://gitee.com/ForgeLab/forge-admin - GitHub:
https://github.com/yaomindong1996/forge-admin - 在线文档:
http://www.dlforgelab.com:8084/forge-docs/ - 在线演示:
http://www.dlforgelab.com:8084/forge/login(admin / 123456)
源码路径:forge-server/forge-framework/forge-starter-parent/forge-starter-excel(4223 行 / 30 个类)。
文中提到的坑 1~5 均为阅读源码所得,修复方案为本文建议,尚未提交到仓库。其中坑 1(
@Async失效)建议优先处理------它不需要改架构,拆个类就行。