做后端快三年的朋友可能都有这种体会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 JpaRepositoryUser, Long { ListUser findByUsernameAndStatus(String username, Integer status); ListUser findByAgeGreaterThanOrderByCreateTimeDesc(Integer age); }这种写法在条件固定、字段较少、单表查询的场景下非常高效也是我最开始接触 JPA 时最喜欢的部分。但舒适区总有边界一件事情我印象特别深某个模块需要统计不同订单状态下的销售额总和还要按商品分类分组。当时的OrderRepository方法名几乎没法表达这种需求硬凑出来是这样的// 这种写法一旦字段一多连自己都看不下去 ListObject[] 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) ListUser 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实体名是帕斯卡风格UserInfoJPQL 里只能写实体名和别名不能写表名。刚开始写 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 JpaRepositoryUser, Long { Query(select u from User u where u.email ?1) OptionalUser findByEmail(String email); Query(select u from User u where u.username :username and u.status :status) ListUser findByUsernameAndStatus(Param(username) String username, Param(status) Integer status); }第一个方法用的是位置参数?1第二个方法用的是命名参数:username。位置参数的问题是一旦方法参数多了很容易传错位置命名参数则必须在每个参数上都加Param注解但可读性明显好很多。我现在的项目里统一用命名参数阅读和维护的成本更低。3.2 参数传递的四种方式除了普通参数实际开发里还会碰到集合、对象、分页框架这几种特殊参数。具体如下表场景写法示例使用要点单个基本类型:status最常用加Param注解集合参数:usernamesJPQL 中配合in (:usernames)使用实体对象:user可以直接用:user.username形式导航属性分页参数Pageable方法参数里加PageableJPQL 不需要写limit集合参数配合IN操作能解决很多批量化查询的需求。具体用法Query(select u from User u where u.username in :usernames) ListUser findByUsernames(Param(usernames) CollectionString usernames);这里有个容易踩的坑in后面必须带括号写成in (:usernames)在某些 JPA 版本下不会自动展开成多值反而会报语法错误。我在 Spring Boot 2.0.3 下实测推荐直接写成in :usernames框架会自动展开为in (?, ?, ?)。3.3 查询结果映射的三种形态JPQL 查询结果的映射方式决定了你后续代码的写法第一种直接返回实体。这是最简单的形态适合单表完整查询。第二种返回Object[]或ListObject[]。聚合查询、部分字段查询时常用每个数组元素就是一行结果。第三种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) ListObject[] 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里的字符串默认是固定的。如果查询条件多、组合不确定我知道的靠谱方案有三个方案一用SpecificationJPA Criteria API。官方支持的动态查询方式类型安全但我个人觉得写起来代码量大。方案二用Query配合COALESCE在 SQL 层面对空值做处理。方案三在 Repository 里用default方法拼接 JPQL。我工作中用的是第三个方案看起来最灵活default ListUser searchUsers(String username, Integer status, String email) { StringBuilder jpql new StringBuilder(select u from User u where 1 1); MapString, 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); } TypedQueryUser query em.createQuery(jpql.toString(), User.class); params.forEach(query::setParameter); return query.getResultList(); }这个方案需要注入EntityManager但是非常直观也没有额外引入任何依赖。4.3 分页查询不要自己拼 SQLJPQL 分页很简单只要方法参数里加上Pageable框架会自动生成limit和offsetQuery(select u from User u order by u.createTime desc) PageUser 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) ListObject[] countUsersByStatus();返回的Object[]里索引 0 是分组字段值索引 1 是统计结果Long类型。如果想让调用方用起来更舒服可以做一个 DTO 投影Query(select new com.example.UserStatusCount(u.status, count(u)) from User u group by u.status) ListUserStatusCount countUsersByStatus();DTO 里提供一个接收这两个参数的构造方法即可。其实从这点可以看得出来JPQL 对嵌套函数、分组、聚合的支持并不比原生 SQL 弱多少只是返回结果形态需要自己掌控。5. 手写 JPQL 三个月后我才想明白的坑与经验5.1 列名不存在和属性名写错是两码事JPQL 报错信息里最经典的一条是Unable to locate Attribute with the given name看到这个基本可以断定是属性名写错了。比如实体属性是userName你写成了username就会触发。这个和数据库字段完全无关纯粹是 Java 属性名的匹配问题排查的时候一定要先打开实体类确认属性名的大小写和拼写。5.2 模糊查询的 LIKE 写法不能照搬 SQLJPQL 里like后面拼%的方式和 SQL 不太一样。像where u.username like %张%这种直接写在字符串里是不行的要这样写Query(select u from User u where u.username like concat(%, :keyword, %)) ListUser searchByKeyword(Param(keyword) String keyword);这个方法我在本地 MySQL 5.7 下测试了很多次concat是通用的 JPQL 函数在跨数据库时也比较稳。第一次用like :keyword %的时候直接报语法错误后来才知道不能用来做字符串拼接。5.3 更新操作背后的事务陷阱前面说了更新删除要加Modifying和Transactional但还有一个更隐蔽的问题批量更新和一级缓存。JPA 默认开启了持久化上下文缓存如果先查出来一个实体然后又执行批量更新更新结果不会自动同步到缓存中那个实体对象上。这会导致接下来的读请求拿到旧数据。我的解决办法是在批量更新方法内部处理完之后调用em.flush()和em.clear()强制清缓存。等后续再查询时就会从数据库重新加载保证读到的是最新值。5.4 关联查询 N1 问题的简易规避JPQL 的 join 查询在开发初期很容易触发 N1 查询问题。比如查订单列表时如果没有把关联的客户信息一次性提取出来遍历订单读取order.customer的时候每条记录都会额外发一条 SQL 去查客户表。解决方案是用join fetchQuery(select o from Order o join fetch o.customer c where o.status :status) ListOrder 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 做针对性优化。