一个接口返回订单详情,包含客户信息。本地跑得好好的,上了生产开始偶发 LazyInitializationException。原因很朴素:本地有缓存、数据少、SQL 少,事务还没结束就把关联字段取到了;生产上多了一次网络往返,事务先提交了,懒加载再去取就已经在事务之外。
ORM 的懒加载不是"延迟取数据",而是把取数据的时机推迟到没人控制的时候。事务边界内它是优化,边界外它是故障——而这个边界通常不在代码的显眼处。
1. 懒加载失败的两个时刻
| 时刻 | 现象 |
|---|---|
| 事务已提交 | LazyInitializationException |
| 连接已归还 | 取到旧值或空值 |
第二项更危险:它不报错,只是返回了不对的数据。原因是一级缓存(会话级)还在,而数据库连接已经回池,于是读到的是会话里的旧快照。
这类"不报错的错"在测试里几乎测不出来,因为它依赖"连接归还"这个时序。
// 反例:事务在 service 内结束,controller 再访问懒加载字段
@Transactional
public OrderDto get(Long id) {
Order o = repo.findById(id).orElseThrow();
return mapper.toDto(o); // 此时 out 已完成,事务准备提交
}
// 正例:DTO 组装必须在事务内完成,或显式 fetch
@Transactional(readOnly = true)
public OrderDto get(Long id) {
Order o = repo.findWithCustomer(id); // JOIN FETCH,一次取全
return mapper.toDto(o); // 不依赖任何懒加载
}
2. 三种解法与代价
| 解法 | 代价 |
|---|---|
| JOIN FETCH / 预加载 | 一次查多表,可能有数据膨胀 |
批量加载(@BatchSize) |
多一次查询,N+1 变 N/批次 |
| DTO 投影 | 需要写投影查询 |
| 延长事务到视图层 | 事务变长,连接占用高 |
第三项(DTO 投影)常被当成"工程量大"而跳过,其实它的成本集中在第一次:一旦有了投影层,后续每个接口都是加一个 record 或查询,而每次省下的是整条懒加载路径。
第四项是"最快见效、最坏长期"的做法:它把事务开到了渲染层,连接在等待序列化时一直被占用,高并发下连接池会先耗尽。
第一项的问题是一对多关联的膨胀:一个订单三个商品、每个商品五个属性,JOIN 后行数相乘,取 100 个订单可能变成 1500 行。这时应当用"两次查询"(先查主表,再按 ID 批量查子表)。
// 两次查询替代 JOIN 膨胀:一对多关联的标准做法
List<Order> orders = repo.findPage(ids); // 1 次
Map<Long, List<Item>> items = itemRepo.findByOrderIds(ids) // 1 次
.stream().collect(groupingBy(Item::getOrderId));
// 行数:100 + 300,而不是 100 × 3 × 5
3. 什么时候该显式加载
| 场景 | 做法 |
|---|---|
| 接口返回需要关联数据 | 投影或 JOIN FETCH |
| 只在少数分支用到关联 | 显式调用加载方法 |
| 关联数据量大 | 分页 + 单独接口 |
| 关联是可选展示 | 前端二次请求 |
第二项与"永远 EAGER"是对立的两种极端,正解是默认懒加载 + 在需要的地方显式触发。关键是"需要的地方"要可搜索:用命名方法(fetchWithXxx)而不是在任意位置调 getXxx()。
第二项(显式触发)的关键是可发现性:同一个实体在不同接口里被不同人用,如果每个调用点都自己决定何时加载,很快会出现"有的接口加载了、有的没加载"。所以显式加载要收敛到 repository 的命名方法里。
第四项在多端场景里很实用:列表页不返回关联详情,点开再请求。这既避免 N+1,也避免一次返回几百 KB 的无用数据。
4. N+1 的识别与度量
| 手段 | 说明 |
|---|---|
| SQL 日志计数 | 一次请求的 SQL 条数 |
| 慢查询 + 计数基线 | 条数突增即异常 |
| APM 的 span 数 | 单 trace 内 DB span 数 |
| 单元测试断言条数 | 防止回归 |
第四项(测试断言)是把"性能问题"变成"功能问题"的手段:断言 SQL 条数会让任何引入 N+1 的改动直接让测试变红,而不是等到生产上延迟变差才被发现。
// 集成测试:断言 SQL 条数,防 N+1 回归
@Test
void 列表接口只执行两条 SQL() {
var counter = new StatementCounter(dataSource);
orderService.page(0, 20);
assertEquals(2, counter.count()); // 主表 + 批量子表
}
第一项是最直接的:一个返回 20 条记录的接口,如果执行了 21 条 SQL,那就是 N+1。把这个数放进集成测试的断言里,能防止以后又被改回去。
第三项在生产上最实用:单 trace 的 DB span 数超过阈值时告警,比看平均延迟更早发现问题——因为 N+1 在数据量小时延迟并不高。
// 把"SQL 条数"变成可观测指标
logging:
level:
org.hibernate.SQL: debug # 预发开启
management:
metrics:
# 自定义:每请求 SQL 条数的分位数
distribution:
percentiles-histogram:
jdbc.statements.per.request: true
5. 事务边界的三个常见误区
| 误区 | 后果 |
|---|---|
| 事务加在私有方法上 | 不生效(代理不拦截) |
| 同类内部调用 | 不生效(绕过代理) |
readOnly 当成"不加锁" |
语义误解 |
前两项是 Spring AOP 的经典陷阱:注解生效依赖代理调用,自调用与私有方法都不经过代理,于是事务根本没开。表现是"偶发"——因为有时外层已有事务,看起来正常。
这三项误区有一个共同的检验方式:在方法里打印"是否有活动事务"。一行日志就能区分"注解没生效"与"逻辑有问题",比读源码快。
第三项:readOnly=true 只是给驱动与 ORM 的提示,它不等于"读不加锁",也不保证一致性快照。真正的只读语义要靠隔离级别或显式查询。
// 反例:私有方法上的 @Transactional 不生效
private void doSave(Order o) { ... } // 注解无效
public void save(Order o) { doSave(o); } // 自调用也无效
// 正例:放在对外方法上,且由代理调用
@Transactional
public void save(Order o) { ... }
6. 排错:五种"关联数据读不到/读不对"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 偶发 LazyInitialization | 事务已结束 | 事务内组装 DTO |
| 数据是旧的 | 会话缓存 + 连接归还 | 缩短会话或清缓存 |
| 接口很慢但 SQL 都快 | N+1(多条快查询) | 批量加载 |
| 加了注解仍无事务 | 私有/自调用 | 移到对外方法 |
| JOIN 后数据重复 | 一对多膨胀 | 改两次查询 |
第三项是最容易被误判的:每条 SQL 都在 2ms 内,20 条就是 40ms,加上网络往返可能 200ms,而慢查询日志里一条都不超阈值。
7. 边界:什么情况不必管懒加载
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单表、无关联 | 无懒加载 | 不必 |
| 关联数据在缓存里 | 无查询 | 不必 |
| 内部批处理(不在乎延迟) | 无并发压力 | 可延长事务 |
| 已全用 DTO 投影 | 不经过实体 | 不必 |
还有一条与"缓存"有关的边界:二级缓存会掩盖懒加载问题。同样的代码在缓存命中时不查库,看起来正常;缓存失效后立刻暴露。所以测试时应当关掉二级缓存,才能测到真实行为。
判断口径一句话:这段代码执行时,事务还在不在。在,懒加载是优化;不在,它就是一次必然失败的延迟读取。
8. 自测
- 懒加载失败的两个时刻里,哪一个不报错?为什么它依赖"连接归还"这个时序?
- 为什么"把事务延长到视图层"是见效最快但长期最坏的做法?
- JOIN FETCH 在一对多关联上会造成什么问题?替代做法是什么?
- 怎么识别 N+1?为什么"每条 SQL 都很快"时最难发现?
@Transactional在私有方法与自调用上为什么失效?"偶发正常"的原因是什么?- 判断"是否需要管懒加载"的那个问题是什么?