多对多分页查询,尤其是配合@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 Set<Course> 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 Set<Student> students = new HashSet<>(); // getter / setter / 构造方法 }有几个细节值得注意。
集合类型建议用Set而不是List。JPA里如果使用List,在关联集合的获取和级联操作上会出现各种奇怪行为,比如同一份数据被多次加载、删除时出现MultipleBagFetchException等。用Set配合HashSet初始化是最稳的。
@JoinTable里两个字段的含义要分清:joinColumns指向当前实体在中间表里的外键字段,inverseJoinColumns指向对面实体在中间表里的外键字段。写反了也不会立刻报错,因为数据库层面外键约束还在,顶多是关联方向颠倒了,但后续查询结果一定不对,排查起来很浪费时间。
关系维护方和反向方。上面示例中Student是关系维护方(写了@JoinTable),Course是反向方(写mappedBy)。增删关联关系时,操作维护方才会触发中间表的写入。如果你往Course的students集合里add一个Student然后保存,中间表不会更新,因为JPA只关系维护方的状态变化。这个坑我踩过不止一次,现场调试的时候一度以为是事务问题。
1.3 序列化小提醒
如果实体直接返回给前端,比如把Student序列化成JSON,它的courses集合会导致双向引用问题:Student引用Course,Course又引用Student,Jackson序列化时直接堆栈溢出。最简单的方法是在其中一侧加@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 JpaRepository<Student, Long> { @Query("select s from Student s where s.studentName like concat('%', :keyword, '%')") Page<Student> 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, '%')") Page<Student> 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, '%')" ) Page<Student> searchStudentsWithCourses(@Param("keyword") String keyword, Pageable pageable);countQuery里不需要查询集合字段,只需要统计实体本身的数量。查询语句里做过滤用的是s.studentName,count语句里也用同样的过滤条件,保证总数和列表一致。
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" ) Page<Student> 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 JpaRepository<Student, 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))" ) Page<Student> 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 Page<Student> 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 ) Page<StudentNameOnly> 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-sql=true,日志里能看到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, '%')") Page<Student> searchStudentsWithCoursesByGraph(@Param("keyword") String keyword, Pageable pageable);@EntityGraph会生成一条单独的fetch查询,或者使用join fetch,但Hibernate对分页的处理方式不同。实测下来,EntityGraph在分页场景下比手写join fetch更安全。尤其当你不确定Hibernate版本行为时,优先EntityGraph。
第二,直接在Service层分成两步:第1步分页查出主实体ID列表,第2步使用in查询把集合加载出来,然后在内存中组装。这种方式SQL简单可控,但代码量多一些。
List<Long> ids = page.getContent().stream().map(Student::getId).toList(); List<Student> studentsWithCourses = studentRepository.findAllWithCoursesByStudentIds(ids); Map<Long, Student> map = studentsWithCourses.stream() .collect(Collectors.toMap(Student::getId, Function.identity()));我处理大数据量分页时更倾向于第二种方案,原因是SQL每个步骤的职责清晰,join也只出现在in查询里,整个执行计划相对可控。
第三,如果你用的是native SQL,则在SQL层手动控制关联和distinct,根本绕开Hibernate的fetch机制,也是在特定场景下的有效方案。
5.4 N+1查询问题隐藏得很深
多对多分页最容易被忽略的性能问题是N+1。分页查询本身只查了limit+1条主数据,但是前端遍历每个学生并调用getCourses()时,Hibernate懒加载会为每个学生额外执行一次课程查询。假设每页20条数据,页面上会瞬间多出20条SQL,这就是N+1。
排查N+1其实很简单:打开SQL日志,如果发现“每一条主查询后面跟着规律性的若干条子查询”,基本就是懒加载触发。要么用@Transactional保证懒加载在会话内完成,要么用EntityGraph预加载,要么用DTO投影只取需要字段避免访问集合属性。
从设计角度说,如果列表页根本不展示课程信息,只是详情页才展示,就不要在列表查询里加载courses集合。明确需求边界,能省掉很多不必要的关联查询开销。这也是我在很多项目里强调的:不要为了“以后可能用到”就一次性把关联对象全都查出来。
5.5 Page返回类型的选择:实体还是DTO
@Query返回值除了Page 这样的实体外,还支持接口投影和DTO。但在多对多分页场景下,直接返回DTO更推荐,原因也很实际:实体映射自带关联集合,处理不好会触发懒加载和序列化问题;DTO就是纯数据载体,返回给前端完全由你控制。
接口投影写法:
public interface StudentView { Long getId(); String getStudentName(); List<CourseView> getCourses(); }需要注意的是,接口投影中的集合属性支持并不完整。多对多场景d下,我实测过SpringDataJPA可以注入关联集合,但前提条件比较多,建议还是老老实实先查实体,再手工转换到DTO。这也是我在性能与可维护性之间的折中:
Page<StudentDTO> convertToDTO(Page<Student> 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 s,Hibernate会翻译成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拉出来,再对照这几点逐一检查,通常都能找到答案。