# วิธีแก้ปัญหา N+1 ใน Spring Data JPA ปี 2026: Fetch Join และ EntityGraph > คู่มือฉบับสมบูรณ์สำหรับการตรวจจับและแก้ไขปัญหา N+1 ใน Spring Data JPA ครอบคลุม Fetch join, @EntityGraph, batch fetching และกลยุทธ์ประสิทธิภาพการคิวรี - Published: 2026-03-17 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- ปัญหา N+1 ถือเป็นหนึ่งในกับดักด้านประสิทธิภาพที่พบบ่อยที่สุดใน JPA คิวรีธรรมดาสำหรับดึงคำสั่งซื้อ 100 รายการอาจกระตุ้นให้เกิดคิวรี SQL ถึง 101 ครั้ง: หนึ่งครั้งสำหรับคำสั่งซื้อ และอีกหนึ่งครั้งสำหรับลูกค้าที่เกี่ยวข้องแต่ละราย การเพิ่มจำนวนคิวรีอย่างเงียบๆ นี้ทำให้ประสิทธิภาพลดลงและสร้างภาระให้ฐานข้อมูล > **ผลกระทบจริงของ N+1** > > endpoint ที่คืนค่าบทความ 50 รายการพร้อมผู้เขียนอาจกระโดดจาก 10ms เป็น 500ms เนื่องจาก N+1 การตรวจจับแต่เนิ่นๆ ช่วยป้องกันปัญหาวิกฤตในระบบโปรดักชัน ## ทำความเข้าใจปัญหา N+1 ใน JPA ปัญหา N+1 เกิดขึ้นเมื่อ JPA โหลดคอลเลกชันของเอนทิตี จากนั้นเรียกใช้คิวรีเพิ่มเติมสำหรับแต่ละเอนทิตีเพื่อโหลดความสัมพันธ์ของมัน พฤติกรรมนี้มาจากการ lazy loading ที่เป็นค่าเริ่มต้นของความสัมพันธ์ `@OneToMany` และ `@ManyToMany` ลองพิจารณาโมเดลคลาสสิกที่มีคำสั่งซื้อและลูกค้า แต่ละคำสั่งซื้อเป็นของลูกค้าหนึ่งราย และความสัมพันธ์นี้ใช้ lazy loading เป็นค่าเริ่มต้น ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; private LocalDateTime createdAt; // ManyToOne relationship is lazy by default since JPA 2.0 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // OneToMany relationship lazy by default @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List items = new ArrayList<>(); // Getters and setters omitted } ``` ```java // Customer.java @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; // Getters and setters omitted } ``` เมื่อคิวรีดึงคำสั่งซื้อแล้วเข้าถึงชื่อลูกค้า Hibernate จะเรียกใช้คิวรีเพิ่มเติมสำหรับแต่ละคำสั่งซื้อ ```java // OrderService.java - Problematic code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 query: SELECT * FROM orders List orders = orderRepository.findAll(); // For each order, accessing customer triggers a query return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), // N queries: SELECT * FROM customers WHERE id = ? order.getCustomer().getName() )) .toList(); } } ``` สำหรับคำสั่งซื้อ 100 รายการ โค้ดนี้เรียกใช้คิวรี SQL 101 ครั้ง บันทึกของ Hibernate เผยให้เห็นรูปแบบที่ทำลายล้างนี้ ```sql -- Query 1: fetch orders SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o -- Queries 2-101: fetch each customer SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3 -- ... 97 more identical queries ``` ## การตรวจจับปัญหา N+1 ด้วยบันทึกของ Hibernate ขั้นตอนแรกเกี่ยวข้องกับการเปิดใช้งานบันทึก SQL เพื่อระบุคิวรีที่มีปัญหา การกำหนดค่าต่อไปนี้แสดงทุกคิวรีที่ Hibernate เรียกใช้ ```yaml # application.yml spring: jpa: show-sql: true properties: hibernate: # Format SQL for better readability format_sql: true # Display session statistics (queries, time) generate_statistics: true logging: level: # Detailed SQL query logging org.hibernate.SQL: DEBUG # Display prepared statement parameters org.hibernate.orm.jdbc.bind: TRACE ``` สถิติของ Hibernate ให้สรุปที่มีค่าเมื่อสิ้นสุดแต่ละธุรกรรม ``` Session Metrics { 23421 nanoseconds spent acquiring 1 JDBC connection; 0 nanoseconds spent releasing 0 JDBC connections; 1254789 nanoseconds spent preparing 101 JDBC statements; 15478963 nanoseconds spent executing 101 JDBC statements; 0 nanoseconds spent executing 0 JDBC batches; } ``` จำนวน 101 คำสั่ง JDBC สำหรับรายการคำสั่งซื้อธรรมดาเป็นสัญญาณที่ชัดเจนของปัญหา N+1 > **ปิดใช้งานในระบบโปรดักชัน** > > บันทึก SQL และสถิติส่งผลกระทบต่อประสิทธิภาพ ตัวเลือกเหล่านี้ควรปิดใช้งานในระบบโปรดักชันและสงวนไว้สำหรับสภาพแวดล้อมการพัฒนาและการทดสอบ ## วิธีแก้ที่ 1: Fetch Join ด้วย JPQL Fetch join โหลดความสัมพันธ์ในคิวรี SQL เดียวผ่าน join แนวทางที่ชัดเจนนี้แก้ปัญหา N+1 โดยดึงข้อมูลที่จำเป็นทั้งหมดในครั้งเดียว ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Explicit fetch join to load customers @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Multiple fetch join for several associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join with WHERE condition @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List findRecentOrdersWithCustomer( @Param("since") LocalDateTime since ); } ``` Fetch join เปลี่ยนคิวรี N+1 ให้เป็นคิวรีที่ปรับให้เหมาะสมเพียงครั้งเดียว ```sql -- Single query with join SELECT o.id, o.order_number, o.created_at, o.customer_id, c.id, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id ``` ตอนนี้บริการใช้เมธอดที่ปรับให้เหมาะสมโดยไม่ต้องแก้ไขโค้ดทางธุรกิจ ```java // OrderService.java - Optimized code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // Single query with join List orders = orderRepository.findAllWithCustomer(); // No additional queries return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Already loaded )) .toList(); } } ``` ## วิธีแก้ที่ 2: @EntityGraph สำหรับการควบคุมแบบประกาศ คำอธิบายประกอบ `@EntityGraph` นำเสนอทางเลือกแบบประกาศแทน fetch join โดยกำหนดว่าจะโหลดความสัมพันธ์ใดโดยไม่ต้องเขียน JPQL แบบกำหนดเอง ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Inline EntityGraph with attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph with multiple attributes @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // Named EntityGraph referencing entity definition @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combination with custom query @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List findByOrderNumberPrefix(@Param("prefix") String prefix); } ``` EntityGraph ที่มีชื่อถูกกำหนดโดยตรงบนเอนทิตีเพื่อนำกลับมาใช้ในหลาย repository ```java // Order.java @Entity @Table(name = "orders") @NamedEntityGraph( name = "Order.withCustomer", attributeNodes = @NamedAttributeNode("customer") ) @NamedEntityGraph( name = "Order.withCustomerAndItems", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode("items") } ) @NamedEntityGraph( name = "Order.full", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode(value = "items", subgraph = "items-product") }, subgraphs = @NamedSubgraph( name = "items-product", attributeNodes = @NamedAttributeNode("product") ) ) public class Order { // Fields unchanged } ``` Subgraph อนุญาตให้โหลดความสัมพันธ์ที่ซ้อนกันได้ ตัวอย่างข้างต้นโหลดคำสั่งซื้อ รายการของพวกเขา และผลิตภัณฑ์ของแต่ละรายการในคิวรีเดียว ## วิธีแก้ที่ 3: Batch Fetching สำหรับคอลเลกชัน Batch fetching นำเสนอทางเลือกแทน fetch join สำหรับคอลเลกชัน `@OneToMany` แทนที่จะโหลดแต่ละคอลเลกชันทีละรายการ Hibernate จะจัดกลุ่มคิวรีเป็นชุด ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // Batch fetching on the collection @OneToMany(mappedBy = "order") @BatchSize(size = 25) private List items = new ArrayList<>(); } ``` ด้วย `@BatchSize(size = 25)` Hibernate จะโหลดรายการสำหรับคำสั่งซื้อ 25 รายการในคราวเดียวแทนที่จะเป็น 1 รายการ สำหรับ 100 คำสั่งซื้อ จำนวนคิวรีลดลงจาก 101 เป็น 5 ```sql -- Without batch fetching: 100 queries SELECT * FROM order_items WHERE order_id = 1 SELECT * FROM order_items WHERE order_id = 2 -- ... 98 more queries -- With @BatchSize(size = 25): 4 queries SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25) SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50) SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75) SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100) ``` การกำหนดค่าขนาดแบทช์ทั่วโลกใช้กับคอลเลกชันทั้งหมดในแอปพลิเคชัน ```yaml # application.yml spring: jpa: properties: hibernate: # Global default batch size default_batch_fetch_size: 25 ``` > **Batch กับ Fetch Join** > > Batch fetching เหมาะสำหรับกรณีที่ fetch join สร้างผลคูณคาร์ทีเซียนที่ใหญ่เกินไป สำหรับคำสั่งซื้อที่มี 10 รายการและ 5 การชำระเงิน fetch join คืนค่า 50 แถว Batch fetching เรียกใช้คิวรีแยก 2 รายการที่มีประสิทธิภาพมากกว่า ## เปรียบเทียบกลยุทธ์การโหลด แต่ละกลยุทธ์มีข้อได้เปรียบขึ้นอยู่กับบริบทการใช้งาน ตารางต่อไปนี้สรุปกรณีการใช้งานที่เหมาะสมที่สุด | กลยุทธ์ | กรณีการใช้งาน | ข้อดี | ข้อเสีย | |----------|----------|------------|---------------| | **Fetch Join** | ความสัมพันธ์ `@ManyToOne` | คิวรี SQL เดียว | ผลคูณคาร์ทีเซียนกับคอลเลกชัน | | **@EntityGraph** | การโหลดแบบประกาศ | ใช้ซ้ำได้ อ่านง่าย | ยืดหยุ่นน้อยกว่า JPQL | | **Batch Fetching** | คอลเลกชัน `@OneToMany` | หลีกเลี่ยงผลคูณคาร์ทีเซียน | คิวรีหลายรายการ | | **Subselect** | คอลเลกชันที่เข้าถึงไม่บ่อย | โหลดเฉพาะเมื่อจำเป็น | คิวรีย่อยที่สัมพันธ์กัน | กลยุทธ์ subselect โหลดทั้งคอลเลกชันเมื่อเข้าถึงองค์ประกอบใดๆ เป็นครั้งแรก ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List items = new ArrayList<>(); ``` ```sql -- Generated subselect query SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?) ``` ## หลีกเลี่ยง N+1 ด้วย DTO Projections DTO projection นำเสนอแนวทางที่รุนแรงแต่มีประสิทธิภาพ โดยการเลือกเฉพาะคอลัมน์ที่จำเป็น projection หลีกเลี่ยงการโหลดเอนทิตีและความสัมพันธ์ของพวกเขาโดยสิ้นเชิง ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // DTO projection with constructor @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c") List findAllOrderSummaries(); // DTO projection with condition @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c " + "WHERE o.createdAt > :since") List findRecentOrderSummaries( @Param("since") LocalDateTime since ); } ``` แนวทางนี้สร้างคิวรี SQL ที่เหมาะสมที่สุดโดยไม่มีค่าใช้จ่ายของการแมปเอนทิตี ```sql SELECT o.id, o.order_number, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.created_at > ? ``` ## การกำหนดค่าขั้นสูงด้วย Spring Data JPA Spring Data JPA 3.x แนะนำการปรับปรุงสำหรับการจัดการ EntityGraph และคิวรีที่ปรับให้เหมาะสม ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Dynamic EntityGraph with Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination with EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice for efficient pagination @EntityGraph(attributePaths = {"customer"}) Slice findByCreatedAtBefore( LocalDateTime date, Pageable pageable ); } ``` การโหลดแบบมีเงื่อนไขอนุญาตให้ใช้กลยุทธ์ที่แตกต่างกันตามบริบท ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List getOrdersWithGraph(String graphName) { // Dynamic EntityGraph retrieval EntityGraph graph = entityManager .getEntityGraph(graphName); return entityManager .createQuery("SELECT o FROM Order o", Order.class) .setHint("jakarta.persistence.loadgraph", graph) .getResultList(); } public List getOrdersForListing() { // Minimal graph for listing return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Full graph for detail view return getOrdersWithGraph("Order.full"); } } ``` ## การทดสอบประสิทธิภาพเพื่อตรวจจับ N+1 การทดสอบอัตโนมัติยืนยันว่าไม่มีปัญหา N+1 โดยการนับคิวรี SQL ที่เรียกใช้ ```java // OrderRepositoryPerformanceTest.java @DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) class OrderRepositoryPerformanceTest { @Autowired private OrderRepository orderRepository; @Autowired private EntityManager entityManager; @PersistenceContext private EntityManager em; private Statistics statistics; @BeforeEach void setUp() { // Enable Hibernate statistics Session session = entityManager.unwrap(Session.class); SessionFactory factory = session.getSessionFactory(); statistics = factory.getStatistics(); statistics.setStatisticsEnabled(true); statistics.clear(); } @Test void findAllWithCustomer_shouldExecuteSingleQuery() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch with fetch join List orders = orderRepository.findAllWithCustomer(); // Then: single query executed assertThat(orders).hasSize(50); assertThat(statistics.getQueryExecutionCount()) .as("Should execute only 1 query with fetch join") .isEqualTo(1); } @Test void findAll_withoutOptimization_triggersNPlus1() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch without optimization List orders = orderRepository.findAll(); // Access customers to trigger lazy loading orders.forEach(o -> o.getCustomer().getName()); // Then: N+1 queries (51 instead of 1) assertThat(statistics.getQueryExecutionCount()) .as("Should detect N+1 problem") .isGreaterThan(1); } private void createTestOrders(int count) { for (int i = 0; i < count; i++) { Customer customer = new Customer(); customer.setName("Customer " + i); customer.setEmail("customer" + i + "@test.com"); entityManager.persist(customer); Order order = new Order(); order.setOrderNumber("ORD-" + i); order.setCustomer(customer); order.setCreatedAt(LocalDateTime.now()); entityManager.persist(order); } entityManager.flush(); } } ``` การยืนยันบน `getQueryExecutionCount()` รับประกันว่าการเพิ่มประสิทธิภาพยังคงอยู่ระหว่างวิวัฒนาการของโค้ด ## บทสรุป ปัญหา N+1 เป็นความท้าทายด้านประสิทธิภาพที่สำคัญใน Spring Data JPA แต่มีวิธีแก้ที่มีประสิทธิภาพหลายวิธี Fetch join และ `@EntityGraph` แก้ปัญหาส่วนใหญ่โดยการโหลดความสัมพันธ์ในคิวรีเดียว Batch fetching เป็นทางเลือกสำหรับคอลเลกชันขนาดใหญ่ที่ fetch join สร้างผลคูณคาร์ทีเซียน **รายการตรวจสอบการป้องกัน N+1:** - ✅ เปิดใช้งานบันทึก SQL ในการพัฒนาเพื่อตรวจจับคิวรีหลายรายการ - ✅ ใช้ `JOIN FETCH` สำหรับความสัมพันธ์ `@ManyToOne` ที่เข้าถึงบ่อย - ✅ ประยุกต์ใช้ `@EntityGraph` สำหรับการโหลดแบบประกาศที่ใช้ซ้ำได้ - ✅ กำหนดค่า `@BatchSize` สำหรับคอลเลกชัน `@OneToMany` ขนาดใหญ่ - ✅ ใช้ DTO projection สำหรับการดำเนินการแบบอ่านอย่างเดียว - ✅ เขียนการทดสอบเพื่อตรวจสอบจำนวนคิวรีที่เรียกใช้ - ✅ ปิดใช้งานบันทึก SQL และสถิติในระบบโปรดักชัน - ✅ ทำโปรไฟล์ endpoint สำคัญเป็นประจำด้วยเมตริกของ Hibernate --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph