You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ControllerAdvice无法捕获CompletionException异常的原因与解决方法

问题描述

在开展异步处理相关测试时,发现如下异常现象:

全局异常处理器代码:

@Slf4j
@ControllerAdvice
public class ErrorAdvice {

    @ExceptionHandler(Exception.class)
    public void ex(Exception e) {
        log.info("error e = ", e);
    }

    @ExceptionHandler(CompletionException.class)
    public void ex2(CompletionException e) {
        log.info("error e = ", e);
    }
}

业务Service代码:

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public void save(String name) {
        memberRepository.save(new Member(name));
        throw new IllegalStateException();
    }
}

调用方代码:

public class Client {
    @Transactional
    public void save(String name) {
        CompletableFuture.runAsync(() -> memberService.save(name));
    }
}

已知Client的save方法开启事务后,通过CompletableFuture启动独立新线程,会绑定全新的事务作用域。但存在以下问题:

  • 代码抛出的异常无法被ControllerAdvice捕获
  • 异常不仅未被捕获,甚至没有输出任何日志,被直接忽略

原认知偏差:认为ControllerAdvice通过代理机制包装Controller,即使MemberService执行异步处理,异常逻辑处于代理范围内理应被处理,但实际运行结果和认知不符。


问题原因

核心问题是跨线程执行导致Spring MVC的异常拦截链路断裂,加上异步异常未被主动处理被静默吞掉,和Controller代理、事务配置没有直接关系。具体原因:

  1. @ControllerAdvice作用边界有限
    它是Spring MVC层的组件,仅能拦截HTTP请求处理线程内、Controller调用链上抛出的未捕获异常,完全无法感知其他独立工作线程中抛出的异常。
  2. 异步任务与原请求线程完全隔离
    调用CompletableFuture.runAsync()提交任务后,原请求线程会立即向下执行,Client.save()方法会快速返回,Spring MVC会判定当前请求已处理完成,开始向客户端返回响应,不会等待异步任务执行结束。异步线程内抛出的IllegalStateException仅存在于异步线程的调用栈中,既不会传递回原请求线程,也不会进入Spring MVC的异常处理链路。
  3. 未处理的异步异常被默认吞掉
    如果既不调用get()/join()获取CompletableFuture的执行结果,也不通过exceptionally()/handle()方法声明异常处理逻辑,任务中抛出的异常会被CompletableFuture内部静默吞掉,不会打印任何日志,这就是看不到任何异常输出的根本原因。

另外纠正认知错误:Spring AOP代理(包括Controller的增强代理、@Transactional的事务代理)都是基于线程内方法调用拦截实现的,一旦启动新线程,新线程的执行流会完全脱离原代理的拦截范围,代理既感知不到新线程的执行逻辑,也拿不到新线程抛出的异常。Client.save()上添加的@Transactional不会作用于异步线程内的数据库操作,异步线程会获取独立的数据库连接,使用完全隔离的事务上下文。


修复方案

根据业务场景选择对应方案即可:

  • 场景1:需要等待异步任务执行完成再返回响应
    不要提交任务后直接返回,要主动等待任务执行结果,把异步线程的异常重新抛回请求线程,这样就能被@ControllerAdvice正常捕获:
    @Transactional
    public void save(String name) {
        try {
            CompletableFuture.runAsync(() -> memberService.save(name))
                    // 阻塞等待任务执行完成,异常会在get()时抛出
                    .get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("异步任务被中断", e);
        } catch (ExecutionException e) {
            // 取出异步任务的真实异常抛给上层
            throw new RuntimeException("异步任务执行失败", e.getCause());
        }
    }
    
  • 场景2:异步任务是后台执行逻辑,不需要等结果返回给前端
    不要指望@ControllerAdvice处理这类异常,必须在异步任务内部自行完成异常捕获、日志记录、告警、补偿逻辑,绝对不能写无异常处理的"发后即忘"异步任务:
    public void save(String name) {
        CompletableFuture.runAsync(() -> {
            try {
                memberService.save(name);
            } catch (Exception e) {
                // 自行处理所有异常:打错误日志、触发告警、执行回滚/重试/死信补偿等
                log.error("异步保存会员失败,入参name:{}", name, e);
            }
        });
    }
    
  • 场景3:要实现Servlet 3.0规范的异步请求处理
    不要在Service层自己开线程,直接在Controller层返回CompletableFuture作为接口返回值,Spring MVC会自动管理异步请求的生命周期,持有连接等待异步任务执行完成,此时异步抛出的异常可以正常被@ControllerAdvice拦截:
    @PostMapping("/member/save")
    public CompletableFuture<Void> save(@RequestParam String name) {
        return CompletableFuture.runAsync(() -> memberService.save(name));
    }
    

注意事项
  • 所有异步执行的逻辑都必须显式处理异常,普通线程池、CompletableFuture默认都会吞掉未捕获的异常,线上出现故障时会没有任何痕迹,排查成本极高
  • Spring中默认绑定线程的上下文(事务、Security上下文、MVC请求上下文等)都不会自动传递到子线程,如果需要跨线程传递上下文要手动配置,不要想当然认为注解逻辑会跨线程生效

内容的提问来源于stack exchange,提问作者user19011807

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.03 07:06:48