本节目标:在图书服务里用
@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);
三个要点:
@Modifying告诉 Spring Data「这是写操作」,返回类型通常是int(受影响行数)。- 写操作必须在一个事务里,否则抛
TransactionRequiredException。可以直接在 Repository 方法上加@Transactional,或由调用它的 Service 保证事务边界。 - 批量更新/删除绕过持久化上下文——数据库里的行改了,但当前 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 覆盖不了所有情况,还有两个「逃生舱」:
| 工具 | 定位 | 适用场景 |
|---|---|---|
EntityManager | JPA 的底层 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 里写了表名/列名 | 启动或首次调用报「无法解析属性」 | 改用实体名与属性名 |
忘写 @Modifying | update 语句被当查询执行,报错 | 写操作加 @Modifying |
@Modifying 后读旧值 | 实体还是更新前的值 | clearAutomatically = true |
| 投影别名不匹配 | 接口投影字段全为 null | as 别名与 getter 名一致 |
| 类投影报「找不到构造器」 | new 后用了简类名 | 写全限定类名 |
原生 SQL 返回 null | 列名与实体列不对齐 | 用别名对齐或自定义 RowMapper |
事务外执行 @Modifying | TransactionRequiredException | 加 @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 分页与排序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。