《Spring Boot + JPA 实战》第7招 JPQL还是SQL

最近复盘一个订单中心项目,同事在代码里写了一个方法名,长得像一列火车:findTop100ByCustomerCreatedAtAfterOrderByTotalAmountDesc。周会上大家盯着这个方法名沉默了五秒,最后还是没人敢说这个方法哪里有问题。我是老李,今天不聊高大上的概念,就聊聊复杂查询到底该怎么选型,为什么方法名不是万能钥匙,以及 JPQL、@Query 和 Native SQL 的边界到底在哪里。

01、痛点

很多团队用 Spring Data JPA,第一步一定是方法名派生查询。它确实省事,findByStatus、findByMobile、findByCreatedAtBetween,简单查询几乎没有成本。可一旦碰到“近30天消费 Top100 客户”“批量更新订单状态”“按月汇总 GMV”这类真实业务,方法名就开始露怯。

首先,表达能力见底。方法名派生查询只是把方法名翻译成 where 条件,能表达属性过滤、排序、分页,表达不了日期计算、聚合、分组、Top N 加过滤再排序。你没法在方法名里写“近30天”这种相对时间,更没法写 sum、group by、having。

其次,@Query 写成一锅粥。很多人退一步用 @Query,但分不清 JPQL 和 Native SQL。同一个 Repository 里,有的方法写 JPQL,有的方法 nativeQuery=true,字段名一会儿是实体属性,一会儿是数据库列名。时间一长,维护的人根本不知道这段查询在操作什么,改一个字段要翻三处。

第三,复杂报表硬套 JPQL。报表要 join 五张表、做窗口函数、按数据库时区处理日期、再分页。JPQL 要么不支持,要么写出来又长又绕,最后生成的 SQL 还不可控。强行套用导致代码既不像对象查询,又不像数据库查询,两边不讨好。

更离谱的是,有人为了凑方法名,查全量数据再用 Java 流过滤,内存占用和响应时间都爆炸。慢查询多了,DBA 开始抓 SQL,发现一百行代码里一半是废操作。这些问题根源不是 JPA 不行,而是没有给查询分层。

02、是什么

先分清几个概念。

JPQL 是 JPA 标准定义的面向对象查询语言,操作的是 Entity 和属性,不是表名和列名。比如查询客户,JPQL 写的是 select c from Customer c where c.createdAt >= :since,这里的 Customer 是实体类,createdAt 是属性。HQL 是 Hibernate 自己的查询语言,语法和 JPQL 高度相似,Spring Data JPA 里通常用 JPQL。

@Query 不是一种新语言,它只是 Spring Data JPA 提供的一个注解,用来在 Repository 方法上写 JPQL 或 Native SQL。当 nativeQuery=false(默认)时,@Query 的内容是 JPQL;当 nativeQuery=true 时,@Query 的内容是原生 SQL。

Native SQL 面向真实表、索引、数据库特性。它操作的是数据库里的表和列,如 orders、created_at,可以用 date_sub、date_format、窗口函数、索引提示等方言能力。Native SQL 强调数据库控制力,能精确利用执行计划、索引和方言函数。

DTO Projection 则是查询结果映射的一种方式,它和查询语言不是一个维度。你可以用 JPQL 查完映射到 DTO,也可以用 Native SQL 查完映射到 DTO。它解决的是“返回什么结构”,而不是“用什么语言查”。

很多开发者会把 HQL 和 JPQL 混为一谈。简单说,JPQL 是规范,HQL 是 Hibernate 方言实现。静态查询里两者几乎无差别,但一旦用到方言特性,HQL 可能扩展更多。Spring Data JPA 默认走 JPQL,背后由 Hibernate 执行。

两者的边界可以概括为:JPQL 面向模型可维护性,把实体关系、继承、懒加载等抽象保留下来;Native SQL 面向数据库控制力,把真实表结构、索引、方言特性暴露出来。

维度 JPQL Native SQL
操作对象 实体和属性 表和列
可移植性 更强 较弱
数据库特性 需要方言扩展 直接使用
复杂聚合 可用但受限 充分支持
维护成本 简单查询低,复杂查询高 简单查询高,复杂查询低

03、为什么

为什么复杂查询不能只靠方法名?因为方法名派生查询的设计初衷是简化简单查询,而不是替代 SQL。一旦查询维度变多,方法名首先会膨胀成不可读的长串;其次方法名无法表达聚合、子查询、计算字段、数据库函数,这些能力必须借助 JPQL 或 SQL。

JPQL 适合实体关系清晰、查询结果可以直接映射实体或少量字段的场景。它跟数据库解耦,切换数据库时更轻松。但代价是:复杂聚合、窗口函数、数据库特定函数、复杂分页时,JPQL 要么写不出来,要么依赖 Hibernate 方言扩展,可移植性优势反而变成可读性负担。

Native SQL 适合报表、批量更新、跨表复杂统计、需要数据库特性的场景。它的问题是和表结构强耦合,表改名、列改名、数据库迁移时成本高。所以 Native SQL 不是万能药,而是复杂查询的“最后一道门”。

举个例子,同样查某个客户最近30天购买金额,JPQL 要写日期参数传进来,Service 层算好 start 时间;Native SQL 可以直接用 date_sub。前者更可移植,后者更直接。复杂报表往往包含十几个这类计算,JPQL 的代码量会迅速超过 SQL,且更难调优。

这里有一个容易忽略的点:ORM 的价值是减少不必要的 SQL,并不是禁止写 SQL。很多人把 JPA 当成“不能写 SQL”的框架,结果在复杂查询上死磕 JPQL,写出又长又慢的查询。正确姿势是根据查询复杂度逐级选择工具,而不是用一把锤子敲所有钉子。

04、架构

我把查询升级路线整理成四层:

第一层:Derived Query,也就是方法名派生查询。适合字段等值过滤、简单排序、分页,比如 findByStatusOrderByCreatedAtDesc。这层能保证可读性和维护成本都很低。

第二层:JPQL,通过 @Query 写面向实体的查询。适合多条件动态过滤、简单聚合、关联查询,结果映射到实体或 DTO。这层兼顾可维护性和表达能力。

第三层:Specification,配合 JpaSpecificationExecutor,适合动态拼接查询条件,比如后台筛选条件很多、条件之间可选。它仍然是 JPQL 层面的抽象,但能避免大量的 if 分支拼 JPQL。

第四层:Native SQL,通过 @Query(nativeQuery=true) 或自定义 Repository 实现。适合报表统计、复杂 join、窗口函数、批量更新、数据库特性依赖强的场景。这层追求数据库控制力和执行效率。

升级原则是:能用简单方式表达的,不要上复杂方式;但一旦复杂查询已经超出当前层的能力,就果断升级。最怕的是在 JPQL 层拼命模拟 SQL 功能,或者在 Native SQL 层写简单查询,增加维护成本。判断标准是业务意图是否清晰、性能是否可控、维护成本是否最低。

这四层不是教条,而是决策顺序。有人问 Specification 和 JPQL 是不是重复?其实 Specification 解决的是动态条件组合,JPQL 解决的是静态复杂查询。动态条件多时用 Specification,能保持 Repository 方法简洁。

05、实战

下面用三个真实场景分别说明。

场景一:近30天消费 Top100 客户。
这个查询既要过滤近30天,又要按客户聚合,还要按消费金额排序并取前100。方法名写不出来,我们尝试 JPQL。

JPQL 可以这样写:

select c.id, c.name, sum(o.amount)
from Order o join o.customer c
where o.createdAt >= :start
group by c.id, c.name
order by sum(o.amount) desc

配合 setMaxResults(100) 限制结果。这个 JPQL 能工作,但要注意几点:group by 里必须包含 select 中出现的实体属性;sum(o.amount) 在 order by 中需要重复;如果要映射到 DTO,还需要写全限定构造器表达式,例如 new com.example.dto.CustomerTopDto(c.id, c.name, sum(o.amount))。DTO Projection 在这里能避免返回 Object[],让结果结构更清晰。不过要注意 DTO 构造器参数顺序必须和 select 一致,否则运行时报错很难查。

如果近30天还要按自然日过滤,并且金额需要数据库做精度处理,JPQL 生成的 SQL 不一定符合索引优化。此时可以降级到 Native SQL:

select c.id as customer_id, c.name as customer_name, sum(o.amount) as total_amount
from orders o
join customers c on o.customer_id = c.id
where o.created_at >= date_sub(now(), interval 30 day)
group by c.id, c.name
order by total_amount desc
limit 100

SQL 里可以直接使用 date_sub 和 limit,执行计划也更容易控制。

场景二:批量更新订单状态。
这个场景是更新操作,不是查询聚合。JPQL 支持批量更新:

update Order o set o.status = :newStatus where o.id in :ids

配合 @Modifying 和事务可以完成。但要注意 @Modifying 要加 @Transactional,批量更新后要 clearAutomatically,避免一级缓存脏数据。

如果更新条件复杂,比如“更新最近7天未支付且金额大于100的订单状态”,JPQL 仍然能写,但一旦涉及子查询、join 更新,JPQL 批量更新表达能力很有限。很多数据库的 update 可以 join,JPQL 不支持 update 里 join,只能用子查询绕,生成 SQL 后性能可能很差。

Native SQL 则可以:

update orders o
join (
  select id from orders where status = 'UNPAID'
  and created_at < date_sub(now(), interval 7 day)
  and amount > 100
) t on o.id = t.id
set o.status = 'EXPIRED'

这种写法直接面向表,能利用数据库的 update join 特性,执行路径更可控。批量更新属于数据库控制力优先的场景,Native SQL 往往是更合理的选择。

场景三:按月汇总 GMV。
按月汇总需要把 created_at 转成月份。JPQL 本身没有统一的日期格式化函数,可以写 function('date_format', o.createdAt, '%Y-%m'),但这种写法依赖 Hibernate 方言,换数据库可能报错。强行用 JPQL 时,通常还要在 Java 里再做一次格式化,或者用数据库无关的 function 注册,维护成本不低。

Native SQL 直接写:

select date_format(created_at, '%Y-%m') as month, sum(amount) as gmv
from orders
where created_at >= :start
group by month
order by month

数据库方言的 date_format、to_char、extract 各不一样,但至少在特定数据库里写得清楚,执行计划也透明。如果团队确定数据库栈,Native SQL 是更务实的选择。注意 Native SQL 参数用 :start 时,Spring Data JPA 会按名称绑定,方法里要加 @Param。

通过这三个场景可以看出:查询类型不同,适合的语言也不同。Top100 可以用 JPQL 加 DTO Projection,批量更新和按月汇总更偏向 Native SQL。

06、洞见

为什么复杂报表强行使用 JPQL 有时比直接 SQL 更难维护?

第一,JPQL 的抽象层次在复杂报表里会变成障碍。报表通常需要多表 join、聚合、分组、过滤、排序、分页、计算字段,JPQL 要表达这些,往往需要写很长的 group by、重复表达式,还要小心实体关系导致的隐式 join。可读性下降得很快。

第二,JPQL 生成的 SQL 不可控。JPQL 由持久化框架翻译成 SQL,翻译规则对开发者不完全透明。复杂查询里,你很难预测它会不会产生笛卡尔积、有没有用上索引、子查询是否被优化。出了性能问题,开发者要先去抓生成的 SQL,再反向理解 JPQL 为什么生成这样,排查链路更长。

第三,数据库特性被屏蔽。报表常用窗口函数、CTE、索引提示、日期函数、排序规则,这些在 JPQL 里要么不支持,要么依赖方言扩展。强行用 JPQL 模拟数据库特性,等于绕远路。

第四,维护成本错位。复杂报表往往和业务口径紧密相关,口径变化频繁。JPQL 里改一个聚合字段,可能涉及 select、group by、order by 多处同步修改。Native SQL 虽然与表结构耦合,但复杂逻辑写得更直白,反而更容易改。

我曾经遇到过一个报表查询,JPQL 写了 80 行,生成的 SQL 执行 15 秒;改成 Native SQL 后 40 行,执行 2 秒。原因不是 JPQL 语言差,而是它不适合这个场景。

所以,边界感很重要。JPQL 负责把简单查询和实体操作变简洁,Native SQL 负责把复杂查询和数据库特性变透明。两者不是对立,而是互补。

07、行动

我建议团队形成一条明确的查询升级路线:

第一步,先问是否能用 Derived Query 表达。如果只是等值过滤、简单排序、分页,直接写方法名,别上 @Query。

第二步,如果方法名不够,再写 JPQL。保持查询面向实体,结果尽量映射到 DTO,少返回 Object[]。DTO Projection 能让查询结果结构化,避免后续取值错位。

第三步,如果查询条件动态变化很多,引入 Specification 做动态拼接,不要在 Repository 里写一堆 if 拼 JPQL 字符串。

第四步,如果碰到复杂报表、批量更新、窗口函数、方言函数,果断上 Native SQL。写 Native SQL 时,单独放在 Repository 接口或专门的查询类里,注释清楚表结构和业务口径。

同时建议加一条团队规范:每次代码评审时,如果看到 JPQL 里出现 function、复杂子查询、重复 group by 表达式,就要问一句“这个查询是不是该用 Native SQL”。如果看到 Native SQL 只是简单等值过滤,也要问一句“这个是不是该用 JPQL”。

升级不是单向的,有时也可以降级。核心标准是:哪个层次的代码更能表达业务意图、更容易维护、性能更可控,就用哪个。

08、小结

复杂查询不能只靠方法名,更不能把 JPQL 当成唯一选择。JPQL 面向实体和属性,Native SQL 面向真实表、索引和数据库特性,二者分别强调模型可维护性和数据库控制力。真正成熟的团队,不是避免写 SQL,而是知道什么时候写什么 SQL。记住一句话:ORM 的价值是减少不必要的 SQL,并不是禁止写 SQL。

09、链接与概念说明

  • Derived Query:Spring Data JPA 根据方法名自动生成查询的实现方式,适用于简单查询。
  • JPQL:JPA 标准查询语言,操作 Entity 和属性,与数据库方言解耦。
  • HQL:Hibernate 查询语言,语法与 JPQL 类似。
  • @Query:Spring Data JPA 注解,可用于声明 JPQL 或 Native SQL。
  • Native SQL:数据库原生 SQL,直接操作表和列,可利用数据库特性。
  • DTO Projection:将查询结果映射到 DTO 对象,避免返回 Object[]。
  • Specification:JPA 动态查询接口,适合多条件组合查询。
posted @ 2026-10-10 23:50  李福春  阅读(2)  评论(0)    收藏  举报