一句話先說結論:
@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()或編程式寫法。
