11.3 延迟加载
对象暂不持有全部数据,却保存按需取得数据的机制,以推迟昂贵或不必要的加载。
学习目标
- 能为订单聚合设计按需加载关联数据的机制,并说明首次访问、重复访问和未访问的查询行为
- 能比较延迟加载、立即加载和显式查询在身份、N+1、会话边界与写回成本上的取舍
- 能在事务内、会话关闭和并发变更场景中验证延迟加载,并识别何时应撤回
为什么 11.3 延迟加载值得单独学习
↡先创建对象或代理,等调用者真正访问数据时才取得关联数据的加载机制。延迟加载不是“把查询藏起来”,而是把加载时机变成可审计的设计决定。对象可以先携带身份和已知字段,只有访问订单行或客户信用时才发起查询;收益是减少不必要读取,代价是访问动作可能触发 I/O,且必须受事务与会话边界约束。
订单聚合持久化能暴露这个取舍。订单列表只需要编号和状态时,不应为每个订单都加载全部行;进入详情页时再加载订单行是合理的。但如果列表循环隐式访问每个订单的行,就会产生 N+1 查询;如果对象在会话关闭后才尝试加载,则错误会从调用现场突然冒出来。
↡跟踪已加载对象、未加载关联和待写回变化的生命周期范围,决定延迟加载何时仍有有效数据源。工作单元把一次读取、业务操作和提交放在可观察的生命周期内;延迟加载不应跨过它而自行寻找连接。
↡在一个工作单元内确保同一持久化身份只对应一个内存实例的缓存机制。身份映射避免同一订单被加载成两个互不相同的对象,但它不能替代事务隔离,也不能证明关联已经加载。
先建立直觉:延迟的是数据,不是边界
↡在遍历一个集合时,先查询父记录,再为每个父记录分别查询关联数据而形成的查询放大。N+1 查询通常不是数据库突然变慢,而是对象访问语义与集合读取策略没有对齐。它需要通过查询计数、调用路径和页面规模一起验证。
↡允许延迟加载发生的事务、请求或工作单元范围,决定代理访问何时仍能安全取得数据。会话边界必须显式:加载前可访问不等于提交后仍可访问,关闭后的隐式查询应失败得清楚,或者在边界内预取需要的数据。
先预测:一个列表读取 100 个订单但只显示编号和状态时,延迟加载是否应该查询 100 组订单行?如果答案是否定的,就要在评审卡中写出列表字段、批量预取策略和查询数量上限,而不是把代理访问留给模板渲染时决定。
延迟加载适合“多数请求不需要关联数据、少数请求需要、关联访问仍在有效工作单元内”的场景。若每次请求都必然访问关联,立即加载或显式批量查询往往更可预测;若关联规则本身形成复杂生命周期,则应重新比较领域对象和查询对象,而不是继续叠加代理。
目录单元到教学证据
11.3 延迟加载
在 11.3 延迟加载 的单元边界内,学习者要把订单聚合持久化拆成身份、加载时机、查询数量、会话范围和写回顺序五类证据。单元键为 poeaa24-pattern-11-lazy-load;核心证据是未访问关联不产生查询,首次访问只加载一次,批量列表不会随订单数量线性增加隐藏查询。
评审记录还要回答:代理或加载器由谁拥有,工作单元怎样提供数据源,身份映射的作用域是什么,会话关闭后如何拒绝访问,关联变更如何进入提交集合。如果这些答案只依赖 ORM 默认行为,换掉框架就无法复核,说明设计没有固定语义。
专属代码案例:订单聚合持久化
下面的代码用一个显式加载器表示延迟加载。加载器只接受当前工作单元提供的查询能力;第一次访问才查询,后续访问复用同一个结果。示例刻意把“延迟”与“隐式跨边界”分开。
type OrderLine = { sku: string; quantity: number };
type OrderReader = (orderId: string) => Promise<OrderLine[]>;
class LazyOrder {
private linesPromise: Promise<OrderLine[]> | undefined;
constructor(
readonly id: string,
private readonly readLines: OrderReader,
) {}
lines(): Promise<OrderLine[]> {
this.linesPromise ??= this.readLines(this.id);
return this.linesPromise;
}
}
async function inspectOrder(order: LazyOrder, showLines: boolean) {
if (!showLines) return { id: order.id, queryCount: 0 };
const lines = await order.lines();
await order.lines();
return { id: order.id, lineCount: lines.length, queryCount: 1 };
}这段代码提供四项可复核证据:不访问 lines() 不查询,第一次访问只查询一次,重复访问复用 Promise,加载器的作用域由创建方决定。生产实现还要把查询计数、会话关闭错误、并发版本和批量预取写入测试;一个缓存字段不能自动解决 N+1 或事务一致性。
模式结构图
结构图把订单身份、延迟代理、工作单元和数据源分开,帮助评审确认代理只延迟数据读取,不拥有事务和提交责任。
延迟加载的会话边界
一次有效访问应发生在工作单元打开到提交之前;会话关闭后再访问必须显式失败,或者在关闭前完成预取。列表读取应使用批量查询或预取,避免把每个对象的访问转换为 N+1 次往返。
选择与拒绝矩阵
| 评审问题 | 选择延迟加载的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 时机 | 关联数据多数请求不需要,首次访问时可测量 | 每次请求都访问关联,延迟只增加不可预测性 |
| 身份 | 同一工作单元内同一身份复用一个实例 | 多个对象代表同一行,更新和缓存语义分叉 |
| 数量 | 列表使用预取或批量读取,查询数不随对象数线性增长 | 循环访问代理形成 N+1 Query |
| 边界 | 会话关闭前完成需要的数据访问,失败路径明确 | 关闭后才触发隐式查询,错误远离根因 |
| 替代 | 已与立即加载和显式查询比较并保留撤回路径 | 只因 ORM 默认代理而采用 |
常见误区
可验证练习
练习
本组练习围绕 11.3 延迟加载,要求把查询时机、身份作用域和会话边界写进同一张评审卡。
问题 1:测量时机。 订单列表只显示编号和状态,订单详情才显示订单行。应如何证明延迟加载确实减少了不必要查询?
问题 2:识别 N+1。 列表包含 100 个订单,日志显示一条订单查询和 100 条订单行查询。下一步应该做什么?
问题 3:处理关闭会话。 订单对象被传给异步任务,任务运行时工作单元已经关闭。应该重新打开全局连接,还是改变边界?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Lazy Load
先创建对象或代理,等数据真正被访问时才取得关联数据的加载机制。
- Unit of Work
跟踪读取、业务变化和提交生命周期的工作单元范围,为延迟加载提供有效数据源。
- Identity Map
在一个工作单元内让同一持久化身份复用同一个内存实例的缓存机制。
- N+1 Query
先查询父集合,再为每个父记录分别查询关联数据形成的查询放大。
- Session Boundary
允许延迟加载发生的事务、请求或工作单元范围,决定代理访问何时仍然有效。
本章小结
掌握 11.3 延迟加载的标志,不是记住一个代理 API,而是能在订单聚合持久化中解释“身份 → 按需访问 → 查询数量 → 会话边界 → 提交”的责任链。延迟的是不必要的数据读取,不是事务、身份或失败边界;当每次请求都需要关联、列表形成 N+1,或对象必须跨会话继续访问时,应选择立即加载、显式批量查询或重新划分工作单元,并记录撤回条件。
前后导航
来源与改写范围
- Martin Fowler 作者图书页:核对全书主题、教程与模式参考结构。
- Martin Fowler 模式目录:核对 11.3 延迟加载的公开模式名称和相关模式族。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。