StackTraces
Spring Boot已驗證

Spring Boot 同類調用 self-invocation 致註解失效

@debug_master32 天前更新閱讀 6 min00

一句話先說結論:@Async 和 @Transactional 都靠 Spring AOP 代理織入。外部通過代理物件調用時註解生效,但同一個類裡用 this.方法() 內部調用時,走的是原始物件、繞過了代理,註解形同虛設——異步變同步、事務不回滾,而且預設沒有任何報錯提示。

背景

Spring 的聲明式能力(@Async、@Transactional、@Cacheable 這些)都不是“加了註解就魔法生效”,而是通過 AOP 動態代理實現的:容器在你拿到的 Bean 外面包一層代理,外部調用先進代理、被攔截器(AsyncExecutionInterceptor / TransactionInterceptor)接管,再進到真正的業務方法。

這個設計意味著:只有“從代理物件進來的調用”才會走攔截器。正常情況下你注入到 Controller 裡的 Service 就是代理,一切正常;真正出問題的是“同一個類內部的互相調用”。

現象

現象 1:@Async 加了,接口反而更慢

一個 CSV 導出接口耗時 3-5 秒,把導出邏輯抽到一個標了 @Async 的方法裡,指望請求執行緒立刻返回。結果發佈後 P99 不降反升:

发布前: avg 45ms  p99 200ms
发布后: avg 90ms  p99 400ms

排查時看執行緒池監控,發現詭異之處——異步執行緒池根本沒被使用:

{ "activeCount": 0, "queueSize": 0, "completedTaskCount": 0, "poolSize": 0 }

執行緒池零活躍、零排隊,說明 @Async 方法壓根沒提交到執行緒池,是在請求執行緒上同步跑完了那 3-5 秒,把 Tomcat 執行緒佔住、請求排隊,接口整體反而更慢。

執行緒 dump 可以直接驗證——@Async 方法執行時,執行緒名仍然是 Tomcat 工作執行緒而非異步執行緒池執行緒:

"http-nio-8080-exec-1" #30 daemon prio=5 os_prio=0 tid=0x00007f8a3c123400 nid=0x2345 runnable [0x00007f89e4ff8000]
   java.lang.Thread.State: RUNNABLE
    at com.example.OrderService.exportAsync(OrderService.java:67)
    at com.example.OrderService.batchExport(OrderService.java:42)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1071)

注意第一行執行緒名是 http-nio-8080-exec-1,說明 exportAsync() 在 Tomcat 執行緒上同步執行,根本沒經過 ThreadPoolTaskExecutor。

現象 2:@Transactional 加了,回滾沒發生

運營批量導入 48 條數據,batchCreate 方法標了 @Transactional。結果第 31 條唯一鍵衝突後,前 30 條已經寫進庫裡、沒有回滾。日誌裡看到的是:

第 1-30 次循环: insert 各自独立提交
第 31 次循环: DuplicateKeyException
// 关键线索:整个调用链上没有任何 TransactionInterceptor 的事务日志

不是“事務回滾失敗”,而是事務壓根沒創建——batchCreate 內部是 this.createUser(user) 調的,this 指向原始物件,攔截器沒機會執行。

打開 logging.level.org.springframework.transaction=DEBUG 後,正常事務調用會看到 Creating new transaction 日誌,但自調用場景下整條調用鏈上一句都沒有:

2026-08-22 14:01:03.102 ERROR 12345 --- [nio-8080-exec-1] c.e.service.UserService         : Duplicate entry '[email protected]' for key 'uk_email'
org.springframework.dao.DuplicateKeyException: ...Duplicate entry '[email protected]' for key 'uk_email'
    at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:263)
    at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:73)
    at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1887)
    at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:632)
    at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:882)
    at org.springframework.data.jdbc.core.convert.JdbcTemplateExtractor.extract(JdbcTemplateExtractor.java:58)
    at org.springframework.data.jdbc.core.convert.DefaultDataAccessStrategy$6.execute(DefaultDataAccessStrategy.java:357)
    at org.springframework.data.jdbc.core.AnonymousAggregateChange.lambda$applySingleChange$0(AnonymousAggregateChange.java:96)

根因分析

問題出在“誰來調這個方法”。以 @Async 為例,把調用鏈攤開:

调用方
  → proxy.batchExport()            // 走代理,但 batchExport 没 @Async,拦截链为空
  → methodProxy.invoke(target)     // CGLIB 直接调到原始对象
  → 目标对象.batchExport() 内部 this.exportAsync()   // this = 原始对象,不是代理
  → 原始 exportAsync() 同步执行    // 拦截器没进来

核心是一條常識:Java 方法內部的 this 永遠指向當前實例本身。CGLIB 代理是“子類繼承目標類”,外部調用時進入的是增強後的 override 方法(裡面有攔截器邏輯);但目標方法體裡寫的 this.xxx(),this 就是那個原始實例,調用直接跳轉到原始實現,不會重新經過代理。JDK 動態代理同理——this 指向目標物件,而不是外面的 InvocationHandler。

所以這條規則同時適用於 @Async、@Transactional、@Cacheable 等所有基於 Spring AOP 代理的註解,本質是“運行時代理的邊界”。

解決方案

方案 B(推薦):拆到獨立 Bean,讓調用天然走代理

把被調用的方法挪到另一個 Service,通過依賴注入調用。這是最乾淨的解法,也順帶理順了職責劃分:

@Service
public class OrderService {
    private final AsyncEmailService asyncEmailService;

    public OrderService(AsyncEmailService asyncEmailService) {
        this.asyncEmailService = asyncEmailService;
    }

    public void process(Long orderId) {
        // 外部 Bean 调用,走代理,@Async 生效
        asyncEmailService.sendEmail(orderId);
    }
}

@Service
public class AsyncEmailService {
    @Async
    public void sendEmail(Long orderId) {
        System.out.println(Thread.currentThread().getName());
        // 耗时操作在线程池里跑
    }
}

方案 A(最快止血):注入自身代理

用 @Lazy 注入一個自己的代理引用,通過它來調,就能重新走攔截器:

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;   // 注入的是代理

    public void handle(Order order) {
        self.sendNotification(order.getId()); // 走代理,@Async 生效
    }

    @Async
    public void sendNotification(Long orderId) { /* ... */ }
}

用 @Lazy 是為了避免注入自身造成構造階段的循環依賴。

方案 C:AopContext.currentProxy()(需開啟 exposeProxy)

@EnableAspectJAutoProxy(exposeProxy = true)  // 开启才能用

((OrderService) AopContext.currentProxy()).sendNotification(orderId);

思路和“注入 self”一樣,只是換成從 ThreadLocal 裡取當前代理,不用額外注入字段。代價是要開 exposeProxy 且寫起來囉嗦。

方案 D:編程式事務 / 顯式提交執行緒池

對事務可以用 TransactionTemplate 手工圈定邊界;對異步可以直接 taskExecutor.submit(...)。兩者都跳過了註解代理,行為完全可控,但失去了聲明式的簡潔。

排查小抄

  • 打印 Thread.currentThread().getName():如果 @Async 方法裡打出來的還是 http-nio-8080-exec-*,說明沒走執行緒池。
  • 開調試日誌 logging.level.org.springframework.transaction=DEBUG:正常情況下能看到 Creating new transaction 或 Participating in existing transaction;自調用時這些日誌一句都沒有。
  • 記錄一下“調用方式是 this.xxx() 還是注入進來的 Bean 調的”——這是這類問題裡最常被忽略的一步。

小結

  • 根因就一條:@Async/@Transactional 靠代理生效,this 自調用繞過代理。
  • 排查先看調用方式,再看註解位置、方法可見性、例外類型這些次要因素。
  • 修復優先拆 Bean,其次注入 self 代理,臨時兜底再用 currentProxy() 或編程式寫法。

References(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法