Spring Data JPA 查询
Spring Data JPA 提供方法名查询、JPQL、原生 SQL、Specification 等多种查询方式。选择查询方式时,要同时考虑可读性、动态条件、SQL 可控性和性能。
本页讲查询方式选择。Repository 方法如何被解析、Page 为什么会执行 count、DTO 投影和 Specification 的底层链路见 Spring Data JPA 核心全过程原理。
查询方式选择
flowchart TD
A["需要查询"] --> B{"条件是否简单"}
B -- "是" --> C["方法名查询"]
B -- "否" --> D{"条件是否动态组合"}
D -- "是" --> E["Specification"]
D -- "否" --> F{"是否面向实体查询"}
F -- "是" --> G["@Query JPQL"]
F -- "否" --> H["原生 SQL"]方法名查询
List<User> findByStatus(Integer status);
Optional<User> findByUsernameAndStatus(String username, Integer status);
List<User> findByStatusOrderByCreatedAtDesc(Integer status);适合简单、固定条件查询。
JPQL
JPQL 面向实体和属性,不是直接写表名和字段名。
@Query("select u from User u where u.status = :status order by u.createdAt desc")
List<User> findActiveUsers(Integer status);注意:User 是实体名,createdAt 是实体属性。
原生 SQL
复杂 SQL 或数据库特有语法可以使用原生 SQL:
@Query(
value = "select * from users where status = :status order by created_at desc",
nativeQuery = true
)
List<User> findByStatusNative(Integer status);原生 SQL 可控性强,但可移植性较差,也要自己关注字段映射。
DTO 查询
列表接口不建议直接返回 Entity,可以返回 DTO。
public record UserListItem(Long id, String username) {
}@Query("select new com.example.UserListItem(u.id, u.username) from User u where u.status = :status")
List<UserListItem> findUserList(Integer status);DTO 查询能避免暴露实体字段,也能减少懒加载问题。
Specification 动态查询
当查询条件来自页面筛选项,且每个条件都可能为空时,可以用 Specification。
public static Specification<User> hasStatus(Integer status) {
return (root, query, cb) -> {
if (status == null) {
return cb.conjunction();
}
return cb.equal(root.get("status"), status);
};
}组合:
Specification<User> spec = Specification.where(hasStatus(status));
Page<User> page = userRepository.findAll(spec, pageable);N+1 问题
N+1 是 JPA 最常见性能问题之一。
flowchart TD
A["查询 1 次订单列表"] --> B["得到 N 个订单"]
B --> C["逐个访问 order.user"]
C --> D["额外查询 N 次用户"]解决方式:
| 方式 | 说明 |
|---|---|
| fetch join | 查询时一次加载关联 |
| EntityGraph | 声明需要加载的关联 |
| DTO 查询 | 直接查需要字段 |
| 分开批量查询 | 先查主表,再批量查关联 |
分页查询注意
Page 会执行数据查询和 count 查询:
Page<User> page = userRepository.findByStatus(status, pageable);大表 count 可能很慢。如果只需要判断是否有下一页,可以使用 Slice。
常见问题
| 问题 | 原因 | 建议 |
|---|---|---|
| 方法名难读 | 条件太多 | 改用 @Query 或 Specification |
| 查询很慢 | SQL 不合理或缺索引 | 打印 SQL,看执行计划 |
| 序列化报错 | 懒加载或循环引用 | 返回 DTO |
| N+1 | 列表后访问关联 | fetch join 或 DTO |
| count 慢 | 大表分页 | 优化 count 或使用 Slice |
小结
Spring Data JPA 查询方式要按复杂度选择:简单查询用方法名,固定复杂查询用 JPQL,数据库特性用原生 SQL,动态条件用 Specification。性能问题要看最终 SQL,不要只看 Java 方法。
查询生成原理
Spring Data JPA 会把 Repository 方法解析成查询模型,再交给 JPA Provider 生成 SQL。
flowchart TD
A["Repository 方法"] --> B["解析方法名或 @Query"]
B --> C["生成 JPQL / Criteria"]
C --> D["Hibernate 生成 SQL"]
D --> E["数据库执行"]Demo:查询方式选择
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
@Query("select u from User u where u.status = :status order by u.id desc")
Page<User> pageByStatus(@Param("status") Integer status, Pageable pageable);
}简单查询用方法名,复杂查询用 @Query,动态组合条件再考虑 Specification。
继续深入:Spring Data JPA 核心全过程原理。
Repository 方法为什么能执行
Repository 接口没有实现类,但 Spring Data JPA 启动时会为它创建代理对象。
flowchart TD
A["启动扫描 Repository 接口"] --> B["读取泛型 Entity 和 ID"]
B --> C["创建 Repository 代理"]
C --> D["分析方法签名"]
D --> E{"方法来源"}
E -- "内置 CRUD" --> F["调用 SimpleJpaRepository"]
E -- "方法名查询" --> G["解析方法名"]
E -- "@Query" --> H["使用 JPQL 或 SQL"]
E -- "Specification" --> I["生成 Criteria 查询"]
F --> J["EntityManager 执行"]
G --> J
H --> J
I --> J所以 Spring Data JPA 不是“凭空查数据库”,它最终仍然通过 EntityManager 和 Hibernate 生成 SQL。
方法名查询的边界
简单方法名很好读:
List<Asset> findByStatusOrderByCreatedAtDesc(Integer status);但复杂方法名会变成灾难:
List<Asset> findByHospitalIdAndDeptIdInAndStatusAndAssetNameContainingAndCreatedAtBetweenOrderByCreatedAtDesc(...);问题:
- 方法名过长,难维护。
- 条件组合变化时要新增很多方法。
- 不容易看最终 SQL。
- 查询优化空间有限。
建议:
| 场景 | 推荐 |
|---|---|
| 1 到 2 个固定条件 | 方法名查询 |
| 固定复杂查询 | @Query JPQL |
| 需要数据库特性 | 原生 SQL |
| 多条件动态组合 | Specification 或 Querydsl |
| 列表接口 | DTO 投影 |
JPQL 和原生 SQL 的区别
JPQL 面向实体和属性:
@Query("""
select a
from Asset a
where a.status = :status
order by a.createdAt desc
""")
List<Asset> listByStatus(@Param("status") Integer status);原生 SQL 面向表和列:
@Query(
value = """
select *
from asset
where status = :status
order by created_at desc
""",
nativeQuery = true
)
List<Asset> listByStatusNative(@Param("status") Integer status);| 对比 | JPQL | 原生 SQL |
|---|---|---|
| 写法 | 实体名、属性名 | 表名、字段名 |
| 可移植性 | 较好 | 依赖数据库 |
| 复杂 SQL | 一般 | 强 |
| 返回 DTO | 支持构造表达式 | 需要映射 |
| 调优 | 仍要看生成 SQL | 更直接 |
DTO 投影为什么推荐
列表页只需要少量字段,不应该返回完整 Entity。
public record AssetListItem(
Long id,
String assetCode,
String assetName,
String deptName
) {
}@Query("""
select new com.demo.AssetListItem(
a.id, a.assetCode, a.assetName, d.deptName
)
from Asset a
join a.department d
where a.status = :status
order by a.createdAt desc
""")
List<AssetListItem> listItems(@Param("status") Integer status);优点:
- 只查接口需要字段。
- 避免懒加载。
- 避免循环引用。
- 避免暴露 Entity 内部字段。
- 更容易控制 SQL。
Specification 详细 Demo
查询条件:
public class AssetQuery {
private Long hospitalId;
private Long deptId;
private Integer status;
private String assetName;
}Specification:
public class AssetSpecifications {
public static Specification<Asset> byQuery(AssetQuery query) {
return (root, cq, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (query.getHospitalId() != null) {
predicates.add(cb.equal(root.get("hospitalId"), query.getHospitalId()));
}
if (query.getDeptId() != null) {
predicates.add(cb.equal(root.get("deptId"), query.getDeptId()));
}
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
if (query.getAssetName() != null && !query.getAssetName().isBlank()) {
predicates.add(cb.like(root.get("assetName"), "%" + query.getAssetName() + "%"));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}使用:
Page<Asset> page = assetRepository.findAll(
AssetSpecifications.byQuery(query),
PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))
);注意:
- Specification 可读性比长方法名好。
- 复杂 join 和报表查询仍可能不如手写 SQL 清楚。
- 要看最终 SQL 和执行计划。
- 动态条件要避免无条件全表扫描。
fetch join 和分页的坑
fetch join 可以解决 N+1,但和分页一起用要谨慎,尤其是一对多关联。
@Query("""
select a
from Asset a
join fetch a.collectLogs
where a.status = :status
""")
Page<Asset> pageAssets(Integer status, Pageable pageable);风险:
- 一对多 fetch join 会把一条主记录放大成多行。
- 数据库分页可能作用在 join 后结果上,不是主表实体上。
- Hibernate 可能内存分页,导致性能灾难。
列表页更推荐 DTO 查询或先分页查主表 ID,再批量查关联数据。
Page 查询为什么可能慢
Page<T> 通常会执行两条 SQL:
- 查询当前页数据。
- 查询总数 count。
flowchart TD
A["Repository 返回 Page"] --> B["执行数据查询"]
A --> C["执行 count 查询"]
B --> D["当前页内容"]
C --> E["总条数和总页数"]复杂查询里 count 可能比数据查询还慢。比如大表、多 join、group by、distinct。
解决:
- 不需要总数时使用
Slice。 - 手写简化 countQuery。
- 限制筛选条件和最大页码。
- 报表走异步统计或预聚合。
查询慢排查
flowchart TD
A["JPA 查询慢"] --> B["打开 Hibernate SQL 日志"]
B --> C["拿到最终 SQL 和参数"]
C --> D["数据库 EXPLAIN"]
D --> E{"是否 N+1"}
E -- "是" --> F["DTO / fetch join / EntityGraph"]
E -- "否" --> G{"count 是否慢"}
G -- "是" --> H["Slice 或手写 countQuery"]
G -- "否" --> I["检查索引、排序、分页、锁等待"]面试标准回答
Spring Data JPA 查询方式包括方法名查询、JPQL、原生 SQL、Specification、DTO 投影等。简单固定条件可以用方法名查询,复杂固定查询用 @Query JPQL,需要数据库特性时用原生 SQL,多条件动态组合可用 Specification。Repository 接口运行时由 Spring Data 创建代理,最终仍然通过 EntityManager 和 Hibernate 生成 SQL。列表接口不建议返回 Entity,优先 DTO 投影,避免懒加载、循环引用和多余字段。Page 会执行数据查询和 count 查询,大表或复杂查询不需要总数时可用 Slice。