跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

上一章把 MonitoringGroupEntity 与 MonitoredServiceEntity 映射成一对多关系。关系能够被访问,不表示每种访问方式都会生成合适的 SQL:循环读取延迟关联可能产生 N+1 查询,加载完整实体也可能取出接口根本不需要的列。

本章从一个明确用例出发:查询某个监控分组中的服务名称,并返回分组名称。我们会依次比较派生查询、JPQL、DTO 投影、fetch join 与实体图,再根据实际 SQL 判断是否需要索引。

1. 查询层次

Spring Data JPA 提供多种查询表达方式,应选择能清楚描述当前用例的最低复杂度方案:

方式查询依据适合场景
继承方法JpaRepository 已定义的契约按主键查询、保存、删除等通用操作
派生查询Repository 方法名条件简单并直接对应实体属性
JPQL实体名、属性和实体关系需要连接、投影或清晰表达较复杂条件
Specification组合条件对象筛选项数量动态变化
原生 SQL表、列和数据库语法必须使用数据库特有能力或精确 SQL

受监控服务实体的属性是 group,分组对象的属性是 name。派生查询可以沿属性路径查找:

src/main/java/cn/usehook/spring/monitoring/MonitoredServiceJpaRepository.java
01
package cn.usehook.spring.monitoring;
02
03
import java.util.List;
04
import org.springframework.data.jpa.repository.JpaRepository;
05
06
public interface MonitoredServiceJpaRepository
07
extends JpaRepository<MonitoredServiceEntity, Long> {
08
09
List<MonitoredServiceEntity> findByGroupNameOrderByNameAsc(String groupName);
10
}

GroupName 会被解析为 group.name,OrderByNameAsc 则按服务名称升序。方法名仍然基于 Java 属性,不是数据库列名。

派生查询适合这一简单条件,但它返回完整服务实体,调用 getGroup().getName() 时还要考虑分组关联有没有一并加载。方法名继续增长到难以阅读时,应切换为 JPQL,而不是用更多关键字勉强拼接。

2. JPQL

JPQL 查询实体及其属性。下面显式连接服务与分组:

MonitoredServiceJpaRepository.java
01
@Query("""
02
select service
03
from MonitoredServiceEntity service
04
join service.group monitoringGroup
05
where monitoringGroup.name = :groupName
06
order by service.name asc
07
""")
08
List<MonitoredServiceEntity> findEntitiesByGroupName(
09
@Param("groupName") String groupName
10
);

这段代码需要引入 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,其中要写真实表列:

native-query.java
01
@Query(
02
value = """
03
select service.*
04
from monitored_service service
05
join monitoring_group monitoring_group
06
on monitoring_group.id = service.group_id
07
where monitoring_group.name = :groupName
08
""",
09
nativeQuery = true
10
)
11
List<MonitoredServiceEntity> findNativeByGroupName(
12
@Param("groupName") String groupName
13
);

当前 JPQL 已经可以表达需求,所以不需要使用原生查询。原生 SQL 会绑定数据库表结构和方言,应留给 JPQL 无法清楚完成的场景。

3. DTO 投影

接口只需要服务编号、服务名称和分组名称时,没有必要返回两个完整实体。先定义读取模型:

src/main/java/cn/usehook/spring/monitoring/MonitoredServiceView.java
1
package cn.usehook.spring.monitoring;
2
3
public record MonitoredServiceView(
4
Long id,
5
String name,
6
String groupName
7
) {}

JPQL 构造器表达式可以直接创建这个 record:

MonitoredServiceJpaRepository.java
01
@Query("""
02
select new cn.usehook.spring.monitoring.MonitoredServiceView(
03
service.id,
04
service.name,
05
monitoringGroup.name
06
)
07
from MonitoredServiceEntity service
08
join service.group monitoringGroup
09
where monitoringGroup.name = :groupName
10
order by service.name asc
11
""")
12
List<MonitoredServiceView> findViewsByGroupName(
13
@Param("groupName") String groupName
14
);

投影只选择构造 record 所需的三列,结果不进入实体脏检查,也不依赖延迟关联。它适合只读列表和接口响应前的读取模型。

构造器表达式需要完整类名,并且参数顺序、类型必须与构造方法一致。字段变化时应通过 Repository 测试尽早发现查询与投影不匹配,而不是等到接口请求才验证。

这里的 MonitoredServiceView 是应用内部查询结果,不直接承担 HTTP 状态码或请求校验。Controller 仍然可以根据公开接口契约把它转换成响应 DTO。

4. N+1 查询

下面的代码先查询全部服务,再在循环中读取延迟分组:

n-plus-one.java
1
List<MonitoredServiceEntity> services = repository.findAll();
2
3
for (MonitoredServiceEntity service : services) {
4
System.out.println(service.getGroup().getName());
5
}

在打开的持久化上下文中,它可能先执行一条服务查询,再为不同分组执行额外查询。这类「一条主查询加多条关联查询」称为 N+1。持久化上下文会复用已加载的同一分组,所以额外查询数不一定严格等于服务数,但仍会随关联对象数量增长。

如果调用发生在事务之外,延迟关联还可能因为持久化上下文已关闭而无法初始化。解决方案不是把所有关系改成 EAGER,而是让每个 Repository 用例明确需要的数据。

需要返回实体并同时使用分组时,可以使用 fetch join:

MonitoredServiceJpaRepository.java
1
@Query("""
2
select service
3
from MonitoredServiceEntity service
4
join fetch service.group
5
order by service.name asc
6
""")
7
List<MonitoredServiceEntity> findAllWithGroup();

也可以使用实体图,不改写 JPQL:

MonitoredServiceJpaRepository.java
1
@EntityGraph(attributePaths = "group")
2
List<MonitoredServiceEntity> findAllByOrderByNameAsc();

这段代码需要引入 org.springframework.data.jpa.repository.EntityGraph。fetch join 和实体图都应针对具体方法使用,不要把实体上的默认加载策略改成全局急加载。

对于当前只读列表,DTO 投影比加载实体关系更直接;需要修改实体并依赖关联行为时,fetch join 或实体图才更合适。

5. 分页边界

分页查询服务并 fetch 它的单值 group 关联通常可行,因为一行服务仍然对应一行结果。分页查询分组并同时 fetch 集合则不同:

collection-fetch.java
1
@Query("""
2
select distinct monitoringGroup
3
from MonitoringGroupEntity monitoringGroup
4
left join fetch monitoringGroup.services
5
""")
6
Page<MonitoringGroupEntity> findGroupsWithServices(Pageable pageable);

一个分组会在 SQL 结果中按服务数量展开成多行。数据库先对展开结果分页,JPA 再去重,就可能得到少于预期的分组,Hibernate 也可能退回内存分页并产生警告。计数查询同样不能直接沿用集合 fetch join。

常用处理方式是:

  1. 第一条分页查询只取得当前页的分组 ID;
  2. 第二条查询使用这些 ID 批量加载分组与服务;
  3. 或者直接使用满足接口字段的扁平 DTO 投影;
  4. 不需要总数时考虑 Slice 或游标分页。

不要仅为了消除 N+1 就给所有查询添加集合 fetch join。加载策略必须同时考虑结果形状、分页语义、数据量和接口真正需要的字段。

6. SQL 与索引

先临时开启 SQL 和参数日志,观察一个接口到底执行几条查询:

src/main/resources/application.yaml
1
logging:
2
level:
3
org.hibernate.SQL: debug
4
org.hibernate.orm.jdbc.bind: trace

这些日志可能包含业务参数,只适合本地短期开启。确认 SQL 后,可以在测试数据库使用 PostgreSQL 执行计划:

explain-query.sql
1
explain (analyze, buffers)
2
select service.id, service.name, monitoring_group.name
3
from monitored_service service
4
join monitoring_group
5
on monitoring_group.id = service.group_id
6
where monitoring_group.name = 'backend'
7
order by service.name asc;

ANALYZE 会真实执行查询,因此这里只用于没有副作用的 SELECT。小表选择顺序扫描并不表示索引失效;优化器会根据统计信息和成本选择计划,应该使用接近真实规模的数据判断。

当前迁移只有 group_id 单列索引。查询还需要在组内按名称排序,可以用新的 Flyway 迁移把它改成复合索引:

src/main/resources/db/migration/V4__optimize_monitored_service_queries.sql
1
drop index idx_monitored_service_group;
2
3
create index idx_monitored_service_group_name
4
on monitored_service (group_id, name);

复合索引仍能支持以 group_id 为前导条件的查找,并进一步提供组内名称顺序,避免维护两个高度重叠的索引。是否采用它应由真实查询和执行计划验证,而不是看到 WHERE 与 ORDER BY 就机械建索引。

JPA 查询性能最终仍由生成的 SQL、返回行数、加载关系和数据库索引共同决定。下一篇继续处理写入边界:使用审计字段记录实体创建与修改时间,再通过版本号阻止并发请求静默覆盖彼此结果。