实体图与批量读取:先确定申请页,再加载明细

采购审批列表一页只需二十条申请,可页面还需要每条申请的商品明细。把关联改成 EAGER,可能让所有读申请的操作都付出明细加载成本;保持 LAZY,循环渲染时又可能出现 N+1 次 SQL。把 JOIN FETCH 直接拼进分页查询,看似同时解决两个问题,实际上分页的对象是 SQL 行还是申请实体,必须先说清。选修 E01 接在 05 篇的查询与分页 和 06 篇的抓取与 N+1 后面,只处理“这一页审批列表要加载哪些关系”这一决策,不把实体图包装成数据库限量或权限机制。

例子沿用 JPA 累计工程:租户甲有申请 101、102、103,分别有两条、一条、三条明细,租户乙还有一份申请。审批页面按 id 升序取租户甲前两条,期望得到 101 与 102 及各自完整明细,而不是取“SQL 返回的前两行”——那样 101 的两条明细就可能占满第一页。如果有人新增或删除申请,跨请求偏移分页会发生漂移;本篇不假设一次请求以外有稳定快照。生产数据的具体 ID 是说明问题的样例值,不是本工程的测试输出。

实体图规定加载范围,不规定 SQL 形状

Jakarta Persistence 3.2 §3.8 定义了动态和静态 entity graph,供 find 或查询选择抓取计划。图上的属性应被抓取;主键和版本不必显式加入图;未列入的属性会因使用 jakarta.persistence.fetchgraph 或 jakarta.persistence.loadgraph 而有不同处理。fetchgraph 将图外属性按懒加载处理,loadgraph 将图外属性按实体映射的抓取要求处理;provider 可以抓取更多状态,不能把“没有列进图”解释成保证永远没有 SQL。实体图不会自动加租户谓词,不会决定结果顺序,更不会让 setMaxResults(20) 神奇地变成二十个完整聚合。

本工程 ProcurementRequest 源码 的 lines 是 @OneToMany(mappedBy = "request", fetch = LAZY);明细的 request 是 owning side。默认页面仅显示总额时不必访问 lines();审批详情要展示商品时,才以图请求明细。现有实体的 lines() 返回 List.copyOf(lines),会读取该集合,所以它也是触发加载的访问点。明细只有 item、unitPrice、quantity 等属性,不应未经查询就断言所有集合都被“单条 JOIN”加载;这是规范抓取语义与 Hibernate 实际 SQL 的分界。

下面是待接入的演示代码,并非已在 examples/jpa 编译、执行的类。方法收取已经过授权的租户 ID,调用方管理 EntityManager 生命周期;示例为 Java 21,所用 API 来自 Jakarta Persistence 3.2。先在无集合抓取的主键查询上分页,再以本页 ID 加载申请和明细;第二次查询要重加租户条件,以免把第一页结果当作跨操作的授权令牌。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package blog.jpa;

import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class ApprovalPage {
private ApprovalPage() {}

public static List<ProcurementRequest> fetch(EntityManager manager,
String authorizedTenant, int offset, int limit) {
if (authorizedTenant == null || offset < 0 || limit < 1 || limit > 100) {
throw new IllegalArgumentException("Invalid page request");
}
List<Long> ids = manager.createQuery("""
select r.id from ProcurementRequest r
where r.tenantId = :tenant
order by r.id
""", Long.class)
.setParameter("tenant", authorizedTenant)
.setFirstResult(offset).setMaxResults(limit).getResultList();
if (ids.isEmpty()) return List.of();

EntityGraph<ProcurementRequest> graph =
manager.createEntityGraph(ProcurementRequest.class);
graph.addAttributeNodes("lines");
List<ProcurementRequest> loaded = manager.createQuery("""
select r from ProcurementRequest r
where r.tenantId = :tenant and r.id in :ids
""", ProcurementRequest.class)
.setParameter("tenant", authorizedTenant)
.setParameter("ids", ids)
.setHint("jakarta.persistence.fetchgraph", graph)
.getResultList();
Map<Long, ProcurementRequest> byId = new HashMap<>();
for (ProcurementRequest request : loaded) byId.put(request.id(), request);
List<ProcurementRequest> page = new ArrayList<>();
for (Long id : ids) {
ProcurementRequest request = byId.get(id);
if (request != null) page.add(request);
}
return page;
}
}

两次查询之间申请可能被其他事务删除、转移租户,或者明细被修改;代码选择只返回第二次确实仍属于该租户的实体,因此实际页长可能小于 limit。它没有把两次查询冻结为同一快照,也没有让单独的 lines() 集合访问在上下文关闭后仍安全。调用方必须在打开的上下文中把明细转成只读页面数据,再关闭上下文;单纯返回实体交给视图层自行访问,可能碰到未初始化集合、重复查询或意外泄露字段。

一个 id 的详情页不需要两段式查询:可以 createEntityGraph(ProcurementRequest.class) 后将 lines 加入图,通过 find(ProcurementRequest.class, id, Map.of("jakarta.persistence.fetchgraph", graph)) 加载。但即便单页详情,也要先保证查到的申请确属已授权租户;find 的图只改变读取的属性范围,不负责隔离。动态构造图时所用的属性名必须和实体映射一致,误写 items 不会因为 SQL 字段叫 item 就自动生效。

实体图的作用域也值得单独划清。图属于这一次 find 或查询的读取请求,不会把 ProcurementRequest.lines 的映射永久改成 EAGER,也不会修改数据库里的记录。已经在同一个持久化上下文中的实体再次按 ID 查找时,一级缓存与已有加载状态会影响本次调用能观察到什么;想比较“有图”和“无图”的 SQL,实验应分别使用新建的 EntityManager,重置 Hibernate 统计后记录实际 SQL,不要在同一个上下文里把第二次 find 的零查询误认为实体图保证零 SQL。如果请求使用的是实体投影,访问图外属性仍可能触发后续加载;图不是“禁止读取其余字段”的字段级权限系统。

两段式做法还有一个容易被忽略的合同:第一页主键查询过滤的业务集合必须与第二段相同。如果第一页选 SUBMITTED 的申请,第二段只按 ID 和租户加载,而另一事务在间隙把 101 批准了,那么页面会展示一个已经不满足第一段筛选条件的实体。若业务要求当前状态仍为 SUBMITTED,第二段也要加状态谓词,并接受页长因此缩短;若要求两次读取同一时刻的状态,必须另设适当的事务与数据库快照策略。第一段的排序使用唯一 id 能给单次查询稳定顺序,不能赋予跨语句原子性。对金额筛选也是一样:total 若在两段查询间变化,结果可能不再符合最初筛选。应在产品契约中决定返回最新值、缩短页面还是重试,而不是指望 ORM 把两次查询偷偷合成一个快照。

[PATTERN] 抓取计划回答“业务需要哪些关联”,主键分页回答“本页有哪些根实体”。先确定根实体集合,再扩展图;不要让一对多的数据库行数代替业务上的申请数。

为什么把 JOIN FETCH 和 LIMIT 并用很危险

设申请 101 有两条明细,102 有一条。对 select r from ProcurementRequest r join fetch r.lines ... order by r.id 直接 setMaxResults(2),开发者想要的是两份申请,底层连接后的前三行才包含第二份申请。Jakarta Persistence 3.2 §4.4.5.3 讨论 fetch join,§3.11.1 明确规定集合 fetch join 查询应用 setMaxResults 或 setFirstResult 的效果未定义;不能将它当作可移植的数据库级“限制二个完整申请”。Hibernate ORM 7.1 的 User Guide 明确提示与集合 fetch join 一起分页会改用内存分页;它可能先读更多行而后裁剪根实体。某次结果列表长度正好是 2,不能证明数据库只读了 2 行。

Hibernate 还提供 hibernate.query.fail_on_pagination_over_collection_fetch 配置,可将这种用法由警告提升为失败,以帮助发现误写;这是 Hibernate 属性,不是 Jakarta Persistence 3.2 标准。要评价两段式方案,至少看查询返回的主键个数、第二段传入的主键、实际 SQL 是否含数据库 LIMIT/OFFSET、扫描行数和内存占用;不能只对比最后的 List.size()。另一种规避途径是在第一页只查需要的标量投影,再按业务需求查明细,但当明细必须展示时仍要保证聚合完整。

为什么不能把主键第一页和完整申请第一页合并成同一条简单 JOIN?关系数据库会把申请和每一条明细组合成不同行;JPA 最终可以把这些行组装为实体及集合,组装之后的“实体数”并不等于组合前的“行数”。如果申请 101 有十条明细而 LIMIT 10 作用于组合行,102 根本没有机会进入结果,即使业务认为第一页应有十份申请。在 Hibernate 7.1 的上述内存分页路径中,最终只显示十份申请也不等于“数据库只读十行”。详细判断应查看 SQL、数据库计划、传输数据量以及根实体与明细的计数。即便改用 distinct,也不能假定数据库会先依据根实体数量截断再取完整明细;对集合抓取的分页结果应按规范边界和实测解释。

此处的“批量”不是 JPQL bulk update,也不是一次性将整个租户申请全取回。它是受页面 ID 集合约束的第二次读取,代价取决于页上申请数、每份明细数和数据库参数处理。一个申请如果有数万条明细,“第一页二十份申请”也可能生成很大的明细结果;按申请分页无法限制返回的明细总行数。审批页若只显示“共 N 条明细”,应该先问是否需要 lines() 的完整对象;用分组计数投影可能更合适。若用户点开某一申请才需要商品详情,可以将详情与列表拆成不同查询,而不是为了避免 N+1 就提前载入每一条明细。投影只读字段与需要脏检查的实体也应分清,否则页面设计会把大量无用对象带入持久化上下文。

[PATTERN] “少查几次”与“少读几行”不是同一个优化指标。先确认页面需要完整明细还是汇总投影,再结合主键分页、数据库行数和实际 SQL 选择加载策略。

两段式查询也不是性能银弹:主键集合过大时 IN 参数数量、查询计划和内存对象量都会增加;读写并发时两个阶段可能看到不一致的状态。可以限制每页上限、使用与数据库和事务契约匹配的快照或游标策略,必要时再比较 Hibernate 的 batch fetching;具体批量大小是实现配置,JPA 实体图不承诺固定 SQL 数量。对审批工作台,更重要的是确认权限谓词、排序键、明细完整性,然后才测数据库开销。

已有观察与待做的对照

现有 测试源码中的 hibernateSpecificQueryCountContrastsLazyFetch() 为随机租户建三条各有一条明细的申请,用 Hibernate SessionFactory 统计 getPrepareStatementCount():逐个触发懒集合的断言值为 4;显式 left join fetch 的断言值为 1。归档 RUN.md 及同目录 test.stdout.txt、exit-code.txt、code-sha256.txt 记录 PostgreSQL 16 RESOURCE_LOCAL 六项测试 PASS、命令退出 0 和当时未提交文件的逐文件 SHA-256。OBSERVED 的范围只是这个固定数据集和 Hibernate 实现;测试没有调用 EntityGraph,没有分页,也没有比较 SQL 是否用数据库级 limit,更不是跨 provider 保证。E01 专测仍 NOT_RUN,本次写作未重新运行。

正常实验(NOT_RUN)。 在专用 PostgreSQL 16 库按工程迁移,用租户甲准备三份申请(明细数 2、1、3),租户乙准备一份;运行动态实体图详情读取与上述两段式分页,验证第一页申请 ID、明细条数、租户字段、总额和第二页,记录各查询的 SQL 与行数。切换 fetchgraph、loadgraph 时单独记生成的 SQL,不预设一致。把每个查询的输入、提交边界、代码 SHA、驱动/provider/数据库版本、命令、退出码及原始输出归档。

失败实验(NOT_RUN)。 故意写一条对集合 join fetch 同时 setMaxResults 的查询,在 Hibernate 7.1 上启用上述失败开关;预期测试拒绝这种写法,但具体异常信息只能由实际运行给出。另设申请 101 有两条明细、102 有一条明细,检查错误方案有没有把物理行数当作申请数;在两段式查询间删除一份申请,检验结果不含其他租户且不会凭空补足页长。没有这些实验,不能把“防止内存分页”或“图优化了几次 SQL”标成通过。

每个对照应带上具体判据,而不只截一段 SQL:分页第一段的结果必须是至多两份授权申请 ID,第二段不得出现其他租户,组装后的每份申请的明细数要与迁移后的真实表行吻合。对错误查询先只检查设置了 Hibernate 失败开关的配置确实生效,再检查执行失败;如果设置错误导致测试仍返回列表,不能把“没看见内存分页警告”算作失败路径已验证。间隙删除的变体应记录第一页返回的 ID、删除事务提交点、第二段返回 ID 与实际页长;间隙改变状态的变体应说明采用的是“最新状态”还是“原始筛选”契约。最后在关闭上下文后访问未列入图的懒集合,单独检验调用方是否把实体误作可任意访问的离线 DTO;这些变体均为 NOT_RUN,不能引用旧的 4/1 统计断言充数。

两道练习

练习一。 列表先以 setMaxResults(2) 查询申请 ID,再用 IN (:ids) 加实体图取明细。第二次查询返回 102、101;能否直接交给页面?**解:**不能假定 IN 返回顺序与参数顺序相同。以第一页 ID 的顺序重组,并且第二段也检查授权租户;若期间删除了 101,可返回不足额页或按产品定义重新查询,不能偷偷让租户乙的数据顶上。

练习二。 有人把 lines 标成 EAGER,说这样审批页面不再需要实体图。审批列表和只读金额统计都会因此变快吗?**解:**不能这么推断。映射级抓取要求会作用于更多读取路径,统计投影甚至无需实例化实体;什么时候访问明细应由查询的业务目的决定。测量时分清列表、详情和纯投影,并记录真实 SQL 与行数,不能以 Hibernate 一次测试的 4/1 计数作为全局性能结论。

限制与速查

需求 建议入口 不提供的保证
详情页读一份申请及明细 find 加实体图,另做租户检查 固定连接方式、图自动鉴权
审批列表每页固定根实体 租户+稳定排序分页 ID,再加载这批 ID 跨请求快照、天然返回顺序
对比 N+1 与替代加载 Hibernate 统计与实际 SQL 跨 provider 固定语句数
阻止集合抓取内存分页 Hibernate 的失败开关及测试 JPA 规范配置或自动性能优化

IN 查询会打乱列表顺序,两段式访问在并发修改下不提供原子页面;批量抓取也不解决“订单创建是否重复”的写事务问题。规范的 entity graph 与 Hibernate 的 SQL 选择要分别引用,PostgreSQL 16 的实际执行计划只可由该环境测量。JTA、二级缓存和数据库级租户策略均不在本篇实测范围。

参考资料