凌晨两点,支付服务的告警像雪崩一样砸来,你在控制台和栈跟踪间疯狂穿梭,却始终想不明白: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 内部机制博士学位。