《Spring Boot 入门》13.2 JPQL 与原生 SQL

派生查询只能表达简单条件,复杂查询要靠 @Query。本节讲 JPQL 的语法要点与两种参数写法、@Modifying 批量更新、接口投影与类投影的取舍、nativeQuery 的适用场景与代价,以及 EntityManager、JdbcTemplate、查询超时与 JPQL 的能力边界。

本节目标:在图书服务里用 @Query 写出跨表、聚合、批量更新等派生查询表达不了的语句,并在 JPQL、原生 SQL、EntityManager、JdbcTemplate 之间做出有依据的选型。
适用版本:Spring Boot 4.1.x(Java 21)

13.2 JPQL 与原生 SQL

12.3 的派生查询很省事:findByTitleContaining 这类方法名会被自动翻译成查询。但它的表达能力有硬边界——方法名只能表达「属性 + 简单比较 + And / Or」,遇到多表连接、聚合、分组、批量更新就写不出来了。这时用 @Query 自己写语句。

@Query 有两种模式:默认的 JPQL(面向实体与属性)和 nativeQuery = true 的原生 SQL(面向表与列)。本节的图书服务已经具备 13.1 的 Book / Author / Category 关联,正好用来演示跨表查询。

13.2.1 JPQL 的语法要点:面向实体,不是表

JPQL(Jakarta Persistence Query Language)最容易被忽略的一点是:它操作的是实体名和属性名,不是表名和列名。对比看:

维度JPQL原生 SQL
查询对象实体类名 Book表名 book
字段属性名 publishedYear列名 published_year
关联b.author.name 直接用点号导航必须 JOIN author a ON ...
大小写实体名区分大小写依数据库而定
public interface BookRepository extends JpaRepository<Book, Long> {

    @Query("select b from Book b where b.author.name = :authorName")
    List<Book> findByAuthorName(String authorName);
}

b.author.name 这种属性导航是 JPQL 的便利之处:Hibernate 会根据映射自动补出 JOIN author,不需要你手写连接条件。查询里出现的 Book 是实体名(可用 @Entity(name = "...") 改写),author、name 是属性名。

13.2.2 位置参数与命名参数

两种参数绑定方式:

// 位置参数:?1 表示第一个参数,按顺序对应
@Query("select b from Book b where b.title like ?1 and b.price > ?2")
List<Book> search(String titlePattern, BigDecimal minPrice);

// 命名参数::name 与 @Param 对应,推荐
@Query("select b from Book b where b.title like :title and b.price > :minPrice")
List<Book> search(@Param("title") String title, @Param("minPrice") BigDecimal minPrice);

两者都能用,但推荐命名参数:位置参数一旦调整参数顺序,?1、?2 与实参就对不上,且编译期无法发现;命名参数靠名字匹配,重排参数不影响正确性。注意 @Param 来自 org.springframework.data.repository.query.Param,别导错包。

还有一个便利特性:如果参数是 Pageable 或 Sort,Spring Data 会自动处理,不需要在 JPQL 里写 order by 或 limit:

@Query("select b from Book b where b.category.name = :category")
Page<Book> findByCategoryName(@Param("category") String category, Pageable pageable);

13.2.3 @Modifying 做批量更新与删除

@Query 默认只用于查询。要执行 update / delete,必须加 @Modifying:

@Modifying
@Transactional
@Query("update Book b set b.price = b.price * :rate where b.category.id = :categoryId")
int raisePriceByCategory(@Param("categoryId") Long categoryId, @Param("rate") BigDecimal rate);

三个要点:

  1. @Modifying 告诉 Spring Data「这是写操作」,返回类型通常是 int(受影响行数)。
  2. 写操作必须在一个事务里,否则抛 TransactionRequiredException。可以直接在 Repository 方法上加 @Transactional,或由调用它的 Service 保证事务边界。
  3. 批量更新/删除绕过持久化上下文——数据库里的行改了,但当前 Session 里已加载的实体内存值仍是旧的。若同一事务里更新后还要读这些实体,用:
@Modifying(clearAutomatically = true, flushAutomatically = true)

flushAutomatically = true 先刷掉待写数据,clearAutomatically = true 更新后清空一级缓存,强制下次查询重新读库。

13.2.4 投影:只查需要的列

列表接口往往只需要「书名 + 作者名」几个字段,把整个 Book 实体查出来是浪费。Spring Data 支持三种投影。

接口投影:定义一个只含 getter 的接口,返回类型写它:

public interface BookSummary {
    String getTitle();
    String getAuthorName();
}

@Query("select b.title as title, b.author.name as authorName from Book b")
List<BookSummary> findSummaries();

as title 里的别名必须与 getter 名对应(getTitle ↔ title),否则绑定为 null。

类投影(构造器表达式):JPQL 直接 new 一个 DTO,类型最安全:

public record BookDto(Long id, String title, String authorName) {
}

@Query("""
        select new com.example.library.web.dto.BookDto(b.id, b.title, b.author.name)
        from Book b
        """)
List<BookDto> findDtos();

new 后面必须是全限定类名,构造器参数顺序要与 select 一致。这是三种投影里唯一能被编译期校验字段类型的。

Object[] 投影:不定义任何类型,直接返回数组:

@Query("select b.id, b.title from Book b")
List<Object[]> findRawRows();
投影方式类型安全可读性建议
接口投影中高快速只读视图
类投影高高需要强类型的 DTO
Object[]低低临时调试,不推荐进生产代码

Object[] 的问题在于「第 3 个元素是什么」全靠记忆,重构时极易错位;能用类投影就用类投影。

13.2.5 原生 SQL:什么时候值得用

有些需求 JPQL 表达不了,或写了会非常笨拙。典型场景:

  • 使用数据库特有语法,如 PostgreSQL 的 ON CONFLICT、MySQL 的 INSERT ... ON DUPLICATE KEY;
  • 调用数据库函数或窗口函数(row_number() over (...));
  • 复杂的报表查询,用原生 SQL 更直观、更易调优;
  • 需要命中特定索引、手写优化过的语句。
@Query(value = """
        select b.* from book b
        join (
            select author_id, max(published_year) as latest
            from book group by author_id
        ) t on t.author_id = b.author_id and t.latest = b.published_year
        """, nativeQuery = true)
List<Book> findLatestPerAuthor();

原生 SQL 的代价必须清楚:

代价说明
不可移植换数据库(MySQL → PostgreSQL)可能要重写
手动映射返回列名要与实体列名对齐,否则映射出错或为 null
无属性导航表名、列名、连接条件全部手写
与实体脱节字段改名后 SQL 不会自动跟着改,靠测试兜底

一条实用原则:能写 JPQL 就写 JPQL,只有 JPQL 确实表达不了或性能确实不行时才下沉到原生 SQL,并在方法注释里写清为什么。

13.2.6 EntityManager 与 JdbcTemplate 什么时候上

@Query 覆盖不了所有情况,还有两个「逃生舱」:

工具定位适用场景
EntityManagerJPA 的底层 API动态拼接 JPQL、CriteriaBuilder 构建复杂条件
JdbcTemplate纯 JDBC 封装报表、批量导入、不涉及实体的裸查询

动态条件的典型需求是「按可选条件过滤」——分类为空就不加分类条件。JPQL 字符串拼接容易出错,CriteriaBuilder 更安全:

@Repository
public class BookQueryRepository {

    @PersistenceContext
    private EntityManager em;

    public List<Book> search(String category, BigDecimal minPrice) {
        var cb = em.getCriteriaBuilder();
        var cq = cb.createQuery(Book.class);
        var root = cq.from(Book.class);
        var predicates = cb.and();
        if (category != null) {
            predicates.add(cb.equal(root.get("category").get("name"), category));
        }
        if (minPrice != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("price"), minPrice));
        }
        cq.where(predicates);
        return em.createQuery(cq).getResultList();
    }
}

JdbcTemplate 则完全绕开 JPA,直接面对 SQL 与 RowMapper,适合「查询结果根本不是实体」的统计报表:

String sql = "select category_id, count(*) from book group by category_id";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);

选择顺序建议:派生查询 → @Query(JPQL) → @Query(原生 SQL) → EntityManager/Criteria → JdbcTemplate,逐级下沉,能用上层就别用下层。

13.2.7 JPQL 的能力边界与绕行

JPQL 不是完整 SQL,几个常见限制:

不支持绕行
INSERT 语句用 EntityManager.persist 或 save 逐条/批量插入
部分数据库函数用原生 SQL,或注册 Hibernate 自定义函数
SELECT * 语义只能 select b(整个实体)或显式列出属性
部分 LIMIT 写法交给 Pageable,不要在 JPQL 里写 limit

其中 INSERT 最常被问到。JPQL 规范里只有 UPDATE 与 DELETE,没有 INSERT。批量插入的替代方案是:saveAll(受 hibernate.jdbc.batch_size 影响)或 JdbcTemplate.batchUpdate。

13.2.8 查询超时与 @QueryHints

慢查询会拖垮连接池。给单条查询设超时可以防止个别语句长时间占用连接:

@QueryHints(@QueryHint(name = "jakarta.persistence.query.timeout", value = "3000"))
@Query("select b from Book b where b.title like :kw")
List<Book> search(@Param("kw") String keyword);

jakarta.persistence.query.timeout 的单位是毫秒(JPA 标准提示,注意命名空间已从 javax.* 变为 jakarta.*)。也可以给整个应用设默认值:

spring:
  jpa:
    properties:
      jakarta.persistence.query.timeout: 5000

需要注意的是,超时提示是建议性的,底层驱动是否支持、以秒还是毫秒解释,取决于数据库。生产环境还应配合数据库侧的语句超时与连接池的 connection-timeout 一起兜底。

13.2.9 用真实 SQL 日志对比 JPQL 与原生 SQL

打开 show-sql 后,同一条业务查询的 SQL 差异一目了然。以「查某作者的所有书」为例,JPQL 写法:

@Query("select b from Book b where b.author.name = :name order by b.publishedYear desc")
List<Book> findByAuthorNameOrdered(@Param("name") String name);

生成的 SQL:

select b1_0.id,b1_0.author_id,b1_0.category_id,b1_0.price,b1_0.published_year,b1_0.title
from book b1_0
join author a1_0 on a1_0.id=b1_0.author_id
where a1_0.name=?
order by b1_0.published_year desc

注意 b.author.name 被自动展开成了一条 join author——JPQL 里你没写连接,SQL 里 Hibernate 替你补了。而如果写成原生 SQL:

@Query(value = "select b.* from book b join author a on a.id = b.author_id " +
        "where a.name = ?1 order by b.published_year desc", nativeQuery = true)
List<Book> findByAuthorNameNative(String name);

生成的 SQL 与你写的完全一致,没有额外的翻译步骤。这带来两个直接差异:

  • 可控性:原生 SQL 的执行计划完全由你决定,调优时所见即所得;
  • 耦合:表名 book、列名 published_year 一旦改名,JPQL 会跟着实体改,原生 SQL 不会。

用日志核对时,建议同时打开 format_sql,并配 logging.level.org.hibernate.SQL=debug 看到带参数值的绑定信息,便于排查「条件没生效」这类问题。

13.2.10 常见坑速查

坑现象解决
JPQL 里写了表名/列名启动或首次调用报「无法解析属性」改用实体名与属性名
忘写 @Modifyingupdate 语句被当查询执行,报错写操作加 @Modifying
@Modifying 后读旧值实体还是更新前的值clearAutomatically = true
投影别名不匹配接口投影字段全为 nullas 别名与 getter 名一致
类投影报「找不到构造器」new 后用了简类名写全限定类名
原生 SQL 返回 null列名与实体列不对齐用别名对齐或自定义 RowMapper
事务外执行 @ModifyingTransactionRequiredException加 @Transactional

小结

  • JPQL 面向实体名与属性名,b.author.name 会自动展开成 join;原生 SQL 面向表名与列名,所见即所得。
  • 参数绑定优先用命名参数 :name + @Param,避免位置参数顺序错位。
  • 批量更新删除要 @Modifying + @Transactional,必要时加 clearAutomatically / flushAutomatically。
  • 投影三选一:接口投影快、类投影类型安全、Object[] 不推荐进生产。
  • 原生 SQL 适合数据库特有能力与报表,代价是不可移植、需手动映射。
  • 选型自上而下:派生查询 → JPQL → 原生 SQL → EntityManager → JdbcTemplate。
  • JPQL 没有 INSERT,分页交给 Pageable,超时用 @QueryHints 的 jakarta.persistence.query.timeout。

写好了查询,列表接口还差最后一块拼图:分页与排序。下一节我们看 Pageable 背后的两条 SQL,以及深分页的性能问题。

阅读导航:上一节:13.1 关联映射 · 下一节:13.3 分页与排序 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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