java springboot N+1问题

一、准备

a.实体映射(Entity Mapping)

Order实体:

java 复制代码
import java.math.BigDecimal;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.Table;
import javax.persistence.Id;
import lombok.Data;
@Entity
@Table(name = "t_demo_order")
@Data
public class DemoOrder {
     @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal amount;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private DemoUser demoUser;

}

用户实体:

java 复制代码
import java.util.ArrayList;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.OneToMany;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.Id;

import javax.persistence.Table;

import lombok.Data;

import java.util.List;
@Entity
@Table(name = "t_demo_user")
@Data
public class DemoUser {
      @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String username;

    @OneToMany(
        mappedBy = "demoUser",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    private List<DemoOrder> orders = new ArrayList<>();

    public void addOrder(DemoOrder order) {
        orders.add(order);
        order.setDemoUser(this);
    }
}

b.Repository 层(核心)

java 复制代码
import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import com.sky.dto.DemoUserOrderCountDTO;
import com.sky.entity.DemoUser;

@Repository
public interface DemoUserRepository extends JpaRepository<DemoUser, Long> {
    // ❌ 默认不会加载 orders
    List<DemoUser> findAll();

    @Query("SELECT u FROM DemoUser u JOIN FETCH u.orders")
    List<DemoUser> findAllWithOrders();

    @Query("SELECT u.id AS id, " +
            "u.username AS username, " +
            "COUNT(o) AS orderCount " +
            "FROM DemoUser u " +
            "LEFT JOIN u.orders o " +
            "GROUP BY u.id, u.username")
    List<DemoUserOrderCountDTO> findUserOrderCount();
}

c. 事务管理(Transaction)

java 复制代码
import java.util.List;

import com.sky.dto.DemoUserDTO;

public interface DemoUserService {
List<DemoUserDTO> listUsersBad();
List<DemoUserDTO> listUsersFetch();
List<DemoUserDTO> listUsersDto();
}

服务实现

java 复制代码
import java.util.List;
import java.util.stream.Collectors;

import com.sky.dto.DemoUserDTO;
import com.sky.entity.DemoUser;
import com.sky.repository.DemoUserRepository;
import com.sky.service.DemoUserService;

import lombok.RequiredArgsConstructor;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class DemoUserServiceImpl implements DemoUserService {

    @Autowired
    private final DemoUserRepository userRepository;

    @Override
    // ❌ N+1 问题示例
    @Transactional(readOnly = true)
    public List<DemoUserDTO> listUsersBad() {
        // TODO Auto-generated method stub
        // throw new UnsupportedOperationException("Unimplemented method
        // 'listUsersBad'");
        List<DemoUser> users = userRepository.findAll();
        return users.stream()
                .map(u -> new DemoUserDTO(
                        u.getId(),
                        u.getUsername(),
                        u.getOrders().size() // ❌ 触发 N+1
                ))
                .collect(Collectors.toList());
    }

    @Override
    // ✅ JOIN FETCH 解决 N+1
    @Transactional(readOnly = true)
    public List<DemoUserDTO> listUsersFetch() {
        // TODO Auto-generated method stub
        List<DemoUser> users = userRepository.findAllWithOrders();
        return users.stream()
                .map(u -> new DemoUserDTO(
                        u.getId(),
                        u.getUsername(),
                        u.getOrders().size()))
                .collect(Collectors.toList());
    }

    @Override
    // ✅ JOIN FETCH 解决 N+1
    @Transactional(readOnly = true)
    public List<DemoUserDTO> listUsersDto() {
        // TODO Auto-generated method stub
        return userRepository.findUserOrderCount()
                .stream()
                .map(d -> new DemoUserDTO(
                        d.getId(),
                        d.getUsername(),
                        d.getOrderCount().intValue()))
                .collect(Collectors.toList());
    }

}

d.DTO

java 复制代码
public interface UserOrderCountDTO {
    Long getId();
    String getUsername();
    Long getOrderCount();
}

控制器

java 复制代码
import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.sky.dto.DemoUserDTO;
import com.sky.service.DemoUserService;

import lombok.RequiredArgsConstructor;

//N+1 的本质是 Lazy 加载 + 循环访问关联对象​
//生产环境最优解是 DTO 投影,其次是 JOIN FETCH
@RestController
@RequestMapping("/demo/users")
@RequiredArgsConstructor
public class DemoUserController {
 private final DemoUserService userService;

    @GetMapping("/bad")
    public List<DemoUserDTO> bad() {
        return userService.listUsersBad();
    }

    @GetMapping("/fetch")
    public List<DemoUserDTO> fetch() {
        return userService.listUsersFetch();
    }

    @GetMapping("/dto")
    public List<DemoUserDTO> dto() {
        return userService.listUsersDto();
    }
}

SQL数据

sql 复制代码
delete from t_demo_order;
delete from t_demo_user;

INSERT INTO t_demo_user (username) VALUES
('demo_user_01'),('demo_user_02');

-- 订单
INSERT INTO t_demo_order (amount, user_id) VALUES
-- user 1 有 3 单
(100.00, 1),
(110.00, 1),
(120.00, 1),

-- user 2 有 2 单
(200.00, 2),
(220.00, 2)
;

测试

使用http

1.N+1请求发送

GET http://localhost:9001/demo/users/bad

Accept: application/json

截图:

解决方案

a.fetch

java 复制代码
@Query("SELECT u FROM DemoUser u JOIN FETCH u.orders")
    List<DemoUser> findAllWithOrders();

测试:

GET http://localhost:9001/demo/users/fetch

Accept: application/json

截图:

b.EntityGraph(推荐,语义更清晰)

定义实体的修改:

java 复制代码
@NamedEntityGraph(
    name = "User.withOrders",
    attributeNodes = @NamedAttributeNode("orders")
)
public class DemoUser

对Repository 接口

java 复制代码
    @EntityGraph(value = "User.withOrders", type = EntityGraph.EntityGraphType.FETCH) //// 不写 JPQL

    List<DemoUser> findAll();

c.DTO

接口

java 复制代码
public interface DemoUserOrderCountDTO {
    Long getId();

    String getUsername();

    Long getOrderCount();
}

实现方法:Repository

java 复制代码
@Query("SELECT u.id AS id, " +
            "u.username AS username, " +
            "COUNT(o) AS orderCount " +
            "FROM DemoUser u " +
            "LEFT JOIN u.orders o " +
            "GROUP BY u.id, u.username")
    List<DemoUserOrderCountDTO> findUserOrderCount();

d.Batch Fetching(补救方案)

老项目补救

java 复制代码
spring:
  jpa:
    properties:
      hibernate:
        batch_fetch_style: IN
        default_batch_fetch_size: 50

sql 效果:

sql 复制代码
select * from t_order where user_id in (?, ?, ?, ...)

总结:

场景 推荐方案

简单列表 DTO 投影

需要 Entity JOIN FETCH / EntityGraph

老项目补救 Batch Fetch

分页 + 关联 DTO + 子查询

用 Hibernate Statistics 验证 SQL 数量

1.开启 Hibernate Statistics

bash 复制代码
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

2.注入 SessionFactory

bash 复制代码
@Autowired
private EntityManagerFactory entityManagerFactory;

3.写测试方法(核心)

bash 复制代码
@Transactional(readOnly = true)
public void testNPlusOne() {
    SessionFactory sf = entityManagerFactory.unwrap(SessionFactory.class);
    Statistics stats = sf.getStatistics();
    stats.clear();

    List<DemoUser> users = userRepository.findAll();
    users.forEach(u -> u.getOrders().size());

    System.out.println("SQL count = " + stats.getPrepareStatementCount());
}

测试引用

bash 复制代码
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
java 复制代码
@DataJpaTest
@Import(DemoDataInitializer.class) // 插入测试数据
class DemoUserRepositoryTest {

    @Autowired
    private DemoUserRepository userRepository;

    @Autowired
    private EntityManagerFactory emf;

    @Test
    void should_not_cause_n_plus_one() {
        SessionFactory sf = emf.unwrap(SessionFactory.class);
        Statistics stats = sf.getStatistics();
        stats.clear();

        List<DemoUser> users = userRepository.findAllWithOrders();
        users.forEach(u -> u.getOrders().size());

        assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
    }
}
java 复制代码
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DemoUserControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void should_return_users_without_n_plus_one() {
        var result = restTemplate.exchange(
            "/demo/users/dto",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<List<DemoUserDTO>>() {}
        );

        assertThat(result.getBody()).hasSize(10);
    }
}

分页错乱

错误代码如下:

java 复制代码
  @Query("SELECT u FROM DemoUser u JOIN FETCH u.orders")
    Page<DemoUser> findAllWithOrders(Pageable pageable);
java 复制代码
@Transactional(readOnly = true)
public Page<DemoUserDTO> listUsers(Pageable pageable) {
    Page<DemoUser> page = userRepository.findAllWithOrders(pageable);

    return page.map(u -> new DemoUserDTO(
            u.getId(),
            u.getUsername(),
            u.getOrders().size()
    ));
}

正确代码如下:

先查 ID,再 FETCH

java 复制代码
@Query("SELECT u.id FROM DemoUser u")
Page<Long> findUserIdPage(Pageable pageable);
@Query("SELECT u FROM DemoUser u JOIN FETCH u.orders WHERE u.id IN :ids")
List<DemoUser> findAllWithOrdersByIds(@Param("ids") List<Long> ids);
java 复制代码
@Transactional(readOnly = true)
public Page<DemoUserDTO> listUsers(Pageable pageable) {

    Page<Long> idPage = userRepository.findUserIdPage(pageable);

    List<DemoUser> users =
        userRepository.findAllWithOrdersByIds(idPage.getContent());

    Map<Long, DemoUser> userMap = users.stream()
        .collect(Collectors.toMap(DemoUser::getId, Function.identity()));

    List<DemoUserDTO> dtos = idPage.getContent().stream()
        .map(id -> {
            DemoUser u = userMap.get(id);
            return new DemoUserDTO(
                u.getId(),
                u.getUsername(),
                u.getOrders().size()
            );
        })
        .toList();

    return new PageImpl<>(dtos, pageable, idPage.getTotalElements());
}

DTO 投影(无分页问题)
总结:

JOIN FETCH 会放大结果集行数,而 Spring Data 的分页是基于行数的,因此会导致分页错乱。
正确做法是:ID 分页 + 批量 FETCH,或直接用 DTO 投影。

相关推荐
DLYSB_1 小时前
生命通道:如何用 HIS 医疗系统直连网络声光终端,打造“零延误”的危急值响应网关?
java·网络·数据库·报警灯
skywalk81631 小时前
设计并实现段言的 C FFI 绑定机制 @Trae
c语言·开发语言·python·编程
weixin_BYSJ19871 小时前
SpringBoot + MySQL 乒乓球运动员信息管理系统项目实战--附源码04954
java·javascript·spring boot·python·django·flask·php
AI小码2 小时前
LLM 应用的缓存工程:当每次 API 调用都在燃烧成本
java·人工智能·spring·计算机·llm·编程·api
Hui Baby2 小时前
Spring Security
java·后端·spring
leoZ2312 小时前
Claude 驱动的全栈开发:Spring Boot + Vue 的 BS 架构实践
vue.js·spring boot·架构
IT笔记3 小时前
【Rust】Rust Match 模式匹配详解
java·开发语言·rust
逝水无殇3 小时前
C# 运算符重载详解
开发语言·后端·c#
我才是银古3 小时前
构建 Java GIS 工具库:从碎片化 API 到统一抽象的设计实践
java·gis·geotools·gdal·esri