2224 字
约 7 分钟
18
S08-A14 · 懒加载与事务边界:什么时候会读不到

一个接口返回订单详情,包含客户信息。本地跑得好好的,上了生产开始偶发 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. 自测

  1. 懒加载失败的两个时刻里,哪一个不报错?为什么它依赖"连接归还"这个时序?
  2. 为什么"把事务延长到视图层"是见效最快但长期最坏的做法?
  3. JOIN FETCH 在一对多关联上会造成什么问题?替代做法是什么?
  4. 怎么识别 N+1?为什么"每条 SQL 都很快"时最难发现?
  5. @Transactional 在私有方法与自调用上为什么失效?"偶发正常"的原因是什么?
  6. 判断"是否需要管懒加载"的那个问题是什么?
S08-A14 · 懒加载与事务边界:什么时候会读不到
https://www.clxhxhhr.top/posts/6464/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。