代理模式下只有“穿過代理的外部方法調用”會被攔截。private 方法既不在接口裡,也無法被 CGLIB 子類覆蓋,代理根本攔不到它——
@Transactional於是變成一段沒人消費的元數據:編譯通過、啟動正常、運行無事務,全程零報錯。
Issue & Env(案發現場與環境)
運行環境
- Spring Boot 2.x / 3.x,Spring Framework 5.x / 6.x
- 預設代理模式:
spring.aop.proxy-target-class=true(CGLIB 目標類代理) - 事務管理:
@Transactional聲明式事務
業務場景
為了讓 public 方法保持“只做參數校驗和編排”的整潔結構,開發者把真正的寫庫邏輯抽成 private 方法,順手把 @Transactional 標在它上面,以為事務邊界跟著方法走。實際結果是:public 入口照常執行,private 方法裡的插入也照常成功,但沒有任何事務被開啟。
為什麼容易踩
- 註解本身合法:
@Transactional可以標在任意方法上,編譯器不攔。 - 啟動不報錯:代理照樣建得起來,不會像
final類那樣拋AopConfigException。 - 現象和“自調用失效”完全一致:都是 DEBUG 日誌裡沒有
Creating new transaction,容易被歸到同一個原因上。
StackTrace(堆疊日誌)
本故障沒有例外堆疊,唯一的證據是“事務日誌缺失”。打開事務攔截器的 DEBUG 級別:
logging.level.org.springframework.transaction.interceptor=DEBUG
正常生效的事務方法,無論成功還是失敗,都會留下進出痕跡:
2026-09-27 10:12:03 DEBUG o.s.t.i.TransactionInterceptor : Getting transaction for [OrderService.createOrder]
2026-09-27 10:12:03 DEBUG o.s.t.i.TransactionInterceptor : Completing transaction for [OrderService.createOrder]
而 private 方法上標註解時,整條調用鏈一句都沒有:
2026-09-27 10:12:03 INFO c.e.OrderService : 开始创建订单
2026-09-27 10:12:03 DEBUG c.e.OrderMapper : ==> Preparing: INSERT INTO orders ...
2026-09-27 10:12:03 INFO c.e.OrderService : 订单创建完成
// 没有 Getting transaction,也没有 Completing transaction
關鍵判據:有 SQL 執行、有正常返回,就是沒有事務攔截器的進出日誌。這說明方法被調用了,但沒經過代理。
Reproduce(復現程式碼)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public void createOrder(Order order) {
// 外部调用进来的是代理,但这一步是普通 Java 方法调用,没有二次穿越代理
doCreate(order);
}
// 复现点:private + @Transactional,注解不会被任何代理消费
@Transactional
private void doCreate(Order order) {
orderMapper.insert(order);
throw new RuntimeException("模拟后续失败");
}
}
調用 createOrder 後:例外如期拋出,但 orders 表裡依然留下了已提交的記錄——因為從頭到尾就沒有事務。
Root Cause(根因分析)
1. 代理模式只認“穿過代理的外部調用”
Spring 官方文檔在代理模式一節裡的表述是:只有外部方法調用經過代理時才會被攔截。這句話有兩個約束:
拦截发生的位置:proxy.method() → MethodInterceptor → target.method()
不被拦截的情况:
1. 同类内部调用(this.method())—— 绕过了代理,见另一个分支
2. 代理无法覆盖的方法 —— 可见性不满足,就是本文的情况
2. private 方法為什麼一定攔不到
兩種代理機制的覆蓋範圍決定了這條硬邊界:
CGLIB(目标类代理,Boot 默认):
原理是继承目标类、override 方法插入拦截器。
private 方法不是虚方法,子类无法 override —— 没有插入点。
JDK 动态代理(接口代理):
原理是实现接口、在 InvocationHandler 里转发。
private 方法既不在接口里,也无法被转发 —— 没有入口。
兩者都失效,因此 @Transactional 標在 private 方法上是純粹的無操作。
3. 一個必須澄清的版本差異
網上大量資料說“非 public 方法一律不生效”,這個說法對 Spring 6.0 以前的版本成立,現在已經不準確:
| 方法可見性 | CGLIB 目標類代理 | JDK 接口代理 |
|---|---|---|
public |
生效 | 生效(且必須在接口中聲明) |
protected / 包級 |
Spring 6.0 起生效 | 不生效(接口代理要求 public) |
private |
不生效 | 不生效 |
也就是說,Spring 6.0 起 protected 和包級方法在 CGLIB 下已經可以織入事務;真正“一定攔不到”的只有 private。若團隊希望代理行為在不同代理類型下保持一致(回到 5.3 之前的語義),可以顯式開啟 publicMethodsOnly:
@Bean
TransactionAttributeSource transactionAttributeSource() {
return new AnnotationTransactionAttributeSource(true);
}
4. 快速定位手段
# 列出所有标注了 @Transactional 但可见性非 public 的方法
grep -rn -B3 "@Transactional" src/main/java --include="*Service*.java" | grep -E "private|protected"
The Fix(解決方案)
方案 A(推薦):把事務邊界上移到 public 方法
讓 public 方法承擔事務邊界,private 方法退回純邏輯。這也符合“事務邊界應當是明確的業務入口”的建模習慣:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
validate(order);
doCreate(order); // private,纯逻辑,不承载事务
}
private void doCreate(Order order) {
orderMapper.insert(order);
}
}
方案 B:private 邏輯確實需要獨立事務時,抽到獨立 Bean
如果這段邏輯需要獨立事務(例如失敗也不回滾、或走獨立提交),把它挪到另一個 Bean 的 public 方法上,用傳播行為表達語義,而不是靠可見性硬湊:
@Service
public class OrderTxService {
@Autowired
private OrderMapper orderMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createInNewTransaction(Order order) {
orderMapper.insert(order);
}
}
注意 REQUIRES_NEW 的語義是獨立提交,外層回滾救不了它,用之前先確認業務上確實允許單獨留存。
方案 C:確實需要織入非 public 方法時,切 AspectJ 模式
代理模式下可見性邊界無法突破,但 AspectJ 編織模式不依賴代理——它直接改寫字節碼,可以覆蓋任意可見性的方法,也能順帶解決自調用問題。代價是要引入 aspectjweaver 並設定編譯期或加載期織入,複雜度明顯上升:
@Configuration
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class TransactionConfig {
}
絕大多數業務項目不必走到這一步,方案 A 已經足夠。
總結與避坑要點
- private 方法上的
@Transactional不報錯也不生效,是代理機制決定的硬邊界,不是設定問題。 protected/ 包級方法在 Spring 6.0 起的 CGLIB 代理下可以生效,別再照搬“非 public 一律失效”的舊結論。- 事務邊界優先放在 public 的業務入口方法上,private 方法只保留純邏輯。
- 排查時認準一個信號:SQL 執行了、方法返回了、但事務攔截器沒有進出日誌,就該去查可見性與自調用這兩條代理邊界。做得更細一點,把
grep非 public 註解方法的檢查加進 Code Review 清單,比出事後再查便宜得多。
