JPA 持久化 E01:实体图与批量读取
实体图与批量读取:先确定申请页,再加载明细
采购审批列表一页只需二十条申请,可页面还需要每条申请的商品明细。把关联改成 EAGER,可能让所有读申请的操作都付出明细加载成本;保持 LAZY,循环渲染时又可能出现 N+1 次 SQL。把 JOIN FETCH 直接拼进分页查询,看似同时解决两个问题,实际上分页的对象是 SQL 行还是申请实体,必须先说清。选修 E01 接在 05 篇的查询与分页 和 06 篇的抓取与 N+1 后面,只处理“这一页审批列表要加载哪些关系”这一决策,不把实体图包装成数据库限量或权限机制。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
例子沿用 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 | |
两次查询之间申请可能被其他事务删除、转移租户,或者明细被修改;代码选择只返回第二次确实仍属于该租户的实体,因此实际页长可能小于 limit。它没有把两次查询冻结为同一快照,但图中的 lines 按 Persistence 3.2 §3.8.1 必须加载;已加载的 lines() 在关闭上下文、实体脱管后仍可访问(§3.3.7)。继续访问未加载的其他关联仍可能失败。跨两次查询的并发快照不一致、对象权限与将实体暴露给视图造成的数据泄露是独立风险;只读投影可缩小暴露范围。
一个 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(证据路径:writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/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、二级缓存和数据库级租户策略均不在本篇实测范围。
参考资料
- Jakarta Persistence 3.2 规范 §3.8 Entity Graphs、§3.11.1 Query Execution、§4.4.5.3 Fetch Joins;Jakarta Persistence 3.2 API(
EntityGraph、Query)。 - Hibernate ORM 7.1 User Guide(pagination、fetching) 与 7.1 设置:
FAIL_ON_PAGINATION_OVER_COLLECTION_FETCH:实现特有的警告、失败开关及行为。 - PostgreSQL 16 查询计划:Using EXPLAIN:实际计划需在专用测试库观察,不能由 JPA API 推断。
- 累计工程入口、归档实验记录 RUN.md(证据路径:
writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md,需仓库权限);实验说明。






