这几年做后端开发只要是和数据库打交道基本绕不开 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 JpaRepositoryUser, Long { ListUser findByLastNameAndFirstName(String firstName, String lastName); }框架会根据方法名自动解析并生成查询。这个方法名不算长还勉强能读。但一旦条件超过三四个情况就开始失控ListUser 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 与原生 SQL2.1 JPQL操作实体而非表Query 默认使用 JPQLJava Persistence Query Language。它最大的特点是面向实体和字段而不是面向数据库表和列。比如public interface UserRepository extends JpaRepositoryUser, Long { Query(SELECT u FROM User u WHERE u.status 1) ListUser findActiveUsers(); }这里的User是实体类名u.status中的status是实体的属性名。JPQL 在底层会被翻译成当前数据库方言对应的 SQL所以你的语句里不需要也不应该出现真实的表名和列名。JPQL 的一个隐藏好处是换数据库不用改查询。我在一个项目里从 MySQL 切到 PostgreSQL所有 JPQL 原封不动只需要调配置文件里的方言。这一点和 MyBatis 那种与 SQL 绑死的方式完全是两个思路。JPQL 还继承了对关联关系的天然支持。假设 User 和 Order 是一对多关系实体里写了OneToMany ListOrder orders那用 JPQL 查询时可以这样Query(SELECT u FROM User u JOIN u.orders o WHERE o.totalAmount :amount) ListUser 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) ListUser 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) ListUser 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) ListUser findActiveUsersAfter(Param(status) Integer status, Param(startTime) LocalDateTime startTime);这里有几个容易忽略的点:status冒号和参数名之间不能有空格。参数名和实体属性名一致时可读性最高但不要求必须一致。JPQL 里用的是:参数名它引用的是 Param 里写的名字。如果方法参数没有标 ParamSpring Data 会尝试用“参数名称发现”编译时需要-parameters参数来做隐式绑定。这依赖编译配置不太稳不如老老实实写 Param。3.3 列表参数的 IN 查询IN 查询是使用频率非常高的写法。列表参数配IN写法很自然Query(SELECT u FROM User u WHERE u.id IN :ids) ListUser findByIds(Param(ids) CollectionLong ids);也可以把范围再扩大一点IN 子查询也行Query(SELECT u FROM User u WHERE u.id IN (SELECT o.userId FROM Order o WHERE o.totalAmount :amount)) ListUser findUsersWithBigOrders(Param(amount) BigDecimal amount);注意当ids为空集合时这个查询会得到空结果而不是报错Spring Data 会帮你把这种“空集合条件”处理成一个永不成立的条件。这在外部参数为空时相当于“查不到数据”需要注意是否符合业务预期。3.4 LIKE 模糊查询的两个写法陷阱LIKE 查询是最容易出问题的点。错误示范Query(SELECT u FROM User u WHERE u.name LIKE %:keyword%) ListUser searchByName(String keyword);这个写法在 JPA 规范里是非法的JPQL 的 LIKE 语法要求模式字符串用字面量或字符串拼接表达式不能直接把%放到占位符外面。实际运行会抛IllegalArgumentException或解析失败。正确写法有两种。第一种把%拼进参数里Query(SELECT u FROM User u WHERE u.name LIKE %:keyword%) ListUser searchByName(Param(keyword) String keyword);调用时传入%包裹的值repository.searchByName(%张%);第二种用 CONCAT 拼接Query(SELECT u FROM User u WHERE u.name LIKE CONCAT(%, :keyword, %)) ListUser searchByName(Param(keyword) String keyword);如果只模糊匹配前缀推荐使用 JPQL 的startswith风格命名方法或者 LIKE 配合:keyword%这种在参数后拼%的写法Query(SELECT u FROM User u WHERE u.name LIKE CONCAT(:keyword, %)) ListUser 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}) ListUser findUsersInSameDepartment(Param(user) User user);这种写法配合 SpEL 表达式后面详细讲可以实现更动态的参数提取。如果只是固定取对象的某个属性直接简化成Query(SELECT u FROM User u WHERE u.department.id :deptId) ListUser findByDeptId(Param(deptId) Long deptId);在 Service 层调用时把user.getDepartment().getId()取出来传进去逻辑反而更直白。4. 高级细节分页、投影与数据变更4.1 分页查询的正确姿势Query 配合分页非常强大只需要引入Pageable参数框架会自动生成 count 查询和分页 SQLQuery(SELECT u FROM User u WHERE u.status :status) PageUser findByStatus(Param(status) Integer status, Pageable pageable);调用方式PageUser page userRepository.findByStatus(1, PageRequest.of(0, 10, Sort.by(createdAt).descending()));框架自动做的事情先执行一条SELECT COUNT(u) FROM User u WHERE u.status :status统计总数再执行带LIMITMySQL 方言下的数据查询包装成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) PageUser 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) ListUser findTopActiveUsers(Pageable pageable);调用userRepository.findTopActiveUsers(PageRequest.of(0, 5))就只查前 5 条。Spring Data 还提供Streamable作为返回类型做流式处理Query(SELECT u FROM User u WHERE u.status 1) StreamableUser 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) ListUserSummary 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) ListUserView 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 批量 UPDATEJPA 就不会自动更新 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}SpELSpring Expression Language可以在 Query 里引用上下文信息。最常用的一个就是#{#entityName}它会根据 Repository 泛型自动带入实体名Query(SELECT u FROM #{#entityName} u WHERE u.status 1) ListUser findAllActive();实体上如果通过Entity(name NewUser)自定义了实体名SpEL 会自动跟随。这看起来有点绕但优势在继承体系里特别明显父接口写一个通用查询子接口继承后#entityName自动变成各个实体的名称不会写死。5.2 集合参数的动态展开#{#ids}如果参数是集合可以用 SpEL 把它展开成逗号分隔的列表Query(SELECT u FROM User u WHERE u.id IN (#{#ids})) ListUser findCustomIn(Param(ids) ListLong 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, %))) ListUser 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) ListUser 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) ListUser findUserRows(); // 会映射失败或拿到 null 字段原生 SQL 默认返回的可能是一堆Object[]或Tuple。要解决只需要做两件事之一在查询里SELECT *或SELECT u.*并且结果为整个实体。使用接口投影见 4.3 节列别名与接口 getter 对齐。6.4 修改查询必须加事务不能省初学者容易忽略register 一个 Modifying 方法后直接调用结果抛TransactionRequiredException。只要方法上有 Modifying就必须在事务里跑。如果不想污染 Service 层也可以在 Repository 方法上加TransactionalModifying 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 与特殊字符转义搜索场景里用户输入的%、_会被当成通配符导致搜索结果不正确。解决办法是用显式 ESCAPEQuery(SELECT u FROM User u WHERE u.name LIKE CONCAT(%, :keyword, %) ESCAPE \\) ListUser searchByName(Param(keyword) String keyword);然后在 Service 层对关键字里的%、_、\做转义String escaped keyword.replace(\\, \\\\).replace(%, \\%).replace(_, \\_);这块比较绕实际项目里如果要支持“搜索特殊符号”的需求还是值得认真处理一下的。6.7 关联查询与 N1 问题的平衡Query 里用 JOIN FETCH 可以避免 N1Query(SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id :id) OptionalUser findByIdWithOrders(Param(id) Long id);但 JOIN FETCH 有两条限制不能和分页的自动 count 查询良好配合也不能在一个查询里同时 FETCH 两个集合关联会触发MultipleBagFetchException。遇到这些场景要么拆成多个查询要么牺牲一部分性能用EntityGraph属于另一个话题这里就不展开了。7. 实操示例从需求到 Query 落地这里用一个综合案例把上面的知识点串起来。业务需求根据关键词和用户状态分页查询用户同时统计每个用户的订单总金额。第一步定义投影接口public interface UserOrderStat { Long getId(); String getUsername(); BigDecimal getTotalAmount(); }第二步写 Querypublic interface UserRepository extends JpaRepositoryUser, 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, %))) PageUserOrderStat findUserOrderStats(Param(status) Integer status, Param(keyword) String keyword, Pageable pageable); }第三步Service 层调用Transactional(readOnly true) public PageUserOrderStat 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 语法差异会被放大。比如同一个findByIdsMySQL 下写成FORCE INDEXPG 下就需要改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 后注解能省掉至少一半的联调时间。