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
- Spring Boot cơ bản (Hướng dẫn bắt đầu)
- JPA fundamentals (Hướng dẫn Database Integration)
- Hiểu SQL và relational databases
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
| Scenario | Không có N+1 | Có N+1 |
|---|---|---|
| 100 authors | 2 queries, ~50ms | 101 queries, ~2000ms |
| 1000 authors | 2 queries, ~100ms | 1001 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: TRACESử 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_idFetch 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: 20Per-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
| Relationship | Default | Khuyến Nghị |
|---|---|---|
@ManyToOne | EAGER | Xem xét LAZY, dùng fetch join khi cần |
@OneToOne | EAGER | Xem xét LAZY, đặc biệt nếu optional |
@OneToMany | LAZY | Giữ LAZY, dùng fetch join hoặc batch |
@ManyToMany | LAZY | Giữ LAZY, dùng fetch join hoặc batch |
Tắt Open Session in View
spring:
jpa:
open-in-view: false # ✅ Khuyến nghị cho production10. Giám Sát Query
Log Slow Queries
spring:
jpa:
properties:
hibernate:
session:
events:
log:
LOG_QUERIES_SLOWER_THAN_MS: 100Metrics với Actuator
management:
endpoints:
web:
exposure:
include: health,metrics,prometheusGiám sát:
hibernate.query.executions- Tổng queries thực thihikaricp.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 GraphTiế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.