《Spring Boot 实战》6.2 复杂查询的三种写法

派生查询、@Query(JPQL / native)与 Criteria、Querydsl 各有适用边界。本节用图书借阅服务的多条件检索场景对比三种写法的可维护性与类型安全,给出动态条件查询的落地方式、原生 SQL 的方言锁定代价,以及 4.x 下 Hibernate 7.x 的相关口径。

本节目标:为「图书检索」这类多条件查询选出可维护的实现方式,掌握派生查询的失效边界、@Query 的 JPQL/native 取舍、Criteria 与 Querydsl 的类型安全动态条件,以及 4.x 的相关版本口径。
适用版本:Spring Boot 4.1.x(Java 21)

6.2 复杂查询的三种写法

6.1 解决了「查询结果怎么出 Service」的问题,本节解决「查询条件怎么写」。图书借阅服务的第一个真实需求是检索页:按标题关键字、分类、是否有可借副本、上架时间区间,四个条件任意组合,还要分页排序。这类「条件动态组合」的查询,正是 Spring Data JPA 最容易写歪的地方。下面按复杂度递进,把三种写法各自的边界讲清。

6.2.1 派生查询:方法名能表达多远

派生查询(derived query)靠方法名推导 SQL,是 Spring Data 的招牌能力,也是最容易被滥用的一个。

简单的组合完全够用:

public interface LoanRepository extends JpaRepository<Loan, Long> {

    List<Loan> findByMemberIdAndStatus(Long memberId, LoanStatus status);

    List<Loan> findTop10ByStatusOrderByBorrowedAtDesc(LoanStatus status);

    long countByMemberIdAndReturnedAtIsNull(Long memberId);
}

但方法名一旦开始「跨关联 + 多条件」,可读性会断崖式下降:

// 反面示例:方法名已经成了一句难读的英文句子
List<Loan> findByMemberIdAndStatusAndBorrowedAtAfterAndBookCategoryOrderByBorrowedAtDesc(
        Long memberId, LoanStatus status, Instant after, String category);

判断何时该换写法,用一条经验规则:条件不超过 2 个、不跨关联、不需要 or,就用派生查询;一旦出现跨关联(BookCategory)或多个条件,就换成 @Query。 派生查询的优势是零成本、可读;一旦要靠长方法名表达逻辑,这个优势就没了,剩下的只是「改条件必须改方法名」的僵化。

6.2.2 @Query 的 JPQL:固定条件的首选

@Query 写 JPQL,条件固定、需要跨关联时最合适。文本块(text block)让多行 JPQL 可读性大幅提升:

public interface LoanRepository extends JpaRepository<Loan, Long> {

    @Query("""
            select l
            from Loan l
            join l.book b
            where b.category = :category
              and l.status = :status
            order by l.borrowedAt desc
            """)
    List<Loan> findActiveByCategory(@Param("category") String category,
                                    @Param("status") LoanStatus status);
}

JPQL 操作的是实体与属性,不是表与列,所以它天然跨数据库、天然受益于 Hibernate 的方言适配。代价是它表达不了数据库特有的能力——窗口函数、CTE、ON CONFLICT、RETURNING 都写不出来。

@Query 有一个隐藏成本要提前意识到:JPQL 是字符串,编译期不校验。 属性名写错、join 写漏,都要等应用启动时(Spring Data 会在启动阶段解析命名查询)或运行到该方法时才暴露。所以带 @Query 的 Repository 一定要有覆盖到该方法的集成测试——这类错误单测 mock 掉 Repository 时是发现不了的。

6.2.3 原生 SQL:什么时候值得付代价

当查询用到数据库专有能力时,才动用 native SQL。典型场景是「热门图书榜」这类聚合统计:

public interface PopularBook {
    Long getId();

    String getTitle();

    long getLoanCount();
}
public interface BookRepository extends JpaRepository<Book, Long> {

    @Query(value = """
            select b.id as id, b.title as title, count(l.id) as loanCount
            from book b
            left join loan l on l.book_id = b.id
            group by b.id, b.title
            order by loanCount desc
            limit 20
            """, nativeQuery = true)
    List<PopularBook> findPopularBooks();
}

用原生查询配接口投影时有一条硬规则:SQL 里的列别名必须和投影方法名(去掉 get 后首字母小写)逐字一致。 上面的 as loanCount 对应 getLoanCount(),写成 loan_count 就对不上,运行期报找不到属性。

原生 SQL 的收益与代价要摆在一起看:

收益代价
能用窗口函数、CTE、ON CONFLICT、RETURNING方言锁定,换数据库要重写
可以精确控制执行计划(配合索引提示)结果映射要手工维护(别名 ↔ 属性名)
复杂聚合、报表类查询更直观无法被 Hibernate 的二级缓存、脏检查感知
一次 join 拿到扁平结果,天然避开 N+1参数绑定、分页(countQuery)都要自己写

结论:native 是逃生舱,不是默认选项。 只在「JPQL 表达不了」或「性能必须手工调优」时才用,并且集中放在少数几个方法里,别扩散成半个应用都用原生 SQL。

6.2.4 Criteria API 与静态元模型

上面三种都是「条件固定」。真正的难点是条件动态组合:检索页的四个条件,用户可能只填其中一两个,where 子句要随之增减。字符串拼 JPQL 是最差的做法(SQL 注入风险 + 拼接错误),Spring Data 给出的正解是 Specification,它建立在 JPA Criteria API 之上。

Criteria API 是类型安全的:它用静态元模型(Book_ 这类生成类)代替字符串属性名。4.0 起,生成这些元模型类的注解处理器坐标发生了变化:

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-processor</artifactId>
    <scope>provided</scope>
</dependency>

这是 4.0 迁移指南里的明确变更:hibernate-jpamodelgen 被 hibernate-processor 取代。如果你是从 3.x 迁过来的老项目,pom.xml 里还写着旧坐标,构建时不会生成 Book_,编译直接失败。别把这两个坐标搞混——hibernate-processor 生成的是 JPA 静态元模型(Book_、Loan_),供 Criteria API 使用。

6.2.5 Specification:动态条件的主力

Specification 把每个条件封装成一个可复用的函数,再用 allOf 组合。先写条件工厂:

public final class BookSpecifications {

    private BookSpecifications() {
    }

    public static Specification<Book> titleContains(String keyword) {
        return (root, query, cb) -> (keyword == null || keyword.isBlank())
                ? cb.conjunction()
                : cb.like(cb.lower(root.get("title")), "%" + keyword.toLowerCase() + "%");
    }

    public static Specification<Book> hasCategory(String category) {
        return (root, query, cb) -> category == null
                ? cb.conjunction()
                : cb.equal(root.get("category"), category);
    }

    public static Specification<Book> available() {
        return (root, query, cb) -> cb.greaterThan(root.get("availableCopies"), 0);
    }
}

cb.conjunction() 返回一个恒真谓词,用来表示「这个条件不参与过滤」,这是动态查询里避免 null 判断扩散的关键。Repository 只需多继承一个接口:

public interface BookRepository extends JpaRepository<Book, Long>,
        JpaSpecificationExecutor<Book> {
}

调用时按需组合:

Specification<Book> spec = Specification.allOf(
        BookSpecifications.titleContains(keyword),
        BookSpecifications.hasCategory(category),
        BookSpecifications.available());

Page<Book> page = bookRepository.findAll(spec, pageable);

Specification.allOf(...) 会把多个 Specification 用 and 串起来,任一条件为空时由它自己的 conjunction() 兜底。注意 Specification.where(...) 在新版 Spring Data 里已废弃,统一改用 allOf / anyOf。

Specification 的优点是零额外依赖、条件可单元测试、可复用。缺点有两个:一是属性名仍是字符串(root.get("title")),没有编译期校验,除非配合静态元模型;二是复杂查询(多级 join + group by)用 Criteria 写出来很啰嗦,可读性不如 JPQL。

6.2.6 Querydsl:要类型安全就上它

Querydsl 用生成的 Q 类型(QBook)把属性名变成字段,彻底消灭字符串。谓词写起来接近自然语言:

import static com.example.bookloan.domain.QBook.book;

BooleanExpression predicate = book.category.eq(category)
        .and(book.availableCopies.gt(0));

if (StringUtils.hasText(keyword)) {
    predicate = predicate.and(book.title.containsIgnoreCase(keyword));
}

Page<Book> page = bookRepository.findAll(predicate, pageable);

Repository 侧继承 QuerydslPredicateExecutor<Book> 即可。Q 类型由 Querydsl 自己的注解处理器生成——注意它和 6.2.4 的 hibernate-processor 是两回事:hibernate-processor 生成 Book_(JPA 静态元模型),Querydsl 生成 QBook。引入 Querydsl 时要带 JPA 模块(Jakarta 环境用 jakarta 分类器):

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
</dependency>

三种动态查询方案的取舍:

维度SpecificationQuerydslMyBatis 风格(手写 SQL)
类型安全弱(字符串属性)强(生成字段)无(SQL 字符串)
额外依赖无querydsl-jpa + APTMyBatis starter
动态条件表达组合 Specification链式 BooleanExpressionXML <if> / 注解
复杂 join/聚合啰嗦较好最直观
与 JPA 实体图配合好好无(需另建映射)
构建影响无多一个 APT 阶段无
迁移成本低中(引入新依赖)高(脱离 JPA 体系)

选型建议:已经在用 Spring Data JPA、条件组合不复杂 → Specification;动态条件多且团队在意类型安全 → Querydsl;查询里大量数据库特有语法、或团队本就偏好「SQL 可见」 → 局部用 MyBatis,但不要在同一个服务里两套 ORM 混用。 最后这条很关键:JPA 与 MyBatis 混用会让事务、缓存、连接管理出现两套语义,边界要划在「独立模块」而不是「同一个 Repository」。

6.2.7 4.x 下的相关口径

写这一章必须对齐 4.x 的版本事实,几处容易写错的地方:

  • Hibernate 版本:4.0 的基线是 Hibernate 7.2、Jakarta Persistence 3.2、Spring Data 2025.1;4.1.x 已升级到 Hibernate 7.4、Spring Data 2026.0.0。所以「4.x 用 Hibernate 7.2」这句话对 4.0 成立,对 4.1 已不准确,写文档时要说清小版本。
  • 元模型生成器:hibernate-jpamodelgen → hibernate-processor(见 6.2.4)。
  • 持久化模块:4.0 新增 spring-boot-persistence 模块承载通用持久化代码与属性;@EntityScan 的包路径迁到 org.springframework.boot.persistence.autoconfigure.EntityScan。
  • 异常转换开关改名:spring.dao.exceptiontranslation.enabled → spring.persistence.exceptiontranslation.enabled。
  • JPA 引导:4.1 细化了 spring.data.jpa.repositories.bootstrap-mode(deferred 找不到 AsyncTaskExecutor 会直接报错,lazy 不再设置引导执行器),并新增 spring.jpa.bootstrap 用于配置 LocalContainerEntityManagerFactoryBean 的异步后台引导。

这几条都不是「可选优化」,而是升级时会直接编译失败或行为变化的地方。不确定的版本细节,一律以官方 Release Notes 为准,不要凭记忆写。

6.2.8 常见坑

坑一:用字符串拼 JPQL。 "select l from Loan l where " + condition 既有注入风险,又破坏了参数绑定。动态条件一律走 Specification / Querydsl。

坑二:派生查询跨关联导致笛卡尔积。 findByBookCategory(...) 若 Book 侧还有集合关联,可能生成意外的交叉连接。跨关联时改用显式 join 的 @Query。

坑三:原生查询忘了写 countQuery。 分页 + native 时,Spring Data 无法自动推导 count SQL,必须显式提供 countQuery,否则分页总数错误或直接报错。

坑四:Specification 里直接 join 又去重。 多集合 join 会放大行数,忘记 query.distinct(true) 会出现重复实体。这类查询优先用 @EntityGraph 控制抓取(下一节详述),而不是在 Specification 里手工拼 join。

坑五:混用 JPA 与 MyBatis 却共享事务。 两者对 @Transactional 的传播与刷新时机理解不同,混用容易产生「MyBatis 写的数据 JPA 看不到」的脏读现象。要混就分模块、分事务边界。

小结

  • 派生查询只适合「条件 ≤ 2、不跨关联」;一旦要靠长方法名表达逻辑,就换 @Query。
  • JPQL 跨数据库、操作实体属性,是固定条件查询的首选,但编译期不校验,必须有集成测试兜底。
  • native SQL 是逃生舱:能用数据库特有语法,代价是方言锁定与手工维护结果映射,别名必须与投影方法名一致。
  • 动态条件用 Specification(零依赖、可复用)或 Querydsl(类型安全);两者都用生成类,但 hibernate-processor(生成 Book_)与 Querydsl APT(生成 QBook)不是一回事。
  • 4.x 口径:4.0 为 Hibernate 7.2 / Spring Data 2025.1,4.1 为 Hibernate 7.4 / Spring Data 2026.0.0;hibernate-jpamodelgen 已被 hibernate-processor 取代。
  • 不要在同一个服务里混用两套 ORM;混用边界要划在模块级。

查询写对了,性能问题往往还没结束。6.3 会专门处理 JPA 最经典的生产事故——N+1 查询,以及 join fetch、@EntityGraph、@BatchSize 三种抓取策略的取舍。

阅读导航:上一节:6.1 Repository 分层与 DTO 转换 · 下一节:6.3 N+1 与抓取策略 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计