延迟加载(lazy)被当成性能优化默认项:字段加 fetch = LAZY、路由改成动态 import()、Bean 加 @Lazy。它确实能减少一次启动或一次请求里做的功,但它同时把"什么时候出错"从编译期/启动期推到了运行期的某个随机时刻。
这篇不讲"什么是懒加载",讲四个判断:它省的是谁的功、错在哪暴露、能不能观测、值不值得。
1. 四种形态,收益与代价各不相同
| 形态 | 典型实现 | 省掉的工作 | 代价 |
|---|---|---|---|
| 懒初始化 | if (x == null) x = compute() / @Lazy Bean |
首次访问前的构造开销 | 首次访问变慢;并发下可能重复初始化 |
| 懒代理 | Hibernate LAZY 关联、@Lazy 注入的代理 |
一次额外查询 | 代理未初始化就访问 → 异常;序列化踩坑 |
| 懒路由/组件 | 前端 import() 动态分包 |
首屏 JS 体积 | 切换页面时的白屏;需要 prefetch 兜底 |
| 懒查询 | 分页、游标、按需 select 字段 |
一次性拉全量数据 | 调用方要理解"还有下一页" |
四种形态的共同结构:把成本从 A 时刻挪到 B 时刻。所以第一个问题是"B 时刻能不能承受"——如果 B 是用户点击后的首屏,延迟加载就是把启动优化换成体验劣化。
2. 懒初始化:并发下的双重检查要写对
public class ExpensiveRegistry {
private volatile Map<String, Rule> rules; // 必须 volatile
public Map<String, Rule> rules() {
Map<String, Rule> r = rules;
if (r == null) {
synchronized (this) {
r = rules;
if (r == null) {
r = loadFromDisk();
rules = r;
}
}
}
return r;
}
}
适用:初始化成本高、且不保证每次都被用到(只有少数请求会走规则引擎)。坑:字段高频访问且初始化只要几毫秒——加锁的复杂度换来的收益是负的,饿汉式更清楚。
更省事的是 holder 类(private static class Holder { static final X V = load(); }),借类加载保证线程安全,不必写双重检查。
@Lazy 注入是另一回事:注入的是 CGLIB 代理,第一次调方法才真正 getBean。它适合"循环依赖"和"重资源 Bean",不适合"想加快启动"——启动耗时大头在类扫描与代理生成,@Lazy 只是把实例化推后。
3. ORM 的 N+1:延迟加载是它的直接成因
// 一个订单列表页
List<Order> orders = orderRepo.findByUserId(userId); // 1 条 SQL
for (Order o : orders) {
o.getItems().size(); // 每个订单 1 条 SQL → N 条
o.getUser().getName(); // 又是 N 条
}
@OneToMany(fetch = LAZY) 让 getItems() 在第一次调用时才发 SQL。列表 200 条就放大成 400 条以上 SQL。表现是"接口在测试环境很快,生产明显变慢",因为测试数据只有几条。
三种处理,各有适用面:
| 方案 | 写法 | 适用 | 坑 |
|---|---|---|---|
JOIN FETCH |
select o from Order o join fetch o.items where ... |
一对一、一对多且结果集不大 | 多集合 fetch 会笛卡尔积膨胀 |
@EntityGraph |
@EntityGraph(attributePaths = {"items"}) |
需要按场景切换抓取策略 | 与分页组合时先分页后抓取,可能触发内存分页警告 |
| 批量抓取 | @BatchSize(size = 50) / hibernate.default_batch_fetch_size |
列表页通用、关联多 | 仍是多条 SQL,只是从 N 降到 N/50 |
判断口径:"这个关联在当前用例里一定会被读到吗?" 一定会 → 改成 EAGER 或用 fetch join 显式声明;只有少数用例要 → 保持 LAZY,但在那些用例里显式抓取,别指望懒加载自己变快。
全部改 EAGER 是另一种灾难:任何 findById 都拖出整棵对象图。默认 LAZY + 用例内显式抓取是唯一可持续的组合。
4. LazyInitializationException:事务边界之外没有 Session
@Service
public class OrderQueryService {
public OrderDTO get(Long id) {
Order o = repo.findById(id).orElseThrow(); // 事务在这里结束(方法没标 @Transactional)
return toDto(o); // 访问 o.getItems() → LazyInitializationException
}
}
原因:懒加载依赖 Session(一级缓存)活着。findById 的事务提交后 Session 关闭,代理再访问就没有连接可用。
| 修法 | 做法 | 代价 |
|---|---|---|
| 查询时就抓取 | fetch join / @EntityGraph |
最优,但要求查询与用例一一对应 |
| 把转换放进事务 | 在 @Transactional 方法内完成 DTO 映射 |
事务变长,包含非数据库逻辑 |
OpenSessionInView |
spring.jpa.open-in-view=true |
连接被 HTTP 请求全程占用,连接池压力大 |
Hibernate.initialize() |
显式初始化需要的集合 | 只是把 N+1 写在明面上,没减少查询 |
open-in-view 默认 true,启动时会打 WARN。它让人"感觉不到"懒加载问题,代价是连接持有到视图渲染结束——高并发下这是连接池耗尽的常见原因。新项目直接关掉,逼自己在查询层把数据取全。
实用约定:Repository 只返回"已抓取完整"的对象,Service 层不允许出现 getXxx().getYyy()。映射用接口投影(interface OrderView { List<ItemView> getItems(); }),一次 SQL 出结果。
5. 代理对象的序列化问题
Hibernate 的 PersistentBag、HibernateProxy 直接丢给 Jackson 会出两种问题:未初始化的代理在序列化时触发初始化,抛 LazyInitializationException 或输出 null;已初始化的代理会把 hibernateLazyInitializer、handler 这些内部字段写进 JSON。处理方式:
| 做法 | 说明 |
|---|---|
| 不序列化实体 | 查询层直接投影成 DTO / record,从根上避免 |
@JsonIgnoreProperties({"hibernateLazyInitializer","handler"}) |
应急,字段仍在,只是不输出 |
jackson-datatype-hibernate6 |
全局注册 module,统一处理代理类型 |
关闭 spring.jpa.open-in-view + DTO 投影 |
推荐组合 |
用 @JsonIgnore 挂在实体字段上是下策:它把"序列化关注点"塞进了领域模型,而且影响所有接口。
6. 前端:路由懒加载与预加载
const routes = [
{ path: '/dashboard', component: () => import('./views/Dashboard.vue'),
meta: { prefetch: 'hover' } },
{ path: '/report/:id', component: () => import('./views/Report.vue'),
meta: { prefetch: 'idle' } }
];
| 策略 | 触发时机 | 适用 | 代价 |
|---|---|---|---|
| 纯懒加载 | 进入路由时 | 低频页面(设置、帮助) | 首次进入白屏,弱网更明显 |
| hover/idle 预取 | 悬停或浏览器空闲 | 导航菜单里的高频页 | 空闲预取会抢带宽 |
| 首屏内联 | 打进主包 | 首页、登录后落地页 | 主包体积上升 |
判断依据不是"页面重不重",而是**"点击到看到内容的时间"**。一个体积不小的页面懒加载后白屏可感知,就比打进主包更糟;没人进的报表页则没必要进主包。
7. 预热还是懒加载:看访问分布
懒加载假设"不一定会用",预热假设"一定会用且不能等"。选择靠访问分布,不靠感觉。
| 分布特征 | 选择 | 理由 |
|---|---|---|
| 少数 key 承担绝大多数访问 | 启动预热 + 懒补 | 热 key 首访不能慢 |
| 长尾明显、冷热差距大 | 纯懒加载 + 短期缓存 | 预热大部分是浪费 |
| 全量都必须可用(如字典表) | 预热 | 懒加载会在高峰期制造抖动 |
| 数据有 TTL 且更新频繁 | 懒加载 + 异步刷新 | 预热数据会过期,需额外失效逻辑 |
预热要回答两个问题才能上线:预热失败怎么办(启动阻塞还是降级)、预热与流量哪个先到(蓝绿发布时预热跑在切流之前)。
8. 收益怎么量化:打点,不是估算
不要说"延迟加载能提升启动速度"。把它打成可比较的数字:
// 每个懒初始化点包一层,记录"实际被用到"与"延迟时长"
public <T> T lazyTracked(String name, Supplier<T> supplier) {
long start = System.nanoTime();
T v = supplier.get();
metrics.timer("lazy.init", "name", name)
.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
metrics.counter("lazy.hit", "name", name).increment();
return v;
}
需要三个数才能下结论:
| 指标 | 含义 | 判读 |
|---|---|---|
| 命中率(hit / 总请求) | 到底有多少请求用到了它 | < 20% 时懒加载的收益才明显 |
| 首次初始化耗时 P99 | 被推迟的那次耗时 | 落在用户路径上就不能懒 |
| 首屏/启动总耗时变化 | 真实的端到端收益 | 只有这个能拿去汇报 |
同一版本灰度 10% 关掉懒加载做 A/B,比任何估算可靠。没有这三个数就别在评审里说"优化了性能"。
9. 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
列表页偶发 500,栈里是 LazyInitializationException |
事务在 Service 内结束,DTO 映射在外 | 查询层投影,或映射放进事务 |
JSON 里多出 hibernateLazyInitializer |
直接序列化代理 | DTO 投影 / 注册 Hibernate module |
| 懒初始化并发下初始化两次 | 双重检查缺 volatile |
加 volatile 或用 holder 类 |
| 压测正常上线抖动 | 预热没跑或跑在切流后 | 预热在切流前完成并校验 |
@Lazy 加了但启动没变快 |
启动耗时在扫描与代理生成 | 用 -Xlog:class+load 或启动打点定位 |
10. 边界:什么时候不该懒加载
| 场景 | 不该懒的理由 |
|---|---|
| 初始化失败必须立刻知道(配置校验、证书加载) | 延迟把"启动失败"变成"请求失败",排查成本翻十倍 |
| 该数据在 90% 请求里都会被用到 | 只增加一次判断与代理,收益为负 |
| 并发极高且初始化非幂等 | 重复初始化会写出重复数据 |
| 依赖关系简单、初始化只要几毫秒 | 抽象成本高于收益 |
| 懒加载掩盖了"这个聚合根太大"的设计问题 | 该拆聚合,而不是加懒 |
最后一条单独说:一个实体带 9 个 @OneToMany,全设成 LAZY 后接口是快了,但每个业务都要写 fetch join,代码里全是抓取策略。这是设计问题的症状,不是性能问题的解法。
11. 自测
- 懒加载的本质是把什么从什么时候挪到了什么时候?
- 双重检查锁的懒初始化为什么必须给字段加
volatile? - 列表页的
LAZY关联会放大成多少条 SQL? open-in-view=true让懒加载"不报错",代价是什么?- 判断该预热还是该懒加载,看哪个数据?
- 什么情况下延迟加载是在掩盖设计问题?