news 2026/10/13 13:50:31

Spring Data JPA复杂查询实战:用JPQL手写SQL替代方法名查询

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Data JPA复杂查询实战:用JPQL手写SQL替代方法名查询

做后端快三年的朋友可能都有这种体会:Spring Data JPA 用久了,最顺手的是那些findByUsernameAndStatus之类的方法名查询,一眼就能看懂,根本不用写 SQL。可一旦遇到多表关联、聚合统计、动态条件、批量更新,那串方法名就会变得又臭又长,甚至直接编译报错。这个系列第 8 篇,我就专门讲怎么在 Spring Boot 2.0.3 + MySQL 5.7 这套组合下,用 JPQL 手写 SQL,把这层窗户纸捅破。

1. 什么时候该抛弃方法名查询

1.1 方法名查询的舒适区与天花板

先说说方法名查询有多舒服。写一个UserRepository,只要继承JpaRepository,什么都不干,框架就会根据方法名自动生成实现。比如:

public interface UserRepository extends JpaRepository<User, Long> { List<User> findByUsernameAndStatus(String username, Integer status); List<User> findByAgeGreaterThanOrderByCreateTimeDesc(Integer age); }

这种写法在条件固定、字段较少、单表查询的场景下非常高效,也是我最开始接触 JPA 时最喜欢的部分。但舒适区总有边界,一件事情我印象特别深:某个模块需要统计不同订单状态下的销售额总和,还要按商品分类分组。当时的OrderRepository方法名几乎没法表达这种需求,硬凑出来是这样的:

// 这种写法一旦字段一多,连自己都看不下去 List<Object[]> findSomethingBy... // 根本不知道怎么往下拼接

最后还是老老实实回到了写查询语句的老路。

1.2 为什么选择 JPQL 而不是原生 SQL

在实际项目里,很多人一遇到复杂查询,第一反应是直接用@Query(nativeQuery = true)写原生 SQL。这确实能解决很多问题,但有一个长期隐患:SQL 与数据库方言深度耦合。比如LIMIT语法在 MySQL 下能跑,换成 Oracle 或 PostgreSQL 就可能出问题。而 JPQL 操作的是实体对象和属性,底层会根据配置的方言自动翻译成对应的 SQL,从跨数据库的角度看是更稳的选择。

更重要的是,JPQL 能直接和实体状态联动。比如多表关联时,JPQL 可以直接写order.customer.username,导航到Order实体关联的Customer属性,而原生 SQL 通常要自己做 join 和结果映射。这个开发效率差异在字段多的场景下非常明显。

所以我的建议是:单表固定查询用方法名,跨库兼容性要求高或者实体关联关系复杂时优先 JPQL,只有涉及索引优化、复杂报表或 MySQL 特有语法(如FOR UPDATE这类)时才用原生 SQL。

2. JPQL 不是 SQL:先搞懂这套思维转换

2.1 查的是实体,不是表

JPQL 和 SQL 最大的区别就是操作对象不同。看一个最简单例子:

@Query("select u from User u where u.age > ?1") List<User> findAdultUsers(Integer age);

这里的User是实体类名,u.age对应的是User类中的age属性,而不是数据库表里的age列。这个转换是 JPA 通过@Table、@Column等注解完成的。所以写 JPQL 之前,脑子里一定要有一个"实体模型图",而不是 ER 图。

这个转换逻辑也解释了为什么调select u时不需要写select *:JPQL 的select u默认会把整个实体所有字段都查出来,等同于 SQL 的select *。如果只想要部分字段,就要显式列出属性,比如select u.username, u.age from User u。

2.2 别名为什么这么讲究

JPQL 里的别名不只是为了缩写,它是定位实体的关键。select u from User u中,u代表了当前查询范围内的User实体的一个引用。多表关联时每个实体都要有自己的别名,后续过滤条件、排序、分组都需要通过别名引用属性。

这里我踩过的一个坑是:别名和表名混淆。数据库里表名通常是下划线风格(user_info),实体名是帕斯卡风格(UserInfo),JPQL 里只能写实体名和别名,不能写表名。刚开始写 JPQL 时我总是不自觉地写select * from user_info,结果立刻报StatePath相关的异常,因为 JPA 根本没有去解析数据库表名。

2.3 关键字的写法约定

JPQL 关键字本身不区分大小写,比如SELECT、select都能用,但表名、属性名必须严格一致。我个人的习惯是关键字全大写,这样和属性名区分明显,后期排查问题也快。另外需要注意,JPQL 里不能使用*号,select *是 SQL 写法,在 JPQL 中必须声明别名再查询。

3. @Query 注解的核心玩法完整拆解

3.1 一个最小的 @Query 示例

@Query是手写 JPQL 的入口,直接写在 Repository 接口的方法上:

public interface UserRepository extends JpaRepository<User, Long> { @Query("select u from User u where u.email = ?1") Optional<User> findByEmail(String email); @Query("select u from User u where u.username = :username and u.status = :status") List<User> findByUsernameAndStatus(@Param("username") String username, @Param("status") Integer status); }

第一个方法用的是位置参数?1,第二个方法用的是命名参数:username。位置参数的问题是,一旦方法参数多了,很容易传错位置;命名参数则必须在每个参数上都加@Param注解,但可读性明显好很多。我现在的项目里统一用命名参数,阅读和维护的成本更低。

3.2 参数传递的四种方式

除了普通参数,实际开发里还会碰到集合、对象、分页框架这几种特殊参数。具体如下表:

场景写法示例使用要点
单个基本类型:status最常用,加@Param注解
集合参数:usernamesJPQL 中配合in (:usernames)使用
实体对象:user可以直接用:user.username形式导航属性
分页参数Pageable方法参数里加Pageable,JPQL 不需要写limit

集合参数配合IN操作,能解决很多批量化查询的需求。具体用法:

@Query("select u from User u where u.username in :usernames") List<User> findByUsernames(@Param("usernames") Collection<String> usernames);

这里有个容易踩的坑:in后面必须带括号,写成in (:usernames)在某些 JPA 版本下不会自动展开成多值,反而会报语法错误。我在 Spring Boot 2.0.3 下实测,推荐直接写成in :usernames,框架会自动展开为in (?, ?, ?)。

3.3 查询结果映射的三种形态

JPQL 查询结果的映射方式决定了你后续代码的写法:

  • 第一种,直接返回实体。这是最简单的形态,适合单表完整查询。
  • 第二种,返回Object[]或List<Object[]>。聚合查询、部分字段查询时常用,每个数组元素就是一行结果。
  • 第三种,DTO 投影。JPQL 支持直接select new com.example.UserDto(u.id, u.username) from User u,但 DTO 必须有对应的构造方法,包路径也要写全。

实际开发中,我一般会在 DTO 投影和实体返回之间做取舍。如果查询结果需要被后续逻辑修改并保存到数据库,就返回实体;如果只是读出来展示,建议直接投影成 DTO,避免多余字段传输。

3.4 更新和删除必须记住的两个注解

JPA 默认查询是读操作,执行更新、删除时必须额外加@Modifying,不然会直接报QueryExecutionRequestException之类的错误。同时,这个方法还必须在事务中执行,否则更新不会生效。

@Modifying @Transactional @Query("update User u set u.status = :status where u.id = :id") int updateStatus(@Param("id") Long id, @Param("status") Integer status);

注意这里的返回值是int,表示受影响的行数。还有个细节是,@Modifying模式下默认不会触发 JPA 的一级缓存清理,如果你后续还在同一事务里查询这个实体,需要自己调EntityManager.clear(),不然会读到旧数据。这个问题刚接触时很容易忽略。

4. 复杂业务场景下的 JPQL 实操案例

4.1 多表关联查询怎么做

项目里最常见的还是关联查询。比如查询所有订单以及对应的客户名字:

@Query("select o, c.username from Order o left join o.customer c where o.status = :status") List<Object[]> findOrdersWithCustomer(@Param("status") Integer status);

这里的关键是left join o.customer c。注意 JPQL 里 join 的不再是表,而是实体之间的关联属性。Order 实体里必须有一个customer属性,并且配置了@ManyToOne或类似的关系映射。很多人第一次写会写成left join Customer c,这样也能跑,但实际上查的是笛卡尔积,而不是关联关系,结果往往令人意外。

返回结果为什么是Object[]?因为select o, c.username查了两个来源,JPA 没法自动封装成某个实体,所以每行结果是一个数组,索引 0 是 Order 对象,索引 1 是客户名字字符串。

4.2 动态条件该用什么方案

JPQL 本身不支持动态拼接条件,@Query里的字符串默认是固定的。如果查询条件多、组合不确定,我知道的靠谱方案有三个:

  • 方案一:用Specification(JPA Criteria API)。官方支持的动态查询方式,类型安全,但我个人觉得写起来代码量大。
  • 方案二:用@Query配合COALESCE,在 SQL 层面对空值做处理。
  • 方案三:在 Repository 里用default方法拼接 JPQL。

我工作中用的是第三个方案,看起来最灵活:

default List<User> searchUsers(String username, Integer status, String email) { StringBuilder jpql = new StringBuilder("select u from User u where 1 = 1"); Map<String, Object> params = new HashMap<>(); if (StringUtils.hasText(username)) { jpql.append(" and u.username like :username"); params.put("username", "%" + username + "%"); } if (status != null) { jpql.append(" and u.status = :status"); params.put("status", status); } if (StringUtils.hasText(email)) { jpql.append(" and u.email = :email"); params.put("email", email); } TypedQuery<User> query = em.createQuery(jpql.toString(), User.class); params.forEach(query::setParameter); return query.getResultList(); }

这个方案需要注入EntityManager,但是非常直观,也没有额外引入任何依赖。

4.3 分页查询不要自己拼 SQL

JPQL 分页很简单,只要方法参数里加上Pageable,框架会自动生成limit和offset:

@Query("select u from User u order by u.createTime desc") Page<User> findAllUsers(Pageable pageable);

调用方传入PageRequest.of(0, 10, Sort.by("createTime").descending())就行。这里有一点需要特别注意:如果 JPQL 带了order by,又在外面套一层PageRequest的排序,两边的排序可能会冲突。我的习惯是排序只保留一处,避免出现意外的乱序。

4.4 聚合统计与分组查询的结果处理

聚合查询是不能返回实体的。比如统计每个状态下的用户数量:

@Query("select u.status, count(u) from User u group by u.status") List<Object[]> countUsersByStatus();

返回的Object[]里,索引 0 是分组字段值,索引 1 是统计结果Long类型。如果想让调用方用起来更舒服,可以做一个 DTO 投影:

@Query("select new com.example.UserStatusCount(u.status, count(u)) from User u group by u.status") List<UserStatusCount> countUsersByStatus();

DTO 里提供一个接收这两个参数的构造方法即可。其实从这点可以看得出来,JPQL 对嵌套函数、分组、聚合的支持并不比原生 SQL 弱多少,只是返回结果形态需要自己掌控。

5. 手写 JPQL 三个月后我才想明白的坑与经验

5.1 列名不存在和属性名写错是两码事

JPQL 报错信息里最经典的一条是Unable to locate Attribute with the given name,看到这个基本可以断定是属性名写错了。比如实体属性是userName,你写成了username,就会触发。这个和数据库字段完全无关,纯粹是 Java 属性名的匹配问题,排查的时候一定要先打开实体类确认属性名的大小写和拼写。

5.2 模糊查询的 LIKE 写法不能照搬 SQL

JPQL 里like后面拼%的方式和 SQL 不太一样。像where u.username like %张%这种直接写在字符串里是不行的,要这样写:

@Query("select u from User u where u.username like concat('%', :keyword, '%')") List<User> searchByKeyword(@Param("keyword") String keyword);

这个方法我在本地 MySQL 5.7 下测试了很多次,concat是通用的 JPQL 函数,在跨数据库时也比较稳。第一次用like :keyword + '%'的时候直接报语法错误,后来才知道+不能用来做字符串拼接。

5.3 更新操作背后的事务陷阱

前面说了更新删除要加@Modifying和@Transactional,但还有一个更隐蔽的问题:批量更新和一级缓存。JPA 默认开启了持久化上下文缓存,如果先查出来一个实体,然后又执行批量更新,更新结果不会自动同步到缓存中那个实体对象上。这会导致接下来的读请求拿到旧数据。

我的解决办法是,在批量更新方法内部处理完之后,调用em.flush()和em.clear()强制清缓存。等后续再查询时,就会从数据库重新加载,保证读到的是最新值。

5.4 关联查询 N+1 问题的简易规避

JPQL 的 join 查询在开发初期很容易触发 N+1 查询问题。比如查订单列表时,如果没有把关联的客户信息一次性提取出来,遍历订单读取order.customer的时候,每条记录都会额外发一条 SQL 去查客户表。解决方案是用join fetch:

@Query("select o from Order o join fetch o.customer c where o.status = :status") List<Order> findOrdersWithCustomer(@Param("status") Integer status);

join fetch会提前把关联对象一并查出,避免逐条查询。但要注意,join fetch和分页同时使用时,某些方言下会报in memory分页的警告,具体还取决于数据库方言和关联集合的类型。一对多集合关联时尤其需要留意。

5.5 从 Spring Boot 2.0.3 的角度多说一句

这一篇是基于 springboot 2.0.3 写的,对应的是 Spring Data JPA 2.0.x 的 API 风格。如果你当前项目用的是 Spring Boot 3.x,命名参数、@Query注解这些核心用法完全没变,但 Jakarta 持久化的包名从javax.persistence变成了jakarta.persistence,碰到导入报错别慌,把包名改过来就好。核心思维还是通用的。

回过头来总结一下,手写 JPQL 真正的门槛不在语法,而在"从表思维切换到实体思维"。一开始写慢、报错多、心里骂 JPA 是常态,等慢慢适应了实体属性导航这种模式之后,你会发现复杂查询的代码量反而比以前写原生 SQL 更精简。我个人现在写新功能时,默认第一版就是用 JPQL 起手,跑不通或者性能瓶颈明显时,才会考虑换原生 SQL 做针对性优化。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 13:49:07

EmbeddingGemma 2:740M端侧多模态统一向量空间实战

1. 这个 740M 的小模型到底在解决什么问题第一次看到 EmbeddingGemma 2 这个项目的时候&#xff0c;我正被一个跨模态检索的需求折磨得够呛。当时手头的场景很典型&#xff1a;用户上传一张商品图&#xff0c;系统要能自动匹配到对应的文字描述、相关的语音备注&#xff0c;甚至…

作者头像 李华
网站建设 2026/10/13 13:49:05

Java开发者的大模型应用指南:LangChain4j核心概念与RAG实操

1. 为什么 Java 开发者现在该认真看看 LangChain4j 这两年大模型应用开发的热度基本被 Python 生态吃掉了&#xff0c;LangChain、LlamaIndex 这些框架一个接一个&#xff0c;搞得很多写 Java 的同学心里发慌&#xff1a;难道后端业务想接个大模型&#xff0c;还得再学一门语言…

作者头像 李华
网站建设 2026/10/13 13:48:01

TEMU招商岗面试题拆解:陌生品类第一周怎么做?

想进 TEMU 招商岗&#xff1f;这个面试题直接背答案&#xff01;说了你别不信&#xff0c;我最近被问得最多的面试题&#xff0c;不是什么“你最大的缺点是什么”&#xff0c;也不是“你为什么选择我们公司”&#xff0c;而是一道听起来特别朴素、实际上藏了不知道多少坑的题&a…

作者头像 李华
网站建设 2026/10/13 13:47:02

RFM客户分层模型:为什么80年老方法仍是运营决策基石

1. 为什么今天还在用80年前的RFM模型做客户分层我第一次在某电商公司的数据看板上看到“RFM分群”四个字时&#xff0c;下意识以为是新出的AI模型缩写。结果打开SQL脚本一看&#xff0c;核心逻辑就三行&#xff1a;最近一次购买时间、购买频次、累计消费金额——连字段名都带着…

作者头像 李华