审批列表的查询很短,SQL 为什么可能很多

一页十条采购申请,界面要求在每条下方展开明细。先查询十条 ProcurementRequest,再循环调用 request.lines(),代码看起来只有一条显式查询,却可能在遍历时为每个尚未加载的集合再访问一次数据库。N+1 不是 JPQL 的语法错误,而是“先取 N 个根对象,再逐个访问未取到的关联”造成的访问路径问题。同一段代码在一级缓存已有对象、二级缓存命中或 provider 改用批量抓取时,SQL 条数都可能变化;1+N 是便于定位问题的模型,不是运行结果或规范保证。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

本篇接续 04 篇的关系映射和 05 篇的排序分页。累计工程 examples/jpa/ 中,ProcurementRequest.lines 是 @OneToMany(mappedBy = "request", fetch = LAZY),ProcurementLine.request 为 @ManyToOne(fetch = LAZY)。申请还带 tenantId、金额与状态,订单只能从 APPROVED 申请生成且一申请最多一单。本文关注列表与详情的读取形状,不改变审批状态机;现有测试已经在冻结环境下记录了三条申请的一次抓取对照,本文其余方案仍是待验证的设计。

延迟与立即规定“何时可用”,不规定几条 SQL

Jakarta Persistence 3.2 的关联映射定义在 §2.11;@OneToMany、@ManyToOne 注解的 fetch 元素分别见 §11.1.41、§11.1.31。LAZY 是 provider 可以采取延迟抓取的提示,EAGER 是必须立即取到所指关联状态的要求;两者都没有规定只能用一条连接查询、不能用补充查询,或者一定出现代理类。即使实体元数据写成 LAZY,也不能仅凭注解证明循环访问时不会发 SQL。

一级缓存即持久化上下文的实体身份与管理范围(§3.2、§3.3)。同一 EntityManager 查过某条申请,再次查找可能从上下文取得同一托管对象;这不能推出“系统有共享缓存”。换一个上下文,旧对象不再受它管理。对延迟关联的访问必须在有效上下文及适当事务边界内安排;把实体返回到已关闭上下文的 HTTP 层再遍历,不是解决 N+1 的办法,具体访问异常由 provider 与实体状态决定,不能宣称规范必抛某个 Hibernate 异常。

想测出列表查询的真实成本,应固定数据集与访问序列。只比较 getResultList() 返回时的 SQL 数量,会漏掉模板在读取明细时触发的查询;只比较页面响应时间,又会混入网络、数据库缓存与渲染。先确定请求的是“摘要列表”还是“包含明细的详情列表”:摘要只需 05 篇的投影,不应把明细集合加载进来;详情必须真正触达明细,计数口径应覆盖加载、循环访问与事务结束。

join fetch 能合并读取,不能随意和集合分页叠加

规范 §4.4.5.3 规定 fetch join 将关联作为查询副作用抓取;被抓取的对象不作为独立顶层结果,也不能在同一查询中任意引用这个 fetch 关联作为额外的筛选变量。对于已经确定的小批申请详情,可以写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import blog.jpa.ProcurementRequest;
import jakarta.persistence.EntityManager;
import java.util.List;

public final class RequestDetails {
public static List<ProcurementRequest> fetch(EntityManager em,
String authorizedTenant,
List<Long> requestIds) {
if (authorizedTenant == null || authorizedTenant.isBlank()
|| requestIds == null || requestIds.isEmpty()) {
throw new IllegalArgumentException("Tenant and ids required");
}
return em.createQuery("""
SELECT DISTINCT r FROM ProcurementRequest r
LEFT JOIN FETCH r.lines
WHERE r.tenantId = :tenant AND r.id IN :ids
ORDER BY r.id
""", ProcurementRequest.class)
.setParameter("tenant", authorizedTenant)
.setParameter("ids", requestIds)
.getResultList();
}
}

左连接保留没有明细的申请(即使正式提交规则不允许空申请,草稿仍可能无明细);DISTINCT 用于去除因一对多连接产生的重复根实体结果,规范 §4.9 定义查询结果的去重语义。它不意味着数据库一定只扫描或传回与根对象相同数量的行:一条申请有多条明细时,关系行仍会膨胀。若同时抓取多个集合,组合行数会进一步增加,具体 SQL 和内存代价须实测。不要因为应用层只看到十个实体就推断数据库只处理十行。

不要在上面的集合抓取查询上直接加 setMaxResults(10) 或 setFirstResult(10)。 规范 §3.11.1 明确:对集合 fetch join 查询使用这两个分页方法,效果 undefined。provider 可能采用内存截断、拒绝执行或者另作处理;即使页面上恰好有十条申请,也不能据此声称数据库做了正确的根实体分页。若要按明细内容筛选根对象,可先用普通 JOIN、子查询或 EXISTS 取根键,再独立确定该页的抓取方式;fetch join 不是关系谓词和分页算子的万能替身。

两阶段读取让页边界由申请决定

更稳妥的方案是第一页只查询申请 ID,按 id 或 (业务排序键, id) 排序并分页,之后按这批 ID 读取实体及其明细。第一页完全不含集合 fetch join:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import blog.jpa.RequestStatus;
import jakarta.persistence.EntityManager;
import java.util.List;

public final class RequestPageIds {
public static List<Long> find(EntityManager em, String authorizedTenant,
int offset, int size) {
if (authorizedTenant == null || authorizedTenant.isBlank()
|| offset < 0 || size < 1 || size > 100) {
throw new IllegalArgumentException("Invalid page");
}
return em.createQuery("""
SELECT r.id FROM ProcurementRequest r
WHERE r.tenantId = :tenant AND r.status = :status
ORDER BY r.id
""", Long.class)
.setParameter("tenant", authorizedTenant)
.setParameter("status", RequestStatus.SUBMITTED)
.setFirstResult(offset)
.setMaxResults(size)
.getResultList();
}
}

取到 ID 后才调用前面的 RequestDetails.fetch,并以第一页的 ID 顺序重新排列结果;IN :ids 本身不承诺保留输入次序。第二次查询仍需重申租户过滤,不能认为“第一页已经过滤过”就放开跨租户实体访问。两次查询之间若有事务更改状态或删除申请,第二次读到的结果可能已经不同。需要同一快照或重试策略时,须结合数据库隔离级别与事务设计;两条查询不是天然的原子快照。

另一种两阶段读取是在第二步查 ProcurementLine:SELECT l.request.id, l.item, l.quantity FROM ProcurementLine l WHERE l.request.id IN :ids,将行按申请 ID 归组。它返回标量投影,适合只读列表,并避免在当前上下文里误把部分明细列表装作完整托管集合;代价是应用要自己按 ID 组装视图。是否使用 fetch join 或标量归组,取决于页面需要完整实体图还是几列可展示数据,不能把“只有两条查询”当作唯一目标。ID 集合过大时还要考虑数据库参数数量、网络开销与分批策略;每页大小应有限制。

若选标量归组,还要明确没有明细的申请在第二条查询中没有行:组装时以第一页根 ID 为基准,填入空明细列表,而不是只遍历第二条查询的结果。金额也不从逐行 unitPrice * quantity 的展示值回填实体 total;累计工程由服务端在明细变更时核算并持久化金额,报表如果重新计算必须交代精度、舍入和跨事务变更。多一步组装,把页面需要的数据与当前工作单元管理的实体集合分开了。

[PATTERN] 先分页根键,再按有界键集合读取所需明细;分页定义作用于申请而不是连接行。这个办法仍要求给第二阶段明确排序、租户过滤和并发变化策略。

批量抓取是 provider 优化,不是 JPA 保证

也可以保留原来的 LAZY 集合访问路径,让 provider 一次加载若干待初始化集合。Hibernate ORM 的批量抓取配置例如 hibernate.default_batch_fetch_size 或 @BatchSize 属于 Hibernate 实现特性,不是 Jakarta Persistence 3.2 的通用查询 API。配置批量大小不代表 SQL 必然从 1+N 变成固定的两条。上下文里有多少实体、哪些集合已初始化,以及方言的参数限制、缓存命中和 provider 加载计划,都会改变计数。现有 4 对 1 的观察未开启专门的批量抓取对照;没有这种受控实验,不能给出“启用批量抓取后提升 10 倍”之类数字。Hibernate 的官方用户指南可用于核对具体配置;迁移 provider 时须重新设计并测量。

批量抓取也不等于批量写入:hibernate.jdbc.batch_size 影响写语句的 JDBC 批处理,不是解决循环读 lines() 的开关。把所有关系改成 EAGER 同样不是通用解法。一个页面只要金额,预先读取全部明细会多搬运数据;另一个页面确实需要完整明细时,则应选定其读取计划。抓取决策应该跟随调用场景,而非永久写死在每个实体的默认映射上。

标准实体图是另一种声明“这个读取入口需要哪些属性”的方式。规范 §3.8.1 的 jakarta.persistence.fetchgraph / jakarta.persistence.loadgraph 允许覆盖或补充默认抓取;列入图的状态必须被抓取,但 provider 仍可抓取图外的更多状态。对只取一条已批准申请的订单详情,图中列明 lines 很合适;图不会把多条根实体的集合分页变成有定义的数据库分页,也不保证一条 SQL。若旧上下文已加载申请,比较图的效果前先在新上下文准备相同数据,否则上下文已有的加载状态会掩盖图的实际作用。实体图的完整分页实验放在选修 E01,本篇只给出可核查的抓取边界。

缓存命中不是审批一致性的证据

规范 §3.10 把二级缓存定义为可能跨持久化上下文复用数据的可选能力,provider 不必支持。shared-cache-mode 可指定 NONE、ALL、ENABLE_SELECTIVE 等策略;不指定或设 UNSPECIFIED 时可能落到 provider 默认值(§3.10.1)。是否有共享缓存、是否缓存申请实体或关联集合、何时失效,不能从“第二次加载没看见 SQL”直接推断。第一次读可能仍在同一上下文、数据库驱动也可能复用连接,必须隔离层次测量。

规范 §3.10.2 的 retrieve/store mode 用于控制与二级缓存的交互:例如查询设置 jakarta.persistence.cache.retrieveMode 为 BYPASS,可排除从二级缓存取实体数据的影响;缓存未启用时该设置会被忽略。这不要求清掉当前 EntityManager 的托管对象。要比较跨上下文读,应关闭第一个上下文,再开启第二个,在同样的事务与数据条件下记录 SQL;若要测二级缓存,必须显式配置可用缓存并保存 provider 配置和统计。不要把一级缓存命中统计误报为二级缓存生效。

审批又特别依赖新鲜状态:另一进程或原生 SQL 修改 purchase_request 后,当前上下文可能仍持有旧实体;跨上下文的二级缓存是否失效依赖具体缓存集成、更新路径及配置。@Version 主要服务于受管实体更新的并发检查,并不让列表、原生修改、bulk 或外部写入自动变成强一致。若必须判断“现在能否下单”,应在服务层事务中重新验证 APPROVED、租户与版本/唯一键等并发条件(见 07、09 篇),不可用缓存过的页面状态代替事务内校验。

即使打开 ENABLE_SELECTIVE 并给申请标注 @Cacheable,也应分别问三个问题:加载单个申请是否从二级缓存读取,关联明细是否另有缓存策略,别的进程改变数据库后缓存何时失效。缓存实体本身不意味着查询结果列表也被缓存;查询缓存若由实现提供,仍有独立的键、失效和一致性边界。采购审批通常写少读多,却对状态新鲜度敏感,不能因为读比例高就默认开启缓存。先让查询返回正确的授权结果,再以可重复的负载评估缓存是否值得承担失效风险。

[PATTERN] 性能诊断先区分根查询、关联加载、一级缓存、二级缓存与数据库缓存;关闭一种缓存、改变一种抓取计划,只能回答对应层次的问题,不能顺带推出并发正确性。

实验判据与代码入口

入口为 累计工程的 ProcurementJpaTest.java(归档内 examples/jpa/);hibernateSpecificQueryCountContrastsLazyFetch 已使用 Hibernate 统计 API 检验语句数,没有独立的 Jpa06FetchingTest。实际运行环境为 Java 21.0.12.1、Hibernate ORM 7.1.36.Final、PostgreSQL 16.15、pgJDBC 42.7.7、RESOURCE_LOCAL。原始证据在 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/;同名实验说明 保存本篇的验证范围。计数是这一实现、数据集与配置下的观察,不是 JPA 规定的 SQL 形状。

已运行的正常路径(PASS,限当前环境)。 hibernateSpecificQueryCountContrastsLazyFetch 用随机租户插入三条申请,每条一条明细。新 EntityManager 中按租户查实体,再逐个调用 lines().size(),断言每个集合大小为 1 且 Hibernate getPrepareStatementCount() 为 4;另一新上下文执行无分页 SELECT DISTINCT r ... LEFT JOIN FETCH r.lines,断言三条申请、各一条明细且计数为 1。测试先启用并清空统计,两种读取各有独立上下文。这是 Hibernate 的预备语句计数断言,不是已归档的逐条 SQL 文本,也不是吞吐/延迟基准;该轮 clean test 总计 6/6,退出码 0。

失败及扩展实验:NOT_RUN。 故意在集合 fetch join 上使用 setMaxResults 的 provider 实际表现尚未测试;规范判据是不能把此结果作为可移植分页(§3.11.1 规定 undefined),不预设异常。尚未运行的还有十条申请、每条两明细的固定数据集;先分页根 ID 再查详情;显式启用 Hibernate 批量抓取;跨事务更改后重读和二级缓存命中/失效。现有测试未显式配置二级缓存,不能拿统计 4 对 1 推断缓存一致或不一致;实体图 E01 也不在本次回归之内。

两道练习

练习一。 需求是“按申请 ID 排序,每页十个申请,同时展示每个申请的全部明细”。能否对 SELECT r FROM ProcurementRequest r LEFT JOIN FETCH r.lines ORDER BY r.id 直接设 setMaxResults(10)?**答:**不能据此获得可移植的页语义。规范 §3.11.1 规定集合 fetch join 的分页效果未定义。先对根申请 ID 分页,再在第二阶段按 ID 取明细、重排,并检验两阶段的租户条件与并发变化。

练习二。 同一 EntityManager 两次 find 没有出现第二条 SQL,是否证明开启了二级缓存,也证明另一个审批进程更新后此处会自动看到新状态?**答:**都不能。首先可能只是一级缓存复用托管实体;二级缓存须在不同上下文与明确配置下验证。即使确有缓存,另一个进程或绕过受管实体的更新路径仍需单独核查失效与事务内重新校验,不能拿 SQL 计数证明审批一致性。

限制与速查

规范只给抓取语义和缓存接口,不给每页 SQL 上限、批量读取计划或 PostgreSQL 执行时间。集合分页的结果甚至被明确留为未定义;页面上的申请个数与 SQL 行数是两种指标。缓存提高命中率的可能性不构成状态授权依据。事务隔离、负载与 provider 配置不同,06 篇的受控计数也要重跑,而不能当作平台指标复用。

场景 选择 不要据此推断
申请摘要页 字段投影 详情关联已经加载
少量确定 ID 的详情 无分页 fetch join 数据库行数等于申请数
带明细的申请分页 先根 ID 分页,再按 ID 取 两条查询天然属于同一快照
循环读多个延迟集合 测量后考虑 Hibernate 批量抓取 JPA 规范保证固定 SQL 数
跨请求复用实体 显式配置并测二级缓存 缓存自动解决外部更新与审批冲突

参考资料