@Autowired 的Bug讓我們白忙三天
凌晨兩點,支付服務的告警像雪崩一樣砸來,你在控制臺和棧跟蹤間瘋狂穿梭,卻始終想不明白:Spring 的依賴注入,怎么會在生產里突然“失手”?我最近讀到一篇事故復盤,講的是兩個看似無害的改動如何在生產環境聯手把系統擊穿,分析深入、啟發很大。于是我把它完整翻譯出來,分享給大家,希望能幫你少走彎路。
以下內容翻譯自:https://medium.com/javarevisited/the-autowired-bug-that-cost-us-3-days-7d24a1e31435
兩個“看似無害”的 PR 如何在凌晨 2 點聯手擊碎生產環境的依賴注入。
我們的首席架構師在一個構造器上加了 @Autowired,應用能編譯。測試通過。代碼評審也過了。
然后凌晨兩點,生產炸了,NullPointerException 到處都是。
故事從一次“簡單”的重構開始
如果你曾在周五合并過一個“很安全”的重構,你大概知道故事怎么發展。
我們在支付服務里清理技術債。沒啥花哨的——把一個巨型類拆成更小、更可測的組件而已。
@Service
public class PaymentProcessor {
@Autowired
private PaymentGateway gateway;
@Autowired
private FraudDetector fraudDetector;
@Autowired
private NotificationService notificationService;
// 847 行業務邏輯...
}
我們的架構師,就叫他 Dave,決定改用構造器注入。“最佳實踐”,他說。干凈、不可變、易測試。
聽起來很合理,對吧?
@Service
public class PaymentProcessor {
private final PaymentGateway gateway;
private final FraudDetector fraudDetector;
private final NotificationService notificationService;
@Autowired
public PaymentProcessor(
PaymentGateway gateway,
FraudDetector fraudDetector,
NotificationService notificationService
) {
this.gateway = gateway;
this.fraudDetector = fraudDetector;
this.notificationService = notificationService;
}
// 業務邏輯...
}
完美。final 字段、構造器注入,跟每篇 Spring Boot 教程教的一樣。
周四發版。周五風平浪靜。周末也很安穩。
周一清晨,地獄之門打開。
凌晨兩點:一切開始崩壞
我們 Slack 的 #incidents 頻道像圣誕樹一樣亮了起來。
PagerDuty: ?? CRITICAL: Payment Service - Error rate 34%
DataDog: Payment processing failures spiking
AWS CloudWatch: 500 errors on /api/payments/process
我是當周值班工程師。真“幸運”。
日志像噩夢:
java.lang.NullPointerException: Cannot invoke "FraudDetector.check()" because "this.fraudDetector" is null
at PaymentProcessor.processPayment(PaymentProcessor.java:67)
at PaymentController.createPayment(PaymentController.java:45)
等等,啥?
fraudDetector 是 null?可它是 @Autowired 的依賴啊。Spring 不應該給它注入嗎?這不就是 Spring 的工作嘛。
我檢查了 Bean 配置。FraudDetector 的 Bean 存在、已注冊,其他服務用它也都沒問題。
我重啟了服務。還是一樣的錯誤。
我看了下構造器。@Autowired 注解也在。
這到底怎么回事?
事情并不隨機
然后事情開始變得詭異。
有些支付成功。大多數失敗。
但并非隨機。它有規律:
- 小額支付(<$100):成功
- 大額支付(>$100):拋
NullPointerException
這完全說不通。支付金額不應該影響依賴注入。這不是 Spring 的工作方式。
我到處加了調試日志:
@Autowired
public PaymentProcessor(
PaymentGateway gateway,
FraudDetector fraudDetector,
NotificationService notificationService
) {
System.out.println("Constructor called!");
System.out.println("Gateway: " + gateway);
System.out.println("FraudDetector: " + fraudDetector);
System.out.println("NotificationService: " + notificationService);
this.gateway = gateway;
this.fraudDetector = fraudDetector;
this.notificationService = notificationService;
}
日志顯示構造器在啟動時只被調用了一次。所有依賴都注入正確。
那運行期為什么 fraudDetector 會是 null?
周二早晨:Git Blame 揭曉謎底
周二早上,Dave 來了。我給他看日志。他很困惑。
“不可能,”他說,“構造器注入可以保證不可變。”
我打開 git diff。他的重構 PR——改了 47 個文件。
然后我看到了——埋在 diff 中間的那一行。
@Service
@Scope("prototype") // 就是這一行
public class PaymentProcessor {
// ...
}
有人給這個類加了 @Scope("prototype")。
不是 Dave。是上周另一個 PR。一個“性能優化”——一位初級同事以為每次創建新實例可以防止內存泄漏。
兩個 PR 分別合并。沒有沖突。評審都通過。各自看也都合理。
但放在一起?災難。
技術拆解:為什么會炸
來解釋一下 @Scope("prototype") 到底做了什么。
| 范圍(Scope) | 實例生命周期 | 常見陷阱 |
|---|---|---|
| Singleton | 啟動時創建一次 | 與構造器依賴注入組合安全 |
| Prototype | 每次請求創建一個 | 與字段訪問組合會出現問題 |
普通 Spring Bean(單例):
- Spring 在啟動時創建一個實例
- 構造器只執行一次
- 依賴只注入一次
- 該實例服務所有請求
原型范圍(prototype)的 Bean:
- 每次獲取都會創建一個新實例
- 構造器每次都會執行
- 依賴也應該每次都被注入
但關鍵點在這里:
當其他服務(比如我們的 PaymentController)通過 @Autowired 注入 PaymentProcessor 時,Spring 注入的不是每次都創建的新實例,而是一個代理(proxy)。
這個代理會在“方法調用”時創建新實例,而不是在“字段訪問”時創建。
所以這段代碼:
@RestController
public class PaymentController {
@Autowired
private PaymentProcessor processor;
public void handlePayment(Payment payment) {
processor.processPayment(payment); // 在這里觸發創建新實例
}
}
工作正常。方法調用會觸發實例創建。
但我們還有第二條代碼路徑:
@Component
public class ScheduledPaymentJob {
@Autowired
private PaymentProcessor processor;
@Scheduled(fixedRate = 60000)
public void processScheduledPayments() {
List<Payment> pending = getPendingPayments();
for (Payment p : pending) {
// 直接字段訪問!
if (processor.fraudDetector.isHighRisk(p)) {
processor.processManually(p);
} else {
processor.processPayment(p);
}
}
}
}
看出問題了嗎?
processor.fraudDetector 是直接字段訪問,不是方法調用。代理不會攔截它。不會創建新實例。字段自然就是 null。
小額支付走控制器路徑(方法調用 = 正常)。
大額支付觸發了定時任務里的人工復核路徑(字段訪問 = NullPointerException)。
這就是為什么它不是隨機的。它非常“合情合理”,只不過很隱蔽。
周三:并不簡單的“修復”
第一反應:移除 @Scope("prototype"),改回單例。
問題:初級同事加它是有理由的。我們之前看到過“內存泄漏”。移除它可能把舊問題帶回來。
第二個想法:保留 prototype,但修代理行為。
問題:你無法用 prototype Bean 修復代理的攔截行為。這是 Spring 的基本限制。
第三個想法:原型 Bean 不用 @Autowired,改成手動從 ApplicationContext.getBean() 獲取。
@Component
public class ScheduledPaymentJob {
@Autowired
private ApplicationContext context;
@Scheduled(fixedRate = 60000)
public void processScheduledPayments() {
List<Payment> pending = getPendingPayments();
for (Payment p : pending) {
// 每次獲取一個全新實例
PaymentProcessor processor = context.getBean(PaymentProcessor.class);
if (processor.getFraudDetector().isHighRisk(p)) {
processor.processManually(p);
} else {
processor.processPayment(p);
}
}
}
}
問題:這太丑了。手動查找 Bean 違背了依賴注入的初衷。
第四個想法(最終有效的那個):
停止使用 prototype 范圍。去修真正的內存問題。
結果發現,“內存泄漏”只是誤解。初級同事看到堆內存使用上升,就以為是泄漏。其實不是,是 G1GC 下 JVM 的正常行為。
我們移除了 @Scope("prototype")。內存使用保持穩定。問題解決。
真正的教訓
出了什么問題:
- 我們在不理解的情況下信任了注解。
@Scope("prototype")看起來無害,其實會改變 Spring 的整個對象生命周期。 - 我們把 PR 當作孤立改動來評審。Dave 的重構看起來沒問題;Scope 的改動也看起來沒問題;放到一起就是災難。
- 我們的測試沒覆蓋到所有路徑。集成測試只跑了控制器路徑(它是正常的),沒人測定時任務路徑。
- 我們以為字段注入和構造器注入是等價的。不是。構造器注入 + prototype 范圍 + 字段訪問 = 空引用。
我們之后的改進
新的團隊規則:
- ? 未經明確審批,不得使用 prototype 范圍。需要時必須在 wiki 里寫明理由和使用方式。
- ? 只用構造器注入(一個例外)。字段注入被禁用。唯一的例外是環狀依賴(然后我們會盡快重構掉它)。
- ? 集成測試必須覆蓋所有代碼路徑。不只“快樂路徑”。要測定時任務、邊界場景。
- ? 代碼評審要檢查交互效應。合并前要看近期有哪些 PR 動過同一服務。
- ? 內存“泄漏”必須用性能分析工具證明。沒有 YourKit 或 VisualVM 的證據,就不能叫“泄漏”。
不那么舒適的真相
Spring 讓依賴注入看起來很有“魔法”。90% 的時候,它確實很好用。
但“魔法”也有邊角。原型 Bean 與代理、構造器注入中的環狀依賴、@Transactional 的內部方法調用等等。
文檔都寫了。這些坑也都被記錄了。但沒人會在寫一個 @Service 類之前,把 500 頁的 Spring 參考手冊通讀一遍。
我們都是在翻車中學習。這次我們在凌晨兩點炸了生產,花了三天時間才搞清楚一個注解背后的交互效應。
Dave 的重構并沒有錯。prototype 范圍也不是絕對錯誤。錯的是它們的組合——只在生產、只在某些代碼路徑、只在兩個 PR 都合并之后才顯現的問題。
這就是“魔法”框架的真實代價:順的時候很妙;不順的時候,你需要一個 Spring 內部機制博士學位。

浙公網安備 33010602011771號