动态

详情 返回 返回

@Autowired 的Bug讓我們白忙三天 - 动态 详情

凌晨兩點,支付服務的告警像雪崩一樣砸來,你在控制枱和棧跟蹤間瘋狂穿梭,卻始終想不明白:Spring 的依賴注入,怎麼會在生產裏突然“失手”?我最近讀到一篇事故覆盤,講的是兩個看似無害的改動如何在生產環境聯手把系統擊穿,分析深入、啓發很大。於是我把它完整翻譯出來,分享給大家,希望能幫你少走彎路。

以下內容翻譯自:https://medium.com/javarevisited/the-autowired-bug-that-cost-...

兩個“看似無害”的 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(單例):

  1. Spring 在啓動時創建一個實例
  2. 構造器只執行一次
  3. 依賴只注入一次
  4. 該實例服務所有請求

原型範圍(prototype)的 Bean:

  1. 每次獲取都會創建一個新實例
  2. 構造器每次都會執行
  3. 依賴也應該每次都被注入

但關鍵點在這裏:

當其他服務(比如我們的 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")。內存使用保持穩定。問題解決。

真正的教訓

出了什麼問題:

  1. 我們在不理解的情況下信任了註解。@Scope("prototype") 看起來無害,其實會改變 Spring 的整個對象生命週期。
  2. 我們把 PR 當作孤立改動來評審。Dave 的重構看起來沒問題;Scope 的改動也看起來沒問題;放到一起就是災難。
  3. 我們的測試沒覆蓋到所有路徑。集成測試只跑了控制器路徑(它是正常的),沒人測定時任務路徑。
  4. 我們以為字段注入和構造器注入是等價的。不是。構造器注入 + prototype 範圍 + 字段訪問 = 空引用。

我們之後的改進

新的團隊規則:

  • ✅ 未經明確審批,不得使用 prototype 範圍。需要時必須在 wiki 裏寫明理由和使用方式。
  • ✅ 只用構造器注入(一個例外)。字段注入被禁用。唯一的例外是環狀依賴(然後我們會盡快重構掉它)。
  • ✅ 集成測試必須覆蓋所有代碼路徑。不只“快樂路徑”。要測定時任務、邊界場景。
  • ✅ 代碼評審要檢查交互效應。合併前要看近期有哪些 PR 動過同一服務。
  • ✅ 內存“泄漏”必須用性能分析工具證明。沒有 YourKit 或 VisualVM 的證據,就不能叫“泄漏”。

不那麼舒適的真相

Spring 讓依賴注入看起來很有“魔法”。90% 的時候,它確實很好用。

但“魔法”也有邊角。原型 Bean 與代理、構造器注入中的環狀依賴、@Transactional 的內部方法調用等等。

文檔都寫了。這些坑也都被記錄了。但沒人會在寫一個 @Service 類之前,把 500 頁的 Spring 參考手冊通讀一遍。

我們都是在翻車中學習。這次我們在凌晨兩點炸了生產,花了三天時間才搞清楚一個註解背後的交互效應。

Dave 的重構並沒有錯。prototype 範圍也不是絕對錯誤。錯的是它們的組合——只在生產、只在某些代碼路徑、只在兩個 PR 都合併之後才顯現的問題。

這就是“魔法”框架的真實代價:順的時候很妙;不順的時候,你需要一個 Spring 內部機制博士學位。

user avatar jianxiangjie3rkv9 头像 souyunku 头像 zzzzbw 头像 nianqingyouweidenangua 头像 dengjijie 头像 shenchendexiaoyanyao 头像 lpc63szb 头像 dreamlu 头像 beishangdeyadan 头像 jacklv 头像 sevencode 头像 dadehouzi 头像
点赞 26 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.