做了"异步导出",用户点了还是卡 3 分钟:扒完 4223 行源码,@Async 压根没生效

这是「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 代理对象只包含接口里声明的方法

executeExportAsyncprotected 且不在接口里 → 它压根不在代理对象上

所以这个 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-1XNIO-1 task-1还是同步,没走代理
  • 打出来是 task-1SimpleAsyncTaskExecutor-1export-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 条诀窍

  1. @Async 失效要查三件事,不是一个。 大部分文章只讲"自调用",但实际项目里 protected 修饰符和"方法不在接口里(触发 JDK 代理)"同样致命,而且更隐蔽------因为这两个改起来要动方法签名。

  2. 依赖容器的能力,不能用 new 出来做单测。 代理、事务、AOP 全是容器行为,new 出来的对象没有这些。要想覆盖,就得用 @SpringBootTest 或者至少手动套一层代理。否则测试通过只是"碰巧和生产一样坏"。

  3. 清理逻辑写完不等于会执行。 方法写好了、过期时间设了,但没有调度 = 没有。这类"慢性病"在线上潜伏几个月才发作,排查成本极高。写完清理逻辑,第一件事是确认谁调它。

  4. 修 A 坑可能引爆 B 坑。 这个模块现在是"同步执行",反而掩盖了"任务存本地 Map"在多实例下的问题。等异步修好了,多实例部署立刻会出现"提交成功但永远查不到"。改一个坑之前,先想想它当前压住了谁。

  5. 异常信息要分级。 内部异常可以很详细(打日志),对外的必须是一句固定文案。这个模块用 PUBLIC_FAILURE_MESSAGE + @JsonIgnore 挡路径的做法很标准,值得直接抄。


十、最后

这个功能从代码上看是完整的

@EnableAsync、有 @Async、有任务模型、有状态枚举、有提交接口、有轮询接口、有下载接口、有过期时间、有清理逻辑、还有个安全契约测试。

Code Review 也过得去。

唯一的问题是,它从第一天起就是同步的。

而验证它只需要 30 秒:在方法里打一行线程名。

我后来越想越觉得,这类 bug 的共同点不是"难发现",而是**"看起来不需要验证"**------代码写得这么完整,功能又是对的,谁会去怀疑"异步"这个最基础的假设?

所以这篇如果你只带走一件事:给所有"异步"的地方打一行线程名日志。 花 30 秒,能省三个月。


如果这篇对你有启发,点个赞吧,这是系列第 7 篇,每一篇都得真读几千行源码。

下一篇我们扒 forge-starter-websocket------实时推送这块,连接管理、心跳、断线重连、消息队列堆积,坑的密度比 Excel 还高。

评论区聊聊:你有没有遇到过"加了 @Async 但其实是同步"的情况? 我赌一半人踩过,而且大部分是查了很久才发现。


系列导航

  1. 数据权限拦截器:SQL 改写那些坑
  2. 多租户 Starter 源码拆解
  3. 多租户 × 数据权限共存:拦截器注册顺序
  4. 幂等 Starter:1279 行里的 5 个隐蔽坑
  5. 操作日志 Starter:1383 行里的 5 个反直觉设计
  6. 认证链路:4091 行,账号锁定为什么完全失效
  7. 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 失效)建议优先处理------它不需要改架构,拆个类就行。

相关推荐
szxinmai主板定制专家2 小时前
【嵌入式实战】RK3588+GMSL多路相机采集方案|SerDes长距离低延时视频流落地教程
人工智能·单片机·机器人·边缘计算·zynq
Ada's2 小时前
【智能体系统AgentOS】核心25:Agentic AI
人工智能
RoboWizard2 小时前
内存条对游戏帧数影响大不大
人工智能
美林数据Tempodata2 小时前
本科人工智能专业(080717T)建设方案:实验室配置要从实验教学要求倒推
人工智能·产教融合·ai教育·实验室建设·新工科
wno7042 小时前
Spring Security添加图形验证码
java·后端·spring
wangqiaowq2 小时前
CV 后端打包
人工智能
AKAMAI2 小时前
Akamai Valkey 托管数据库:企业 AI 的实时内存解决方案
人工智能·云计算
染指11102 小时前
118.Agent-LangChain核心组件-StructuredOutPut结构化输出-工具策略(ToolStrategy )
人工智能·langchain·agent·agents
kaixin_啊啊2 小时前
中国研究生数学建模竞赛(华为杯)学习笔记——AI绘图方法
人工智能·笔记·学习·数学建模