Spring Data 3中Fetch Join结合分页与Specifications内存分页问题
问题:JPA Specifications结合Fetch Join时分页未在数据库层面执行
使用Spring Boot 3结合JPA Specifications构建动态查询,同时使用分页和Fetch Join避免N+1查询问题,但分页并未在数据库层面通过SQL实现,而是在应用内存中处理。
实体类代码
@Entity @Table(name = "users") public class User implements UserDetails, Serializable { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @Column(name = "names") private String names; @ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) private Set<Role> roles = new HashSet<>(); ... }
Repository代码
@Repository public interface UserRepository extends JpaRepository<User, UUID>, JpaSpecificationExecutor<User> { ... }
查询实现代码
@Repository @RequiredArgsConstructor public class UserGatewayImpl implements UserGateway { private final UserRepository userRepository; @Override public Page<User> findByParams(UserSearchCommand searchCriteria, Pageable pageable) { var userSpecification = buildCriteria(searchCriteria); return userRepository.findAll(userSpecification, pageable); } }
Specification构建代码
private Specification<User> buildCriteria(UserSearchCommand queryCriteria) { return (root, criteriaQuery, criteriaBuilder) -> { List<Predicate> predicates = new ArrayList<>(); if (Long.class != criteriaQuery.getResultType() && long.class != criteriaQuery.getResultType()) { root.fetch("roles", JoinType.LEFT); } else { root.join("roles", JoinType.LEFT); } if (nonNull(queryCriteria.getNames())) { predicates .add(criteriaBuilder.and( criteriaBuilder.equal(root.get("names"), queryCriteria.getNames()))); } return criteriaBuilder.and(predicates.toArray(new Predicate[0])); }; }
问题现象
查看Hibernate生成的SQL,发现没有分页相关的LIMIT/OFFSET语句(不同数据库语法可能不同),所有数据先被查询到应用内存中再做分页处理:
... from users u1_0 left join (users_roles r1_0 join roles r1_1 on r1_1.id=r1_0.role_id) on u1_0.id=r1_0.user_id where 1=1 order by u1_0.id desc
解决方案
问题核心是Fetch Join会导致查询结果出现重复的User实体(一个用户对应多个角色时,关联查询会返回多条重复的User记录),JPA为了保证Page中的数据是去重后的正确数量,会先拉取全部数据到内存去重再分页,从而触发内存分页。
修改Specification构建代码
private Specification<User> buildCriteria(UserSearchCommand queryCriteria) { return (root, criteriaQuery, criteriaBuilder) -> { List<Predicate> predicates = new ArrayList<>(); if (Long.class != criteriaQuery.getResultType() && long.class != criteriaQuery.getResultType()) { // 数据查询:使用fetch join,同时添加distinct去重 root.fetch("roles", JoinType.LEFT); criteriaQuery.distinct(true); // 关键:强制SQL返回去重后的User记录 } else { // 计数查询:添加distinct保证计数准确,避免关联导致计数偏大 root.join("roles", JoinType.LEFT); criteriaQuery.distinct(true); } if (nonNull(queryCriteria.getNames())) { predicates.add(criteriaBuilder.equal(root.get("names"), queryCriteria.getNames())); } return criteriaBuilder.and(predicates.toArray(new Predicate[0])); }; }
原理说明
criteriaQuery.distinct(true):会让Hibernate在生成的SQL中添加DISTINCT关键字,数据库层面直接返回去重后的User记录,每条User只出现一次,此时分页的LIMIT/OFFSET能正确作用在数据库层面。- 计数查询也添加distinct:避免因为关联查询导致计数结果大于实际用户数量,保证总页数计算准确。
验证效果
修改后生成的SQL会包含DISTINCT和分页关键字,分页逻辑完全在数据库层面执行,同时Fetch Join依然能避免N+1查询问题。
内容的提问来源于stack exchange,提问作者Andres Grisales
相关产品推荐
相关产品推荐

