Spring Data JPA多对多分页与@Query避坑指南
多对多分页查询尤其是配合Query注解手写JPQL/SQL的情况可以说是JPA实践里最容易把人卡住的地方之一。网上搜到的资料大多是“这样写就行”可真拿到自己项目里一跑各种问题就冒出来了要么count语句报错要么查出重复数据要么返回的Page对象里根本不是自己想要的结构。我前前后后处理过不少这类问题这篇文章就把SpringBoot SpringDataJPA通过Query注解实现多对多分页查询的完整思路、代码写法、常见坑位一次说清楚。1. 先把多对多的模型搞清楚很多人分页查不好根子不在分页代码本身而是实体关系映射从源头就设计得别扭。多对多的理解其实不复杂关键是中间表怎么处理。1.1 选一个最典型的场景学生选课学生和课程就是教科书级别的多对多一个学生可以选多门课一门课也可以被多个学生选。这种关系在数据库层面必须借助第三张表——选课表来记录里面存学生ID和课程ID的对应关系。画个简单的逻辑图就是student学生表 student_id主键 student_name ... course课程表 course_id主键 course_name ... student_course选课中间表 student_id course_id实际业务中这张中间表往往会带上额外字段比如选课时间、成绩、是否退课等。一旦中间表出现了业务字段就不建议再用纯粹的ManyToMany而要拆成两个一对多。不过今天讨论的侧重点是“纯多对多加Query分页查询”所以先以最简单、不带额外字段的中间表为例后面对带字段的场景单独提一句。1.2 实体映射的写法要点实体类的核心写法如下// Student 实体 Entity Table(name student) public class Student { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name student_name) private String studentName; ManyToMany JoinTable( name student_course, joinColumns JoinColumn(name student_id), inverseJoinColumns JoinColumn(name course_id) ) private SetCourse courses new HashSet(); // getter / setter / 构造方法 }// Course 实体 Entity Table(name course) public class Course { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name course_name) private String courseName; ManyToMany(mappedBy courses) private SetStudent students new HashSet(); // getter / setter / 构造方法 }有几个细节值得注意。集合类型建议用Set而不是List。JPA里如果使用List在关联集合的获取和级联操作上会出现各种奇怪行为比如同一份数据被多次加载、删除时出现MultipleBagFetchException等。用Set配合HashSet初始化是最稳的。JoinTable里两个字段的含义要分清joinColumns指向当前实体在中间表里的外键字段inverseJoinColumns指向对面实体在中间表里的外键字段。写反了也不会立刻报错因为数据库层面外键约束还在顶多是关联方向颠倒了但后续查询结果一定不对排查起来很浪费时间。关系维护方和反向方。上面示例中Student是关系维护方写了JoinTableCourse是反向方写mappedBy。增删关联关系时操作维护方才会触发中间表的写入。如果你往Course的students集合里add一个Student然后保存中间表不会更新因为JPA只关系维护方的状态变化。这个坑我踩过不止一次现场调试的时候一度以为是事务问题。1.3 序列化小提醒如果实体直接返回给前端比如把Student序列化成JSON它的courses集合会导致双向引用问题Student引用CourseCourse又引用StudentJackson序列化时直接堆栈溢出。最简单的方法是在其中一侧加JsonIgnore。一般建议在Course的students集合上标注这样查询学生时能看到他选了哪些课查询课程时不会把选课学生全部带出来。注意多对多实体在设计时就要想清楚“谁才是被序列化的主角”。大多数场景下把Student作为主角Course作为附属数据在反向方加JsonIgnore即可。2. 分页查询方案怎么选明确了实体关系接着就是查询层的思路。分页查询无非几个选择直接调用Repository内置方法、使用Query写JPQL、使用Query写native SQL。每种方案适用场景不同。2.1 内置方法能满足什么场景SpringDataJPA内置了findAll(Pageable)这个只适合没有任何过滤条件的全表分页。一旦出现“根据条件过滤后分页”比如查选了某课程的学生、按姓名关键字搜索学生同时带出他选的课程内置方法就无能为力了必须走自定义查询。很多人一开始图省事把过滤逻辑写在Service层先把数据全部查出来再用Java代码过滤最后在内存里手动分页。数据量小的时候看不出问题到几十万条数据就非常痛苦。这也是我在代码评审里最常反对的做法分页必须在数据库层面完成不要相信“数据量不大内存过滤没问题”这句话因为业务增长起来翻车只是时间问题。2.2 Query Pageable的核心模式SpringDataJPA对自定义分页查询的支持非常成熟标准做法是方法签名里接收一个Pageable参数返回值用Page 。框架会自动执行两件事第一根据你写的查询语句执行数据查询第二根据查询语句自动生成一个count查询用于计算总条数。public interface StudentRepository extends JpaRepositoryStudent, Long { Query(select s from Student s where s.studentName like concat(%, :keyword, %)) PageStudent searchStudents(Param(keyword) String keyword, Pageable pageable); }就这么简单对最基础的场景确实这么简单。真正麻烦的是在多对多关联查询时自动生成的count语句经常不是你想要的甚至直接报错。解决办法就是在Query里指定countQuery属性把自己写好的count语句塞进去。后面的实操部分会重点展开。2.3 JPQL和native SQL该怎么选JPQL操作的是实体对象和属性写起来更贴近Java代码自动映射成实体不需要自己处理结果集转换。大部分业务查询用JPQL完全够用。native SQL直接操作数据库表和列适合复杂报表、多表聚合查询、数据库特有函数、查询性能需要精细控制的场景。缺点也很明显返回结果是Object[]数组或需要手动映射类型安全差数据库方言一旦切换可能就要重写。我的习惯是优先JPQL只有JPQL解决不了或者执行计划明显不合理时才上native SQL。前面说的多对多分页两种都能做但JPQL版本要注意JOIN FETCH和分页的冲突native SQL版本要注意字段歧义和去重问题各有各的坑后面细讲。3. 完整实操JPQL版多对多分页查询下面进入正题完整演示从Repository到Service的实战代码这里使用学生选课场景。每一步我都标注了参数和核心意图。3.1 基础分页查询关联了课程的学生第一个需求根据学生姓名模糊搜索分页返回学生列表同时把每个学生选的课程带出来。第一种写法——直接join取关联集合Query(select distinct s from Student s left join fetch s.courses c where s.studentName like concat(%, :keyword, %)) PageStudent searchStudentsWithCourses(Param(keyword) String keyword, Pageable pageable);这里的distinct必不可少。因为left join fetch会把一个学生对应多门课程的结果展开成多行没有distinct的话Page里的学生对象会出现重复分页总数也会错。加了distinct之后数据库层的行数跟你实体对象的数量才一一对应。但是这个写法有一个大隐患Page的计数查询。当指定了join fetch时SpringDataJPA自动生成的count语句大概率长这样select count(distinct s) from Student s left join fetch s.courses c where ...count语句里带着join fetch会直接抛异常。Hibernate在解析count时会尝试把fetch join映射到count语句里结果就是QuerySyntaxException。应对方法很简单显式声明countQuery去掉join fetch。Query( value select distinct s from Student s left join fetch s.courses c where s.studentName like concat(%, :keyword, %), countQuery select count(distinct s) from Student s where s.studentName like concat(%, :keyword, %) ) PageStudent searchStudentsWithCourses(Param(keyword) String keyword, Pageable pageable);countQuery里不需要查询集合字段只需要统计实体本身的数量。查询语句里做过滤用的是s.studentNamecount语句里也用同样的过滤条件保证总数和列表一致。3.2 进阶按课程条件过滤学生分页第二个需求更贴近真实业务查询选了某门课的学生并且按学生字段排序分页。Query( value select distinct s from Student s join s.courses c where c.courseName :courseName order by s.studentName asc, countQuery select count(distinct s) from Student s join s.courses c where c.courseName :courseName ) PageStudent findStudentsByCourse(Param(courseName) String courseName, Pageable pageable);注意这里用的是join而不是join fetch。因为查询条件在关联表上用的是inner join来过滤返回的学生对象如果没有带上课程集合前端展示时访问getCourses()会触发懒加载查询。如果希望返回的学生直接带courses仍然可以改成left join fetch distinct同时保持countQuery独立。关于排序Pageable参数里可以传Sort也可以像上面这样在JPQL里直接写order by。两者都会生效但要注意JPQL里自己写的order by优先级固定存在Pageable里带的Sort会被追加在后面。多个order by叠加时执行计划可能不符合预期所以要么只用Pageable的Sort要么只在JPQL里写order by不建议两处同时用。3.3 关键经验什么时候必须带countQuery总结一句话只要查询语句里出现了join fetch、distinct、多表关联且返回边不是简单实体就必须手写countQuery。或者说如果自动count生成的SQL执行报错或返回的总数明显不对就把它改成手写。countQuery的编写有一条铁律统计的目标是主表实体条数不是关联结果的行数。多对多场景下关联表会放大行数所以需要distinct或者count(s.id)但加上distinct后又有可能因为列的顺序问题引发SQL解析差异。最稳妥的方式是count(distinct 主别名)比如count(distinct s)。注意countQuery里的过滤条件和主查询必须完全一致。不一致时不会直接报错但会出现“列表第一页有数据总条数却是0”这类诡异问题而且极其难排查。3.4 实操现场一个完整的Repository写法把上面两个查询整合到同一个Repository里完整结构如下public interface StudentRepository extends JpaRepositoryStudent, Long { Query( value select distinct s from Student s left join fetch s.courses c where (:name is null or s.studentName like concat(%, :name, %)) and (:courseName is null or exists (select 1 from Course c2 join c2.students cstu where cstu.id s.id and c2.courseName :courseName)), countQuery select count(distinct s) from Student s where (:name is null or s.studentName like concat(%, :name, %)) and (:courseName is null or exists (select 1 from Course c2 join c2.students cstu where cstu.id s.id and c2.courseName :courseName)) ) PageStudent searchStudents(Param(name) String name, Param(courseName) String courseName, Pageable pageable); }这个写法里利用exists子查询代替直接join课程表好处是第一不会因为join产生重复行distinct可以去掉第二exists条件判断比你想象中高效尤其是课程表大、学生选课关系多的时候第三countQuery和主查询结构完全对齐逻辑不容易出错。唯一的遗憾是join fetch和exists并用时主查询还是要加distinct。所以这里我保留了distinct但没有直接join课程重复数据的问题天然就没了。参数为空时的处理用(:name is null or ...)的方式这种做法SQL层面能自动跳过滤条件。不过要注意这种方式对JPQL完全支持对native SQL部分数据库可能不能很好地使用参数推断所以在native版本里我更推荐在Java代码里动态拼接。3.5 Service层调用和Page对象使用Repository写好后Service层的调用其实非常直接Service public class StudentService { private final StudentRepository studentRepository; public StudentService(StudentRepository studentRepository) { this.studentRepository studentRepository; } public PageStudent searchStudents(String name, String courseName, int page, int size) { Pageable pageable PageRequest.of(page, size, Sort.by(studentName).ascending()); return studentRepository.searchStudents(name, courseName, pageable); } }调用方拿到Page 后可以使用getTotalElements()获取总条数、getTotalPages()获取总页数、getContent()获取当前页数据。这些信息足够组装成前端分页组件所需的数据结构。注意Page的页码从0开始前端传入页码时往往是从1开始的需要在Controller层做一次减1转换否则第一页数据会变成第二页的数据但总数不变这种边界问题很容易被测试忽略。4. 实操native SQL版的分页写法JPQL版本解决大部分场景就够了但有个现实问题复杂的多对多查询在JPQL里实在写不动或者SQL优化器给出的执行计划不理想此时就需要native SQL。我实际项目里遇到过一个案例查询条件涉及中间表的业务字段且需要对多个关联表做聚合统计JPQL表达起来绕了很大一圈换成native SQL后清晰很多。4.1 native SQL的基本框架Query( value select distinct s.id, s.student_name, s.student_no from student s left join student_course sc on s.student_id sc.student_id left join course c on sc.course_id c.course_id where c.course_name :courseName order by s.student_id desc , countQuery select count(distinct s.student_id) from student s left join student_course sc on s.student_id sc.student_id left join course c on sc.course_id c.course_id where c.course_name :courseName, nativeQuery true ) PageStudentNameOnly findStudentsByCourseNative(Param(courseName) String courseName, Pageable pageable);这段代码里有几个关键设计第一返回类型用了接口投影StudentNameOnly而不是实体Student。native SQL查询结果直接映射到实体需要满足很多条件字段名对不上、实体有无法填充的属性时就会报错或产生性能问题。投影接口是SpringDataJPA提供的轻量级映射方式最省事。第二select只取自己需要的字段。这是性能习惯也是native SQL和JPQL最大的区别。JPQL查询实体时会产生select *在数据量大、列多、有懒加载属性时代价很高。native SQL完全由你控制返回列跟索引覆盖查询配合起来效果非常明显。4.2 投影接口定义public interface StudentNameOnly { Long getId(); String getStudentName(); String getStudentNo(); }SpringDataJPA会把查询结果中同名字段自动映射到这个接口上。需要注意SQL返回的列别名要和接口方法名严格对应比如student_name可以这样处理Query( value select distinct s.student_id as id, s.student_name as studentName, s.student_no as studentNo from student s ..., ... nativeQuery true )数据库默认会把不带引号的别名转成大写或小写不同数据库行为不一样。我在MySQL上遇到过别名studentName被转成studentname导致映射失败的案例所以建议把别名养成写全小写的习惯接口方法命名也尽量使用简单无驼峰的字段比如直接用as student_name配合接口里getStudentName()SpringDataJPA做映射时会智能匹配。4.3 native SQL分页常见错误native SQL模式下Pageable参数依然生效SpringDataJPA会自动在SQL末尾拼接limit和offset。这一点在MySQL上没问题但在Oracle上如果数据库方言配置不对生成的SQL可能以”rownum”套子查询的方式处理性能和逻辑都可能受干扰。所以使用native SQL分页时数据源方言务必在application配置里显式指定不要交给自动检测。另外native SQL的countQuery如果写了distinct注意distinct的列必须包含在主查询中否则某些数据库会直接报错。上面示例中主查询distinct s.id、统计语句distinct s.student_id两者保持一致就不会出问题。5. 多对多分页查询的常见坑和排查实录这一部分是整篇文章里我最想分享的很多问题表面上看起来都是“分页结果不对”但根因各不相同排查的思路和手段也不一样。5.1 排查过的最经典问题总数和列表不一致现象Page.getTotalElements()返回5但第一页只有2条数据且内容重复。典型原因查询语句产生了笛卡尔积式的多行结果而count计算的是另外一套逻辑。比如主查询用join fetch拿了课程集合但distinct没有加或者countQuery里的关联条件和主查询不一致。排查步骤第一步打开SQL日志把执行的真实SQL拉出来看。配置里加上spring.jpa.show-sqltrue日志里能看到Hibernate生成的查询和count语句。对着两条语句一对比问题基本就现形了。第二步把去掉分页后的查询结果在数据库客户端手动执行一遍看返回的行数和内容。如果同一主表ID出现多行distinct或exists的调整方向就有了。第三步确认join方向。多对多中最容易写反的就是从Course表反向join Student时中间表关联条件写错导致一张中间表匹配到了错误方向的另一张表。5.2 countQuery没写导致直接抛异常日志报错大概是这样的query specified join fetching, but the owner of the fetched association was not present in the select list或者org.springframework.dao.InvalidDataAccessApiUsageException: Failed to create query result from SQL: ... unexpected token: fetch本质是Hibernate尝试自动生成count语句失败。多对多查询加join fetch时这个错误几乎必现。解决方式就是前面强调的手写countQuery。这个坑太普遍了我建议只要Query里出现join fetch就不要依赖自动count直接把手写countQuery当成默认规则。5.3 JOIN FETCH和分页的深层冲突这个话题值得单独拿出来说。Hibernate 6以前join fetch一个集合属性再配合分页Hibernate会在内存中完成分页也就是把所有关联数据加载到内存然后对List做subList。当数据量很大时这条查询会拖垮应用。日志里一般不报错但你会看到查询返回的记录数比limit大多得多的现象。在PostgreSQL或MySQL的SQL日志里select语句末尾没有limit或者limit参数不对就要警惕这个情况。解决办法有几个方向第一查询时只查主实体分页然后通过EntityGraph或者二次查询批量加载课程集合。也就是分次查询而不是一次join fetch完成。EntityGraph(attributePaths courses) Query(select s from Student s where s.studentName like concat(%, :keyword, %)) PageStudent searchStudentsWithCoursesByGraph(Param(keyword) String keyword, Pageable pageable);EntityGraph会生成一条单独的fetch查询或者使用join fetch但Hibernate对分页的处理方式不同。实测下来EntityGraph在分页场景下比手写join fetch更安全。尤其当你不确定Hibernate版本行为时优先EntityGraph。第二直接在Service层分成两步第1步分页查出主实体ID列表第2步使用in查询把集合加载出来然后在内存中组装。这种方式SQL简单可控但代码量多一些。ListLong ids page.getContent().stream().map(Student::getId).toList(); ListStudent studentsWithCourses studentRepository.findAllWithCoursesByStudentIds(ids); MapLong, Student map studentsWithCourses.stream() .collect(Collectors.toMap(Student::getId, Function.identity()));我处理大数据量分页时更倾向于第二种方案原因是SQL每个步骤的职责清晰join也只出现在in查询里整个执行计划相对可控。第三如果你用的是native SQL则在SQL层手动控制关联和distinct根本绕开Hibernate的fetch机制也是在特定场景下的有效方案。5.4 N1查询问题隐藏得很深多对多分页最容易被忽略的性能问题是N1。分页查询本身只查了limit1条主数据但是前端遍历每个学生并调用getCourses()时Hibernate懒加载会为每个学生额外执行一次课程查询。假设每页20条数据页面上会瞬间多出20条SQL这就是N1。排查N1其实很简单打开SQL日志如果发现“每一条主查询后面跟着规律性的若干条子查询”基本就是懒加载触发。要么用Transactional保证懒加载在会话内完成要么用EntityGraph预加载要么用DTO投影只取需要字段避免访问集合属性。从设计角度说如果列表页根本不展示课程信息只是详情页才展示就不要在列表查询里加载courses集合。明确需求边界能省掉很多不必要的关联查询开销。这也是我在很多项目里强调的不要为了“以后可能用到”就一次性把关联对象全都查出来。5.5 Page返回类型的选择实体还是DTOQuery返回值除了Page 这样的实体外还支持接口投影和DTO。但在多对多分页场景下直接返回DTO更推荐原因也很实际实体映射自带关联集合处理不好会触发懒加载和序列化问题DTO就是纯数据载体返回给前端完全由你控制。接口投影写法public interface StudentView { Long getId(); String getStudentName(); ListCourseView getCourses(); }需要注意的是接口投影中的集合属性支持并不完整。多对多场景d下我实测过SpringDataJPA可以注入关联集合但前提条件比较多建议还是老老实实先查实体再手工转换到DTO。这也是我在性能与可维护性之间的折中PageStudentDTO convertToDTO(PageStudent page) { return page.map(student - { StudentDTO dto new StudentDTO(); dto.setId(student.getId()); dto.setStudentName(student.getStudentName()); dto.setCourses(student.getCourses().stream() .map(c - new CourseDTO(c.getId(), c.getCourseName())) .toList()); return dto; }); }Page接口自带map方法这个转换非常方便不用手动构造新的Page对象直接返回page.map(...)即可。6. 分页查询的性能优化还有一些值得说的细节基础的查询代码跑通之后性能上还有几个值得投入精力的地方。6.1 索引设计要考虑分页和过滤条件多对多分页的瓶颈通常不在主表而在中间表和被过滤的关联表。以学生选课为例下面的索引建议几乎是无脑加的alter table student_course add index idx_student_course_course_id (course_id); alter table student_course add index idx_student_course_student_id (student_id);课程表的course_name字段如果是高频过滤条件也建议加索引。索引设计的原则很简单where条件里的字段、join条件里的字段都要优先进入索引。分页排序字段如果固定也可以组合进索引比如(student_name, student_id)这种覆盖排序条件的组合索引。我用explain分析执行计划时发现很多慢查询不是SQL写得多差而是中间表没有索引导致join走全表扫描。这个优化成本极低收益却非常显著。6.2 大偏移量分页的问题普通的limit offset分页在偏移量大到几十万时性能会急剧下降。因为数据库需要扫描并丢弃前面offset行再取size行。多对多查询本身涉及join这个代价会被放大。解决思路是使用键集分页keyset pagination依靠排序字段的唯一值往前翻页不依赖offset。但这样前端分页组件通常要改造和“跳转到第X页”的交互方式不兼容。折中方案是限制最大翻页深度比如只允许查看前500页超过部分提示缩小条件范围。这也是大多数业务系统实际采用的做法。6.3 查询字段越少越好这一点在native SQL里已经强调过值得再展开说。JPQL里写的select sHibernate会翻译成select *即使你只需要name和id两个字段。数据表字段少还好如果表上有几十个列还有text类型的字段这个差异在IO上就很明显。尽量避免“开发期图省事全查上线后再说”的思维。多对多场景本来就有join成本再把大字段一起带出来慢是必然的。习惯做法是列表页查询只取列表展示需要的字段详情页再按主键单独查询完整实体。6.4 实在不想手写Query的情况有一种场景完全不需要Query多对多实体的关联ID列表查询。假如业务上只需要“查某个学生选的所有课程ID”那直接用实体自带的关联集合就能完成不需要任何自定义查询。此时做分页反而没有意义因为关联集合数量通常有限。另外如果使用的是中间表带业务字段的设计推荐做法那中间表应该拆成一个独立实体多对多就变成了一个一对多加一个多对一。这时分页查询的对象是中间表实体反而比纯ManyToMany更清晰。比如选课记录带成绩需求是“分页查询选了某课程且成绩大于60分的选课记录”那直接对选课记录实体做分页关联Student和Course做fetch即可。我在实际项目中越来越倾向于把中间表实体化也就是给中间的关联加上自己的主键和业务字段本质上把多对多拆成两个一对多。这样做会让查询语义更清楚分页目标也更明确避免了很多纯多对多带来的模型上的别扭。如果你还在设计阶段我非常建议提前考虑这个方式。最后分享一点个人体会多对多分页查询之所以困扰很多人是因为它同时涉及实体关系、动态条件、分页机制、性能优化四个层面的知识任何一个环节想得不透代码效果都会跑偏。我处理过几次线上问题后逐步固化了几个习惯一是只要Query里出现join fetch就默认手写countQuery哪怕当时运行没报错也会防止以后Hibernate升级或数据量变化引发不可控行为。二是能用exists尽量不用直接join过滤条件少一次join就少一份数据膨胀的风险。三是不迷信“默认懒加载不会有问题”每一条分页接口上线前我都会开着SQL日志跑一遍典型请求看一眼SQL数量和返回行数是否符合预期。四是分页接口尽量返回DTO而不是实体这舍弃了少量开发便利换来了接口稳定性和性能可控性。尤其多对多集合属性在DTO里你能精准控制展示什么、遍历什么永远不用担心Jackson序列化把整个对象图炸开。这些经验不一定适用于所有项目但在大部分基于SpringBoot SpringDataJPA的场景里按这个思路去设计和排查多对多分页查询基本不会走太多弯路。如果你在项目中遇到了更诡异的查询问题建议先把实际执行的SQL拉出来再对照这几点逐一检查通常都能找到答案。