上一章把 MonitoringGroupEntity 与 MonitoredServiceEntity 映射成一对多关系。关系能够被访问,不表示每种访问方式都会生成合适的 SQL:循环读取延迟关联可能产生 N+1 查询,加载完整实体也可能取出接口根本不需要的列。
本章从一个明确用例出发:查询某个监控分组中的服务名称,并返回分组名称。我们会依次比较派生查询、JPQL、DTO 投影、fetch join 与实体图,再根据实际 SQL 判断是否需要索引。
1. 查询层次
Spring Data JPA 提供多种查询表达方式,应选择能清楚描述当前用例的最低复杂度方案:
| 方式 | 查询依据 | 适合场景 |
|---|---|---|
| 继承方法 | JpaRepository 已定义的契约 | 按主键查询、保存、删除等通用操作 |
| 派生查询 | Repository 方法名 | 条件简单并直接对应实体属性 |
| JPQL | 实体名、属性和实体关系 | 需要连接、投影或清晰表达较复杂条件 |
| Specification | 组合条件对象 | 筛选项数量动态变化 |
| 原生 SQL | 表、列和数据库语法 | 必须使用数据库特有能力或精确 SQL |
受监控服务实体的属性是 group,分组对象的属性是 name。派生查询可以沿属性路径查找:
01package cn.usehook.spring.monitoring;0203import java.util.List;04import org.springframework.data.jpa.repository.JpaRepository;0506public interface MonitoredServiceJpaRepository07extends JpaRepository<MonitoredServiceEntity, Long> {0809List<MonitoredServiceEntity> findByGroupNameOrderByNameAsc(String groupName);10}
GroupName 会被解析为 group.name,OrderByNameAsc 则按服务名称升序。方法名仍然基于 Java 属性,不是数据库列名。
派生查询适合这一简单条件,但它返回完整服务实体,调用 getGroup().getName() 时还要考虑分组关联有没有一并加载。方法名继续增长到难以阅读时,应切换为 JPQL,而不是用更多关键字勉强拼接。
2. JPQL
JPQL 查询实体及其属性。下面显式连接服务与分组:
01@Query("""02select service03from MonitoredServiceEntity service04join service.group monitoringGroup05where monitoringGroup.name = :groupName06order by service.name asc07""")08List<MonitoredServiceEntity> findEntitiesByGroupName(09@Param("groupName") String groupName10);
这段代码需要引入 org.springframework.data.jpa.repository.Query 和 org.springframework.data.repository.query.Param。MonitoredServiceEntity 是实体名,service.group 是对象关系;Hibernate 再根据映射生成对 monitored_service 和 monitoring_group 的 SQL 连接。
命名参数 :groupName 与方法参数通过 @Param 对应。参数不会直接拼接进查询字符串,Hibernate 会使用绑定参数,因此不应该为了动态输入而自己连接 JPQL 文本。
普通 join 用来限制结果,不承诺把 group 关联标记为已经加载。查询返回服务实体后再访问 getGroup(),Hibernate仍可能执行额外查询。需要在同一次查询加载关联时,要明确使用 join fetch 或实体图。
原生 SQL 使用 nativeQuery = true,其中要写真实表列:
01@Query(02value = """03select service.*04from monitored_service service05join monitoring_group monitoring_group06on monitoring_group.id = service.group_id07where monitoring_group.name = :groupName08""",09nativeQuery = true10)11List<MonitoredServiceEntity> findNativeByGroupName(12@Param("groupName") String groupName13);
当前 JPQL 已经可以表达需求,所以不需要使用原生查询。原生 SQL 会绑定数据库表结构和方言,应留给 JPQL 无法清楚完成的场景。
3. DTO 投影
接口只需要服务编号、服务名称和分组名称时,没有必要返回两个完整实体。先定义读取模型:
1package cn.usehook.spring.monitoring;23public record MonitoredServiceView(4Long id,5String name,6String groupName7) {}
JPQL 构造器表达式可以直接创建这个 record:
01@Query("""02select new cn.usehook.spring.monitoring.MonitoredServiceView(03service.id,04service.name,05monitoringGroup.name06)07from MonitoredServiceEntity service08join service.group monitoringGroup09where monitoringGroup.name = :groupName10order by service.name asc11""")12List<MonitoredServiceView> findViewsByGroupName(13@Param("groupName") String groupName14);
投影只选择构造 record 所需的三列,结果不进入实体脏检查,也不依赖延迟关联。它适合只读列表和接口响应前的读取模型。
构造器表达式需要完整类名,并且参数顺序、类型必须与构造方法一致。字段变化时应通过 Repository 测试尽早发现查询与投影不匹配,而不是等到接口请求才验证。
这里的 MonitoredServiceView 是应用内部查询结果,不直接承担 HTTP 状态码或请求校验。Controller 仍然可以根据公开接口契约把它转换成响应 DTO。
4. N+1 查询
下面的代码先查询全部服务,再在循环中读取延迟分组:
1List<MonitoredServiceEntity> services = repository.findAll();23for (MonitoredServiceEntity service : services) {4System.out.println(service.getGroup().getName());5}
在打开的持久化上下文中,它可能先执行一条服务查询,再为不同分组执行额外查询。这类「一条主查询加多条关联查询」称为 N+1。持久化上下文会复用已加载的同一分组,所以额外查询数不一定严格等于服务数,但仍会随关联对象数量增长。
如果调用发生在事务之外,延迟关联还可能因为持久化上下文已关闭而无法初始化。解决方案不是把所有关系改成 EAGER,而是让每个 Repository 用例明确需要的数据。
需要返回实体并同时使用分组时,可以使用 fetch join:
1@Query("""2select service3from MonitoredServiceEntity service4join fetch service.group5order by service.name asc6""")7List<MonitoredServiceEntity> findAllWithGroup();
也可以使用实体图,不改写 JPQL:
1@EntityGraph(attributePaths = "group")2List<MonitoredServiceEntity> findAllByOrderByNameAsc();
这段代码需要引入 org.springframework.data.jpa.repository.EntityGraph。fetch join 和实体图都应针对具体方法使用,不要把实体上的默认加载策略改成全局急加载。
对于当前只读列表,DTO 投影比加载实体关系更直接;需要修改实体并依赖关联行为时,fetch join 或实体图才更合适。
5. 分页边界
分页查询服务并 fetch 它的单值 group 关联通常可行,因为一行服务仍然对应一行结果。分页查询分组并同时 fetch 集合则不同:
1@Query("""2select distinct monitoringGroup3from MonitoringGroupEntity monitoringGroup4left join fetch monitoringGroup.services5""")6Page<MonitoringGroupEntity> findGroupsWithServices(Pageable pageable);
一个分组会在 SQL 结果中按服务数量展开成多行。数据库先对展开结果分页,JPA 再去重,就可能得到少于预期的分组,Hibernate 也可能退回内存分页并产生警告。计数查询同样不能直接沿用集合 fetch join。
常用处理方式是:
- 第一条分页查询只取得当前页的分组 ID;
- 第二条查询使用这些 ID 批量加载分组与服务;
- 或者直接使用满足接口字段的扁平 DTO 投影;
- 不需要总数时考虑
Slice或游标分页。
不要仅为了消除 N+1 就给所有查询添加集合 fetch join。加载策略必须同时考虑结果形状、分页语义、数据量和接口真正需要的字段。
6. SQL 与索引
先临时开启 SQL 和参数日志,观察一个接口到底执行几条查询:
1logging:2level:3org.hibernate.SQL: debug4org.hibernate.orm.jdbc.bind: trace
这些日志可能包含业务参数,只适合本地短期开启。确认 SQL 后,可以在测试数据库使用 PostgreSQL 执行计划:
1explain (analyze, buffers)2select service.id, service.name, monitoring_group.name3from monitored_service service4join monitoring_group5on monitoring_group.id = service.group_id6where monitoring_group.name = 'backend'7order by service.name asc;
ANALYZE 会真实执行查询,因此这里只用于没有副作用的 SELECT。小表选择顺序扫描并不表示索引失效;优化器会根据统计信息和成本选择计划,应该使用接近真实规模的数据判断。
当前迁移只有 group_id 单列索引。查询还需要在组内按名称排序,可以用新的 Flyway 迁移把它改成复合索引:
1drop index idx_monitored_service_group;23create index idx_monitored_service_group_name4on monitored_service (group_id, name);
复合索引仍能支持以 group_id 为前导条件的查找,并进一步提供组内名称顺序,避免维护两个高度重叠的索引。是否采用它应由真实查询和执行计划验证,而不是看到 WHERE 与 ORDER BY 就机械建索引。
JPA 查询性能最终仍由生成的 SQL、返回行数、加载关系和数据库索引共同决定。下一篇继续处理写入边界:使用审计字段记录实体创建与修改时间,再通过版本号阻止并发请求静默覆盖彼此结果。