2787 字
约 9 分钟
16
S18-X06 · 延迟加载:什么时候该延迟,什么时候是自找麻烦

延迟加载(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. 自测

  1. 懒加载的本质是把什么从什么时候挪到了什么时候?
  2. 双重检查锁的懒初始化为什么必须给字段加 volatile?
  3. 列表页的 LAZY 关联会放大成多少条 SQL?
  4. open-in-view=true 让懒加载"不报错",代价是什么?
  5. 判断该预热还是该懒加载,看哪个数据?
  6. 什么情况下延迟加载是在掩盖设计问题?
S18-X06 · 延迟加载:什么时候该延迟,什么时候是自找麻烦
https://www.clxhxhhr.top/posts/5721/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。