这几年做后端开发,只要是和数据库打交道,基本绕不开 Spring Data JPA。用得越久就越会发现一个矛盾:方法名派生查询(findByXXX、countByXXX)确实爽,写起来快,读起来也直观,可一旦遇到多表关联、动态条件或者稍微复杂点的统计报表,那套“按方法名自动生成 SQL”的魔法就撑不住了。方法名长得像天书,IDE 里补全都补不动,后期维护更是灾难。
@Query 注解就是干这个用的。它是 Spring Data JPA 在 Repository 接口方法上声明自定义查询语句的入口,让你脱离命名规范的束缚,直接用 JPQL 或原生 SQL 表达意图。这篇文章把我这几年在项目里用 @Query 积累的经验、踩过的坑、以及值得注意的细节全部整理出来,从基础用法到高级场景,帮你在实际开发中真正拿捏住这个注解。
先说明一下适用范围:本文适用于使用 Spring Boot 2.x/3.x 搭配 Spring Data JPA 的项目,基础代码基于常见的 User、Order 实体演示。内容尽量讲透“原理 + 场景 + 实操”,刚开始接触 JPA 的同学也能跟得上。
1. @Query 注解解决的核心矛盾
1.1 方法名派生查询的“天花板”
Spring Data JPA 最大的卖点是“零 SQL”。你只需要在 Repository 接口里定义一个方法,比如:
public interface UserRepository extends JpaRepository<User, Long> { List<User> findByLastNameAndFirstName(String firstName, String lastName); }框架会根据方法名自动解析并生成查询。这个方法名不算长,还勉强能读。但一旦条件超过三四个,情况就开始失控:
List<User> findByStatusAndRoleAndAgeBetweenAndCreatedAtAfterAndDeletedFalse( Integer status, String role, Integer startAge, Integer endAge, LocalDateTime startTime);这行代码已经很难一眼看出含义,而且这种派生查询存在几个硬伤:
- 查询逻辑被“编码”进方法名,语义被拆得稀碎,改一个条件就等于改方法名,所有调用处全部跟着变。
- 这种查询只适合“固定条件、固定条数”的简单场景。想要动态条件或者子查询,方法名根本表达不了。
- 方法数量会爆炸。同一个实体,按不同的排列组合写出十几个方法,Repository 文件越来越大,看着都头疼。
而 @Query 直接把 SQL/JPQL 写在注解里,方法的语义就变成了“名字是给人读的,SQL 才是给数据库执行的”,这是编程思维上一个很重要的转变。
1.2 @Query 真正擅长的事情
@Query 适合处理的场景,我用四个关键词来概括:
- 难。多表关联、子查询、聚合统计、CASE WHEN 这类逻辑,方法名写不出来,只能靠显式查询语句。
- 变。查询条件涉及动态拼装(比如“可选参数 + 分页排序”),虽然动态拼接严格来说更适合 Specification,但 @Query 配合可空参数的写法在简单场景下完全够用。
- 快。需要手工控制 SQL 来走索引、用上数据库特有的功能(例如 MySQL 的 FOR UPDATE、LIMIT 语法),这时候 nativeQuery 模式能直接执行原生 SQL。
- 稳。并发场景下的乐观锁更新、批量修改删除,需要显式写 UPDATE/DELETE 语句,而不是先 SELECT 再逐条改。
清楚了它能干什么,后面所有细节就都好理解了。
2. 两种查询模式:JPQL 与原生 SQL
2.1 JPQL:操作实体而非表
@Query 默认使用 JPQL(Java Persistence Query Language)。它最大的特点是面向实体和字段,而不是面向数据库表和列。比如:
public interface UserRepository extends JpaRepository<User, Long> { @Query("SELECT u FROM User u WHERE u.status = 1") List<User> findActiveUsers(); }这里的User是实体类名,u.status中的status是实体的属性名。JPQL 在底层会被翻译成当前数据库方言对应的 SQL,所以你的语句里不需要也不应该出现真实的表名和列名。
JPQL 的一个隐藏好处是:换数据库不用改查询。我在一个项目里从 MySQL 切到 PostgreSQL,所有 JPQL 原封不动,只需要调配置文件里的方言。这一点和 MyBatis 那种与 SQL 绑死的方式完全是两个思路。
JPQL 还继承了对关联关系的天然支持。假设 User 和 Order 是一对多关系,实体里写了@OneToMany List<Order> orders,那用 JPQL 查询时可以这样:
@Query("SELECT u FROM User u JOIN u.orders o WHERE o.totalAmount > :amount") List<User> findUsersWithLargeOrders(@Param("amount") BigDecimal amount);看到没有,JOIN 用的是“实体属性导航”而不是物理外键关系。这是 JPA 面向对象思想的直接体现。
2.2 原生 SQL:把控制权握在自己手里
当把nativeQuery设为 true 时,@Query 写的就是数据库原生 SQL 了:
@Query(value = "SELECT * FROM t_user WHERE status = 1 AND create_time > :startTime", nativeQuery = true) List<User> findRecentUsers(@Param("startTime") LocalDateTime startTime);原生 SQL 的典型使用场景:
- 查询涉及复杂的窗口函数、JSON 函数、全文检索等数据库特性,JPQL 没有对应的表达能力。
- 需要通过 SQL hint 控制执行计划。
- 需要对某条 SQL 做精细调优,比如手工指定 FORCE INDEX。
- 统计报表类需求,SQL 里大量使用 GROUP BY,甚至需要 UNION。
但要给你提个醒:原生 SQL 是突破 JPA 抽象层的一把双刃剑。换数据库时这些语句大概率要重新Review,而且返回结果的映射规则——列名对应实体字段——需要你自己心里有数。
2.3 两种模式的选择原则
我给团队定的一个务实原则是这样的:
- 单表、简单连表、条件固定,用 JPQL。理由:可移植性好,编译期能检查实体属性名。
- 报表、分组聚合、复杂连表、需要数据库调优,用原生 SQL。理由:直接对着真实库表写,执行计划可控。
- 两种模式混用没有任何问题,同一个 Repository 里可以既有 JPQL 方法又有 nativeQuery 方法。
另外注意一个细节:JPQL 的 SELECT 默认可以省略。比如@Query("FROM User u WHERE u.status = 1")是完全合法的,写法上等同于SELECT u FROM User u。不过隐含查询所有字段在只查部分列时会多出无用字段的映射,我个人的习惯还是写完整SELECT u。
3. 参数绑定:@Query 的核心细节
3.1 位置参数的坑与习惯
最早期的 @Query 支持位置参数,用?1、?2来引用方法参数的位置:
@Query("SELECT u FROM User u WHERE u.status = :status AND u.age > :age")这是命名参数的写法。还有一种早期写法是位置参数:
@Query("SELECT u FROM User u WHERE u.status = ?1 AND u.age > ?2") List<User> findByStatusAndAge(Integer status, Integer age);注意?1表示第一个参数,从 1 计数,不是从 0。这种写法有个很直观的问题:一旦修改了方法参数的顺序或增删参数,SQL 里所有位置引用都得跟着改,极其容易埋雷。除了极少数历史项目,我强烈建议一律使用命名参数。
3.2 命名参数 + @Param
命名参数是主流做法。每个参数用冒号开头引用,并通过@Param注解绑定方法形参:
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt >= :startTime") List<User> findActiveUsersAfter(@Param("status") Integer status, @Param("startTime") LocalDateTime startTime);这里有几个容易忽略的点:
:status冒号和参数名之间不能有空格。- 参数名和实体属性名一致时可读性最高,但不要求必须一致。JPQL 里用的是
:参数名,它引用的是 @Param 里写的名字。 - 如果方法参数没有标 @Param,Spring Data 会尝试用“参数名称发现”(编译时需要
-parameters参数)来做隐式绑定。这依赖编译配置,不太稳,不如老老实实写 @Param。
3.3 列表参数的 IN 查询
IN 查询是使用频率非常高的写法。列表参数配IN,写法很自然:
@Query("SELECT u FROM User u WHERE u.id IN :ids") List<User> findByIds(@Param("ids") Collection<Long> ids);也可以把范围再扩大一点,IN 子查询也行:
@Query("SELECT u FROM User u WHERE u.id IN (SELECT o.userId FROM Order o WHERE o.totalAmount > :amount)") List<User> findUsersWithBigOrders(@Param("amount") BigDecimal amount);注意:当ids为空集合时,这个查询会得到空结果而不是报错,Spring Data 会帮你把这种“空集合条件”处理成一个永不成立的条件。这在外部参数为空时相当于“查不到数据”,需要注意是否符合业务预期。
3.4 LIKE 模糊查询的两个写法陷阱
LIKE 查询是最容易出问题的点。错误示范:
@Query("SELECT u FROM User u WHERE u.name LIKE %:keyword%") List<User> searchByName(String keyword);这个写法在 JPA 规范里是非法的,JPQL 的 LIKE 语法要求模式字符串用字面量或字符串拼接表达式,不能直接把%放到占位符外面。实际运行会抛IllegalArgumentException或解析失败。
正确写法有两种。
第一种,把%拼进参数里:
@Query("SELECT u FROM User u WHERE u.name LIKE %:keyword%") List<User> searchByName(@Param("keyword") String keyword);调用时传入%包裹的值:
repository.searchByName("%张%");第二种,用 CONCAT 拼接:
@Query("SELECT u FROM User u WHERE u.name LIKE CONCAT('%', :keyword, '%')") List<User> searchByName(@Param("keyword") String keyword);如果只模糊匹配前缀,推荐使用 JPQL 的startswith风格命名方法,或者 LIKE 配合:keyword%这种在参数后拼%的写法:
@Query("SELECT u FROM User u WHERE u.name LIKE CONCAT(:keyword, '%')") List<User> findByNamePrefix(@Param("keyword") String keyword);再说一个细节:%这个字符在 JPQL 里如果只是普通文本的一部分,需要转义。默认转义字符是\,如果你要查找名字里本身就带%的字符串,得写LIKE :keyword ESCAPE '\\'之类的显式转义。我很少见人用,但确实是个隐蔽的坑。
3.5 对象参数与属性导航
@Query 的参数除了基本类型,还可以直接传实体对象,然后通过「属性导航」访问其内部字段:
@Query("SELECT u FROM User u WHERE u.department.id = :#{#user.department.id}") List<User> findUsersInSameDepartment(@Param("user") User user);这种写法配合 SpEL 表达式(后面详细讲)可以实现更动态的参数提取。如果只是固定取对象的某个属性,直接简化成:
@Query("SELECT u FROM User u WHERE u.department.id = :deptId") List<User> findByDeptId(@Param("deptId") Long deptId);在 Service 层调用时把user.getDepartment().getId()取出来传进去,逻辑反而更直白。
4. 高级细节:分页、投影与数据变更
4.1 分页查询的正确姿势
@Query 配合分页非常强大,只需要引入Pageable参数,框架会自动生成 count 查询和分页 SQL:
@Query("SELECT u FROM User u WHERE u.status = :status") Page<User> findByStatus(@Param("status") Integer status, Pageable pageable);调用方式:
Page<User> page = userRepository.findByStatus(1, PageRequest.of(0, 10, Sort.by("createdAt").descending()));框架自动做的事情:先执行一条SELECT COUNT(u) FROM User u WHERE u.status = :status统计总数,再执行带LIMIT(MySQL 方言下)的数据查询,包装成Page对象返回。
这里有几个必须注意的点:
Pageable参数不能用在只有List返回类型的方法里——那样 Pageable 会被忽略,返回全部匹配数据。- 分页查询必须保证排序字段稳定,否则“重复数据跨页”会严重干扰前端体验。
- 如果查询里有 JOIN FETCH 或复杂聚合,框架自动生成的 count 查询可能不是最优的,可以自己补一个
countQuery。
自定义 count 语句的写法:
@Query(value = "SELECT u FROM User u LEFT JOIN u.orders o WHERE o.status = :orderStatus", countQuery = "SELECT COUNT(u) FROM User u LEFT JOIN u.orders o WHERE o.status = :orderStatus") Page<User> findByOrderStatus(@Param("orderStatus") Integer orderStatus, Pageable pageable);4.2 限定返回条数与 Streamable
有时候并不需要分页,只需要限制返回条数。JPA 的LIMIT在 JPQL 里不直接支持,但可以通过Pageable做到“取前 N 条”:
@Query("SELECT u FROM User u WHERE u.status = 1") List<User> findTopActiveUsers(Pageable pageable);调用:userRepository.findTopActiveUsers(PageRequest.of(0, 5)),就只查前 5 条。
Spring Data 还提供Streamable作为返回类型做流式处理:
@Query("SELECT u FROM User u WHERE u.status = 1") Streamable<User> findActiveUsersStreamable();调用时repository.findActiveUsersStreamable().stream().forEach(...),对于需要链式操作的场景比直接拿 List 更顺手。注意理解这是一个增量的流,数据量大时不要反复toList()造成内存压力。
4.3 投影查询:只取需要的列
生产中经常会遇到“只想查某几个字段,不想把整个实体加载出来”的需求。JPA 有两种投影方案。
接口投影——定义接口,用 getter 对应列名:
public interface UserSummary { Long getId(); String getUsername(); String getEmail(); } @Query("SELECT u.id AS id, u.username AS username, u.email AS email FROM User u WHERE u.status = :status") List<UserSummary> findSummaryByStatus(@Param("status") Integer status);Spring Data 会在运行时生成代理实现,把查询结果的列映射到接口方法上。列别名必须和接口 getter 对应,否则会映射失败或返回 null。
类投影(DTO)——使用 JPQL 的构造器表达式:
public class UserView { private Long id; private String username; public UserView(Long id, String username) { ... } } @Query("SELECT new com.example.dto.UserView(u.id, u.username) FROM User u WHERE u.status = :status") List<UserView> findViewByStatus(@Param("status") Integer status);要点:
new后面的路径必须是全限定类名,并且这个类必须有对应参数的构造函数。- 构造函数参数顺序和类型必须和 SELECT 后面完全一致。
- 返回的普通类是非托管实体,对它做任何修改都不会同步回数据库,这点和直接返回实体完全不同。
在字段较多、查询结果用于展示层而非业务层时,投影能显著减少网络传输和实体初始化的开销。
4.4 @Modifying:更新与删除
JPA 默认只允许 @Query 执行查询语句。要执行 UPDATE 或 DELETE,必须加上@Modifying,同时方法所在场景需要事务支持。
@Modifying @Query("UPDATE User u SET u.status = :newStatus WHERE u.status = :oldStatus") int updateStatus(@Param("oldStatus") Integer oldStatus, @Param("newStatus") Integer newStatus);删除类似:
@Modifying @Query("DELETE FROM User u WHERE u.id = :id") int deleteById(@Param("id") Long id);几个关键细节:
@Modifying默认flushAutomatically = false,你可以显式开启:@Modifying(clearAutomatically = true, flushAutomatically = true)。clearAutomatically = true会在执行后清理持久化上下文,防止缓存中的旧数据干扰后续查询;flushAutomatically = true会在执行前把未刷新的实体变化同步到数据库,避免修改被新语句覆盖。- 执行 UPDATE/DELETE 的方法必须在事务中运行,否则会抛
TransactionRequiredException。在 Service 层方法上标@Transactional是最常见的做法。 - 批量修改会被 Spring Data 的
SimpleJpaRepository内部先 flush 一次。如果同一个事务里先查询实体改属性、再调批量 UPDATE,要注意结果可能和你预想的不同,配合clearAutomatically使用更安全。
4.5 原生 SQL 做 UPDATE 时的注意
原生模式修改语句的写法和 MyBatis 很像:
@Modifying(clearAutomatically = true) @Query(value = "UPDATE t_user SET status = :newStatus WHERE status = :oldStatus", nativeQuery = true) int updateStatusNative(@Param("oldStatus") Integer oldStatus, @Param("newStatus") Integer newStatus);这种写法适合跨表 UPDATE 或使用数据库特有语法(比如JOIN UPDATE)。但要注意,原生 SQL 不会自动帮你维护 @Version 乐观锁字段,如果实体有并发版本控制需求,宁愿先 SELECT 再 UPDATE,或者手动把 version 字段写进 SET 语句。
4.6 乐观锁与 @Query 的纠缠
JPA 的乐观锁通常靠实体的@Version字段在持久化上下文中自动维护。但你一旦走 @Modifying 批量 UPDATE,JPA 就不会自动更新 version。
这意味着多种风险:并发场景下可能发生“丢失更新”。一个比较常见的做法是把 version 字段显式写进 UPDATE 条件:
@Modifying @Query("UPDATE User u SET u.status = :newStatus, u.version = u.version + 1 WHERE u.id = :id AND u.version = :version") int updateStatusWithVersion(@Param("id") Long id, @Param("version") Long version, @Param("newStatus") Integer newStatus);返回值为 0 就说明期间被改了,需要在业务层处理冲突。这是“乐观锁下批量更新”的经典解法。
5. SpEL 表达式:让查询更灵活
5.1 动态表名:#{#entityName}
SpEL(Spring Expression Language)可以在 @Query 里引用上下文信息。最常用的一个就是#{#entityName},它会根据 Repository 泛型自动带入实体名:
@Query("SELECT u FROM #{#entityName} u WHERE u.status = 1") List<User> findAllActive();实体上如果通过@Entity(name = "NewUser")自定义了实体名,SpEL 会自动跟随。这看起来有点绕,但优势在继承体系里特别明显:父接口写一个通用查询,子接口继承后#entityName自动变成各个实体的名称,不会写死。
5.2 集合参数的动态展开:#{#ids}
如果参数是集合,可以用 SpEL 把它展开成逗号分隔的列表:
@Query("SELECT u FROM User u WHERE u.id IN (#{#ids})") List<User> findCustomIn(@Param("ids") List<Long> ids);这里和IN :ids区别在于展开时机:IN :ids是 JPA 参数绑定,IN (#{#ids})是 SpEL 表达式直接拼接。实际开发中优先用IN :ids,因为参数绑定能走更好的预编译和缓存;只有遇到某些动态 SQL 片段确实需要用 SpEL 时才使用这种写法。
5.3 可选条件的动态拼装
SpEL 可以配合条件运算符实现“可选参数”的效果:
@Query("SELECT u FROM User u WHERE u.status = 1 " + "AND (:name IS NULL OR u.name LIKE CONCAT('%', :name, '%'))") List<User> searchUsers(@Param("name") String name);(:name IS NULL OR u.name LIKE ...)这种模式非常实用:参数为空时,条件自动失效;参数非空时才参与过滤。注意:name IS NULL需要 JPA 2.1+ 支持,Spring Data JPA 默认都满足。
但这个写法的代价是:当参数为空时,SQL 里总有一个u.name LIKE CONCAT('%', NULL, '%')的无效判断,数据库优化器不一定能消除它。数据量小无所谓,数据量千万级时建议还是用 Specification 动态拼接,让 SQL 更干净。
6. 常见问题与排查技巧实录
6.1 方法名与 @Query 冲突
@Query("SELECT u FROM User u WHERE u.status = 1") List<User> findByStatus(Integer status);如果方法名里带的status参数和 @Query 里的条件不一致,你会发现 Spring Data以 @Query 为准,方法名不再参与解析。这本身不算问题,但很容易误导:方法名里写findByStatus却传入了别的参数,调用方以为是按状态过滤,实际上执行的 SQL 根本不看参数。规避办法是让方法名尽量贴合查询含义,比如改成findAllActive()。
6.2 表名/列名不匹配的两个关键场景
JPQL 里写错实体名或属性名,启动时或首次调用时会抛QuerySyntaxException或PropertyReferenceException。这里有个易错点:JPQL 是大小写敏感的。实体名User写成了user会解析失败,属性username写成了userName也可能失败。
原生 SQL 里写错表名列名,抛的是BadSqlGrammarException之类的数据库异常。如果是从 JPQL 移植到 nativeQuery,记得把原先的实体/属性全部替换成数据库真实表/列名。
6.3 返回类型不匹配:Object[] 无处不在
原生 SQL 查询多列但用 List<实体> 接收,是最常见的翻车现场:
@Query(value = "SELECT u.id, u.username FROM t_user u WHERE u.status = 1", nativeQuery = true) List<User> findUserRows(); // 会映射失败或拿到 null 字段原生 SQL 默认返回的可能是一堆Object[]或Tuple。要解决只需要做两件事之一:
- 在查询里
SELECT *或SELECT u.*,并且结果为整个实体。 - 使用接口投影(见 4.3 节),列别名与接口 getter 对齐。
6.4 修改查询必须加事务,不能省
初学者容易忽略:register 一个 @Modifying 方法后直接调用,结果抛TransactionRequiredException。只要方法上有 @Modifying,就必须在事务里跑。如果不想污染 Service 层,也可以在 Repository 方法上加@Transactional:
@Modifying @Transactional @Query("UPDATE User u SET u.status = 2 WHERE u.id = :id") int markInactive(@Param("id") Long id);在 Repository 层直接加事务是允许的,但对于跨多个方法的业务操作,还是应该在 Service 层统一控制事务边界。
6.5 分页与 count 查询的性能隐患
复杂查询下,JPA 自动生成的 count 查询可能极其低效。典型的情况是:主查询 JOIN 了一个一对多关系,count 会去 COUNT 关联行导致数字膨胀,或者 count 时把所有 JOIN 都带上但实际不需要。
解决方案就是手写countQuery(见 4.1 节)。我踩过一次比较深的教训:一张订单关联明细表的查询,自动 count 耗时是主查询的 20 倍,手写 countQuery 只统计主表关联条件后耗时降到毫秒级。不要小看这个参数。
6.6 LIKE 与特殊字符转义
搜索场景里用户输入的%、_会被当成通配符,导致搜索结果不正确。解决办法是用显式 ESCAPE:
@Query("SELECT u FROM User u WHERE u.name LIKE CONCAT('%', :keyword, '%') ESCAPE '\\'") List<User> searchByName(@Param("keyword") String keyword);然后在 Service 层对关键字里的%、_、\做转义:
String escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");这块比较绕,实际项目里如果要支持“搜索特殊符号”的需求,还是值得认真处理一下的。
6.7 关联查询与 N+1 问题的平衡
@Query 里用 JOIN FETCH 可以避免 N+1:
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id") Optional<User> findByIdWithOrders(@Param("id") Long id);但 JOIN FETCH 有两条限制:不能和分页的自动 count 查询良好配合,也不能在一个查询里同时 FETCH 两个集合关联(会触发MultipleBagFetchException)。遇到这些场景,要么拆成多个查询,要么牺牲一部分性能用@EntityGraph,属于另一个话题,这里就不展开了。
7. 实操示例:从需求到 @Query 落地
这里用一个综合案例,把上面的知识点串起来。
业务需求:根据关键词和用户状态分页查询用户,同时统计每个用户的订单总金额。
第一步,定义投影接口:
public interface UserOrderStat { Long getId(); String getUsername(); BigDecimal getTotalAmount(); }第二步,写 @Query:
public interface UserRepository extends JpaRepository<User, Long> { @Query(value = "SELECT u.id AS id, u.username AS username, SUM(o.totalAmount) AS totalAmount " + "FROM User u LEFT JOIN u.orders o " + "WHERE u.status = :status " + "AND (:keyword IS NULL OR u.username LIKE CONCAT('%', :keyword, '%')) " + "GROUP BY u.id, u.username", countQuery = "SELECT COUNT(DISTINCT u.id) FROM User u LEFT JOIN u.orders o " + "WHERE u.status = :status " + "AND (:keyword IS NULL OR u.username LIKE CONCAT('%', :keyword, '%'))") Page<UserOrderStat> findUserOrderStats(@Param("status") Integer status, @Param("keyword") String keyword, Pageable pageable); }第三步,Service 层调用:
@Transactional(readOnly = true) public Page<UserOrderStat> queryStats(Integer status, String keyword, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "totalAmount")); return userRepository.findUserOrderStats(status, StringUtils.hasText(keyword) ? keyword : null, pageable); }这个例子把命名参数、LIKE、可选空条件、接口投影、自定义 count、分页全组合进来了。看着复杂,其实每一块都是前面讲过的知识点。实际开发中这就是很典型的“报表查询”形态。
补充一句,分页排序字段totalAmount在 JPQL 里是查询结果的列别名,Spring Data 会把Sort.by("totalAmount")翻译进 order by,注意别用“实体字段”和“投影别名”混在一起,否则排序不生效。
8. 多数据源与 @Query 的联动
一个项目里切多数据源时,@Query 本身不会变,变的只是@Transactional的transactionManager和@Entity表的物理归属。但有一个点要特别注意:nativeQuery 是直接打到当前连接的数据源,不同数据库方言带来的 SQL 语法差异会被放大。
比如同一个findByIds,MySQL 下写成FORCE INDEX,PG 下就需要改pg_hint_plan或完全去掉。为了让切换数据源时省心,我的建议是:
- 业务逻辑复杂到必须用数据库特性时,把这类查询独立成方法,并做好命名标识(比如
findXxxForMysql、findXxxForPg),在重构数据源时改动范围最小。 - 能用 JPQL 表达的绝对不要碰 nativeQuery,这是长期维护最稳的策略。
9. 性能相关的补充提醒
使用 @Query 时不光是写对语法,还要关心执行计划和数据量级。
- LIKE 前置通配符(
%xxx%)无法走普通索引,数据量大时慎用。更多考虑配合全文索引。 - COUNT 和实际分页查询的字段选择要在 index 设计阶段一起考虑,不要等慢查询出来才补救。
- 原生 SQL 里如果加了
FOR UPDATE,记得评估锁粒度是否在可控范围。 - 查询返回大对象列表时,能投影就投影,别把整个实体和它的关联关系全部物化出来。生产上一个简单的
SELECT *都可能因为@OneToMany的默认 LAZY 触发额外访问,投影是一道很好的防火墙。
10. 最后的经验沉淀
上面这些用法,单独拎出来都不难,但组合起来就是一套完整的 @Query 使用体系。我最后想再强调几个多年实践下来的体会:
第一,能读的代码才是好代码。@Query 写出来如果不加任何注释,三个月后你自己都未必看得懂。养成为复杂查询保留一行注释的习惯,说明业务意图和核心逻辑,比把知识封印在脑子里强得多。
第二,原生 SQL 是手段不是目的。每次想上 nativeQuery 之前,先问自己三个问题:JPQL 是不是真的表达不了?数据库特性是不是必须用?以后换库会不会痛苦?三个答案如果都是否,那就继续用 JPQL。
第三,务必要有“计数意识”。分页查询里,findBy...和countBy...不是同一回事;自动生成的 count 查询,在一些复杂 JOIN 场景下会算歪。亲手写一遍countQuery,你对查询的掌控力会上一个台阶。
第四,事务和缓存边界要清晰。批量 UPDATE 之后,持久化上下文里可能还躺着旧数据,clearAutomatically和flushAutomatically这两个属性该开就开,不要因为默认值省事而留下理论上的隐患。
@Query 注解的门槛很低,低到会写接口方法就能用;但它的上限也很高,高到能承载复杂报表、动态查询、乐观锁这类实战级难题。把它彻底玩明白,你的 Spring Data JPA 项目从“能用”到“好用”,就差这一层功夫。
最后分享一个我经常用的小技巧:写复杂 @Query 之前,先在数据库客户端里把原生 SQL 调通,确认结果集长什么样,再决定是用 JPQL 还是 nativeQuery,以及返回类型应该用什么投影。先 SQL 后注解,能省掉至少一半的联调时间。