一个 Spring 项目里有四百多个 Bean,启动要四十秒。某个模块加了 @Lazy 试图加快启动,结果运行时偶发拿到未初始化的代理,错误出现在很远的地方。最后发现问题不在 @Lazy,而在于这个模块根本不需要进入容器——它是一个纯计算模块,被依赖注入的便利裹挟着进了容器。
容器解决的是"对象之间怎么连起来",它不解决"这个对象该不该被谁拥有"。放进容器的成本很少被计算:启动时间、隐式依赖、循环依赖的可能、以及装配关系从代码里消失。
1. 容器的收益与成本
| 收益 | 成本 |
|---|---|
| 无需手工传递依赖 | 装配关系不在调用处可见 |
| 生命周期统一管理 | 启动时集中创建,无法按需 |
| 横切能力(事务、代理)易加 | 代理带来隐式行为与栈变化 |
| 配置与实现分离 | 依赖来源分散在注解、配置、条件里 |
第二行最容易被忽略:容器的创建是集中的。这意味着任何一个 Bean 初始化失败都会拖垮整个上下文,而手工装配时只有真正用到它的路径才会失败。
第四行是长期成本:一个 Bean 的最终实现可能是注解、配置开关、条件装配三者共同决定的,读代码无法确定"运行时到底是哪个"。这类不确定性在排错时最耗时。
2. 五类不必进容器的对象
| 类型 | 为什么 | 该怎么做 |
|---|---|---|
| 纯函数/无状态计算 | 无依赖、无生命周期 | 静态方法或普通 new |
| 值对象(DTO、参数) | 每次不同 | 直接构造 |
| 短生命周期临时对象 | 创建成本低 | 就地 new |
| 工具类(格式化、转换) | 无状态 | 静态方法 |
| 只在单处使用的辅助类 | 无需替换 | 直接 new |
第五项在重构期最容易被破坏:为了减少 new,把临时对象也交给容器。这类对象通常是"每次调用都不同"的(比如一次请求的上下文),交给容器意味着要么用原型作用域,要么变成共享状态——后者是并发问题的来源。
第一项最典型:一个只有入参和返回值的计算类,进入容器后,它的使用者反而多了一个依赖注入点,测试时还要构造容器或 mock 注入。
// 不必进容器:纯计算,无状态,无依赖
public final class FeeCalculator {
public static BigDecimal compute(BigDecimal amount, Rate rate) { ... }
}
// 进入容器后反而更麻烦:要么 mock 注入,要么起容器
@Service
public class FeeCalculator {
public BigDecimal compute(...) { ... }
}
3. 五类应当进容器的对象
| 类型 | 为什么 |
|---|---|
| 有外部资源(连接、客户端) | 需要统一关闭与复用 |
| 需要横切能力(事务、缓存、审计) | 由代理实现 |
| 有配置差异的实现 | 需要按环境切换 |
| 需要单例共享状态 | 需要统一实例 |
| 需要被多处注入的横切组件 | 避免重复构造 |
第二项是容器不可替代的能力:声明式事务、方法级缓存、审计日志都依赖代理。手工装配时这些能力要么自己写,要么放弃。
可以用一行检查发现这类问题:某个类型在代码里被 new 了几次。资源型对象的 new 次数应当只有一处。
// 找出被 new 多次的资源型对象(按需替换类名)
grep -rn "new HttpClient\|new JedisPool\|new HikariDataSource" src/ | wc -l
// 结果 > 1 → 资源共享与关闭都会出问题,应改为容器单例
第一项值得展开:连接池、HTTP 客户端这类对象,如果每处 new 一个,资源会被悄悄放大——十个地方 new 十次,连接数就乘十。它们必须单例并由容器统一关闭。
4. 装配关系必须能被读出来
| 做法 | 可读性 |
|---|---|
| 构造器注入 + final 字段 | 高:依赖一目了然 |
字段注入(@Autowired 字段) |
低:看不出一共有多少依赖 |
配置类集中装配(@Bean) |
高:装配关系集中在一处 |
| 条件装配 + 配置开关 | 中:需要查配置才知道实际实现 |
可以用一个很直接的数字判断:构造器参数个数。超过五个通常说明这个类承担了太多职责;而字段注入让这个数字隐藏起来,问题就不会在评审时暴露。构造器注入把"依赖太多"变成肉眼可见。
第一种与第三种是可维护的形态。字段注入的问题不只是"不推荐",而是它让一个类有多少依赖变得不可数——三十个字段注入的类,在代码里看起来是三十个字段,而不是三十个依赖。
// 构造器注入:依赖在签名上,测试不需要容器
public class OrderService {
private final OrderRepo repo;
private final FeeCalculator fee;
public OrderService(OrderRepo repo, FeeCalculator fee) {
this.repo = repo;
this.fee = fee;
}
}
// 测试直接 new,不需要 Spring 上下文
var svc = new OrderService(new FakeRepo(), new FeeCalculator());
5. 启动时间与依赖图
| 现象 | 原因 | 处理方向 |
|---|---|---|
| 启动慢 | Bean 数量多或初始化重 | 拆分上下文、延迟初始化 |
| 循环依赖 | 两个 Bean 互相注入 | 引入第三方或改设计 |
| 偶发拿到代理对象 | @Lazy 或 AOP 未初始化 |
去懒加载,改按需创建 |
| 某 Bean 初始化失败全站不可用 | 集中于同一上下文 | 隔离到独立模块 |
第三项是"为了快"引入 @Lazy 的典型代价:懒加载把错误从启动期推到了运行期。启动失败的代价是明确的(发布不通过),运行期失败的代价是隐性的(偶发、难复现)。
// 两种初始化的取舍
启动期失败:发布卡住、错误明确、可回滚 ← 更可控
运行期失败:偶发、依赖流量、定位困难 ← 更难处理
// 结论:只有在"初始化确实很重且不影响主路径"时才用懒加载
装配关系还需要能被检查。一个可用的做法是把"谁注入了谁"打印出来,与预期的分层对照:
// 启动时输出依赖图摘要,用于人工核对分层是否被打破
web → service (12) 期望 12,实际 12
web → repository (3) 期望 0,实际 3 ← 分层被绕过
service → repository (18)
// 只需一行统计,就能发现"为了省事直接注入下层"的扩散趋势
6. 排错:五种"容器带来的怪问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改实现后行为没变 | 条件装配选中了另一个 Bean | 打印实际选中的实现 |
| 事务不生效 | 方法被同类内部调用,绕过代理 | 拆类或改调用方式 |
| 缓存不生效 | 同上,代理未被经过 | 同上 |
| 单例里出现并发问题 | 有状态 Bean 被当单例 | 改为无状态或改作用域 |
| 启动顺序问题 | 依赖初始化顺序不确定 | 用显式依赖或事件 |
验证方法很简单:在方法上加日志,看它是否被调用。事务方法内部调用时,外部调用的日志会打,内部调用的不会——因为内部那次没有经过代理。
前两项是同一根因的两个表现:代理只在"从容器取出并调用"时生效。同一个类内部方法互相调用,等于绕过了代理,事务与缓存都不会生效。这个坑在重构时最容易踩——把一个类的方法拆分后忘记调整调用路径。
7. 边界:什么情况不必讲究
| 场景 | 为什么 | 做法 |
|---|---|---|
| 极小应用(几个类) | 手工装配更快 | 直接在 main 里 new |
| 一次性脚本 | 无生命周期管理需求 | 不用容器 |
| 函数式计算密集模块 | 无依赖、无资源 | 静态方法 |
| 已用其他装配方式(如 Go 的手工注入) | 该语言没有容器文化 | 保持手工装配 |
与分层有关的边界值得单独记:容器不该成为跨层调用的通道。如果允许 web 层直接注入数据层,那么分层只是目录结构,不是约束。约束只能靠架构测试(如依赖方向检查)来守,容器本身不提供。
判断口径一句话:这个对象需不需要"统一创建、统一关闭、统一加强"。三者都不需要,容器只是多一层间接。
8. 自测
- 容器解决的是什么问题?它不解决什么问题?
- 为什么"创建集中"既是收益也是成本?举出集中创建带来的具体失败形态。
- 五类不必进容器的对象中,哪一类最常被错误注册?错误注册后测试成本如何变化?
- 为什么连接池、HTTP 客户端必须单例并由容器关闭?
@Lazy把哪类成本从启动期转移到了运行期?什么情况下才值得这么做?- "事务不生效"与"容器带来的怪问题"的共同根因是什么?重构时为什么容易踩到?