Back to blog

JPA Nâng Cao: Tối Ưu Query & Giải Quyết N+1 trong Spring Boot

javaspring-bootjpahibernatedatabaseperformancebackend
JPA Nâng Cao: Tối Ưu Query & Giải Quyết N+1 trong Spring Boot

Giới Thiệu

JPA giúp thao tác database trở nên dễ dàng—cho đến khi ứng dụng của bạn chậm lại vì những vấn đề hiệu năng ẩn. Vấn đề N+1 query khét tiếng, lazy loading pitfalls và fetch strategies không hiệu quả có thể biến ứng dụng nhanh thành chậm.

Bạn Sẽ Học Được

✅ Hiểu vấn đề N+1 query và tại sao nó quan trọng
✅ Phát hiện N+1 với logging và tools
✅ Sửa N+1 với fetch joins, entity graphs và batch fetching
✅ Chọn fetch strategy phù hợp (LAZY vs EAGER)
✅ Sử dụng projections và DTOs cho read-only queries
✅ Viết JPQL và native queries hiệu quả
✅ Tối ưu bulk operations
✅ Giám sát query performance trong production

Yêu Cầu Trước


1. Hiểu Vấn Đề N+1 Query

N+1 Là Gì?

Vấn đề N+1 xảy ra khi code của bạn thực thi 1 query để fetch N entities, sau đó N queries bổ sung để fetch related data cho mỗi entity.

Ví dụ:

// Entity relationships
@Entity
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
 
    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    private List<Book> books = new ArrayList<>();
}
 
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;
}

Code có vấn đề:

@Service
public class AuthorService {
 
    public List<AuthorWithBooksDto> getAllAuthorsWithBooks() {
        // Query 1: Get all authors
        List<Author> authors = authorRepository.findAll();
 
        // Với mỗi author, truy cập books trigger thêm query!
        return authors.stream()
            .map(author -> new AuthorWithBooksDto(
                author.getId(),
                author.getName(),
                author.getBooks().size()  // ❌ N queries ở đây!
            ))
            .toList();
    }
}

Với 100 authors:

-- Query 1: Fetch authors
SELECT * FROM authors;
 
-- Query 2-101: Fetch books cho mỗi author
SELECT * FROM books WHERE author_id = 1;
SELECT * FROM books WHERE author_id = 2;
-- ... 97 queries nữa
SELECT * FROM books WHERE author_id = 100;

Đó là 101 queries thay vì 1 hoặc 2!

Tại Sao N+1 Nguy Hiểm

ScenarioKhông có N+1Có N+1
100 authors2 queries, ~50ms101 queries, ~2000ms
1000 authors2 queries, ~100ms1001 queries, ~20000ms

Performance giảm tuyến tính với dữ liệu tăng.


2. Phát Hiện N+1

Bật SQL Logging

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true
 
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

Sử Dụng Hypersistence Utils

<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-63</artifactId>
    <version>3.7.0</version>
</dependency>
@Test
void getAllAuthors_shouldNotHaveNPlus1() {
    SQLStatementCountValidator.reset();
 
    authorService.getAllAuthorsWithBooks();
 
    SQLStatementCountValidator.assertSelectCount(2);
}

3. Sửa N+1 Với Fetch Joins

JPQL Fetch Join

public interface AuthorRepository extends JpaRepository<Author, Long> {
 
    // ✅ Fetch join - load authors VÀ books trong 1 query
    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books")
    List<Author> findAllWithBooks();
 
    // ✅ Với điều kiện
    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books WHERE a.name LIKE %:name%")
    List<Author> findByNameWithBooks(@Param("name") String name);
}

Generated SQL:

SELECT a.*, b.*
FROM authors a
LEFT JOIN books b ON a.id = b.author_id

Fetch Join Với Pagination

⚠️ Vấn đề: Fetch joins với @OneToMany không hoạt động tốt với pagination.

Giải pháp: Two-query approach

public interface AuthorRepository extends JpaRepository<Author, Long> {
 
    // Query 1: Get IDs với pagination
    @Query("SELECT a.id FROM Author a")
    Page<Long> findAllIds(Pageable pageable);
 
    // Query 2: Fetch data cho những IDs đó
    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books WHERE a.id IN :ids")
    List<Author> findByIdsWithBooks(@Param("ids") List<Long> ids);
}

4. Entity Graphs

Entity Graphs cung cấp cách khai báo để định nghĩa fetch plans.

Named Entity Graph

@Entity
@NamedEntityGraph(
    name = "Author.withBooks",
    attributeNodes = @NamedAttributeNode("books")
)
public class Author {
    // ...
}

Sử Dụng Trong Repository

public interface AuthorRepository extends JpaRepository<Author, Long> {
 
    @EntityGraph(value = "Author.withBooks")
    List<Author> findByActiveTrue();
 
    // Ad-hoc entity graph
    @EntityGraph(attributePaths = {"books", "books.publisher"})
    Optional<Author> findWithBooksById(Long id);
}

5. Batch Fetching

Khi không thể dùng fetch joins, batch fetching giảm N queries xuống N/batch_size queries.

Cấu Hình Batch Size

Globally:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 20

Per-entity:

@Entity
public class Author {
 
    @OneToMany(mappedBy = "author")
    @BatchSize(size = 20)
    private List<Book> books;
}

6. Projections và DTOs

Queries nhanh nhất chỉ trả về những gì bạn cần.

Interface Projection

public interface AuthorSummary {
    Long getId();
    String getName();
    Integer getBookCount();
}
 
public interface AuthorRepository extends JpaRepository<Author, Long> {
 
    @Query("""
        SELECT a.id as id, a.name as name, COUNT(b) as bookCount
        FROM Author a LEFT JOIN a.books b
        GROUP BY a.id, a.name
        """)
    List<AuthorSummary> findAllSummaries();
}

Class-Based Projection (DTO)

public record AuthorDto(Long id, String name, int bookCount) {}
 
public interface AuthorRepository extends JpaRepository<Author, Long> {
 
    @Query("""
        SELECT new com.example.dto.AuthorDto(a.id, a.name, SIZE(a.books))
        FROM Author a
        """)
    List<AuthorDto> findAllAsDto();
}

7. JPQL và Native Queries Hiệu Quả

JPQL Best Practices

public interface BookRepository extends JpaRepository<Book, Long> {
 
    // ✅ Dùng parameters - không bao giờ concatenate!
    @Query("SELECT b FROM Book b WHERE b.title LIKE %:keyword%")
    List<Book> searchByTitle(@Param("keyword") String keyword);
 
    // ✅ Dùng EXISTS cho checking existence (nhanh hơn COUNT)
    @Query("SELECT CASE WHEN COUNT(b) > 0 THEN true ELSE false END FROM Book b WHERE b.isbn = :isbn")
    boolean existsByIsbn(@Param("isbn") String isbn);
}

Native Queries

@Query(value = """
    SELECT b.*
    FROM books b
    INNER JOIN authors a ON b.author_id = a.id
    WHERE b.title ILIKE '%' || :keyword || '%'
    ORDER BY b.created_at DESC
    LIMIT :limit
    """, nativeQuery = true)
List<Book> fullTextSearch(@Param("keyword") String keyword, @Param("limit") int limit);

Query Hints

// Read-only hint (Hibernate không track changes)
@QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
@Query("SELECT b FROM Book b WHERE b.price > :minPrice")
List<Book> findExpensiveReadOnly(@Param("minPrice") BigDecimal minPrice);

8. Bulk Operations

Bulk Updates

public interface BookRepository extends JpaRepository<Book, Long> {
 
    @Modifying
    @Query("UPDATE Book b SET b.price = b.price * :multiplier WHERE b.category = :category")
    int applyDiscount(@Param("category") String category, @Param("multiplier") BigDecimal multiplier);
 
    @Modifying
    @Query("DELETE FROM Book b WHERE b.soldOut = true AND b.updatedAt < :cutoffDate")
    int deleteOldSoldOutBooks(@Param("cutoffDate") LocalDateTime cutoffDate);
}

Batch Insert

@Transactional
public void importBooks(List<BookDto> books) {
    int batchSize = 50;
 
    for (int i = 0; i < books.size(); i++) {
        Book book = new Book(books.get(i).title(), books.get(i).price());
        em.persist(book);
 
        if (i > 0 && i % batchSize == 0) {
            em.flush();
            em.clear();
        }
    }
    em.flush();
    em.clear();
}

9. Fetch Strategy Guidelines

Khi Nào Dùng LAZY (Mặc Định)

// ✅ LAZY cho collections - luôn luôn!
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;

Bảng Quyết Định

RelationshipDefaultKhuyến Nghị
@ManyToOneEAGERXem xét LAZY, dùng fetch join khi cần
@OneToOneEAGERXem xét LAZY, đặc biệt nếu optional
@OneToManyLAZYGiữ LAZY, dùng fetch join hoặc batch
@ManyToManyLAZYGiữ LAZY, dùng fetch join hoặc batch

Tắt Open Session in View

spring:
  jpa:
    open-in-view: false  # ✅ Khuyến nghị cho production

10. Giám Sát Query

Log Slow Queries

spring:
  jpa:
    properties:
      hibernate:
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 100

Metrics với Actuator

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus

Giám sát:

  • hibernate.query.executions - Tổng queries thực thi
  • hikaricp.connections.active - Active DB connections

Tóm Tắt

✅ N+1 là performance killer #1 - luôn theo dõi
✅ Bật SQL logging trong development
✅ Dùng fetch joins để load related entities trong 1 query
✅ Dùng entity graphs cho reusable fetch plans
✅ Cấu hình batch fetching khi fetch joins không practical
✅ Dùng projections/DTOs cho read-only queries
✅ Bulk operations cho updates/inserts quy mô lớn
✅ Giữ LAZY làm mặc định
✅ Tắt OSIV trong production

Quick Reference: N+1 Solution Decision Tree

Cần load related data?
├── Luôn cần cùng nhau?
│   ├── Có → Xem xét EAGER (hiếm) hoặc default fetch join
│   └── Không → Giữ LAZY
│
├── Cho display/read-only?
│   └── Có → Dùng Projection/DTO
│
├── Cần full entities?
│   ├── Result set nhỏ → Fetch Join
│   ├── Cần pagination → Two-query approach
│   └── Result set lớn → Batch Fetching
│
└── Nhiều relationships?
    ├── Một level → Fetch Join với nhiều joins
    └── Graph sâu → Entity Graph

Tiếp Theo

Tiếp tục Spring Boot series:

Bài viết liên quan:


Thuộc series Spring Boot Learning Roadmap

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.