+2

Vấn đề N+1 queries

1. Vấn đề N+1 queries là gì

Vấn đề N+1 queries xảy ra khi một ORM như Hibernate thực thi một truy vấn SQL để truy xuất thực thể chính từ mối quan hệ parent-child và sau đó một truy vấn SQL cho từng đối tượng child.

Vấn đề N+1 queries không riêng cho JPA và Hibernate vì bạn có thể gặp phải vấn đề này ngay cả khi bạn đang sử dụng các công nghệ truy cập dữ liệu khác.

2. Khi sử dụng Hibernate

Phần này mình tham khảo ở đây Giả sử chúng ta có các bảng cơ sở dữ liệu post và post_comments sau đây tạo thành mối quan hệ bảng one-to-many:

image.png

Chúng ta sẽ tạo 4 hàng trong bảng post như sau:

INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 1', 1)
  
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 2', 2)
  
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 3', 3)
  
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 4', 4)

Và chúng ta cũng sẽ tạo 4 bản ghi trong bảng post_comment child:

INSERT INTO post_comment (post_id, review, id)
VALUES (1, 'Excellent book to understand Java Persistence', 1)
  
INSERT INTO post_comment (post_id, review, id)
VALUES (2, 'Must-read for Java developers', 2)
  
INSERT INTO post_comment (post_id, review, id)
VALUES (3, 'Five Stars', 3)
  
INSERT INTO post_comment (post_id, review, id)
VALUES (4, 'A great reference book', 4)

2.1. Vấn đề N+1 queries khi sử dụng Plain SQL

Vấn đề truy vấn N+1 có thể được kích hoạt bằng cách sử dụng bất kỳ công nghệ truy cập dữ liệu nào, ngay cả với Plain SQL( Câu truy vấn SQL thông thường).

Nếu bạn select post_comments bằng truy vấn SQL này:

List<Tuple> comments = entityManager.createNativeQuery("""
    SELECT
        pc.id AS id,
        pc.review AS review,
        pc.post_id AS postId
    FROM post_comment pc
    """, Tuple.class)
.getResultList();

Và sau đó, bạn quyết định fetch title trong bảng post liên quan cho mỗi post_comment:

for (Tuple comment : comments) {
    String review = (String) comment.get("review");
    Long postId = ((Number) comment.get("postId")).longValue();
 
    String postTitle = (String) entityManager.createNativeQuery("""
        SELECT
            p.title
        FROM post p
        WHERE p.id = :postId
        """)
    .setParameter("postId", postId)
    .getSingleResult();
 
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        postTitle,
        review
    );
}

Ta sắp kích hoạt vấn đề N+1 queries vì thay vì một truy vấn SQL, bạn đã thực thi 5 (1 + 4):

SELECT
    pc.id AS id,
    pc.review AS review,
    pc.post_id AS postId
FROM post_comment pc
 
SELECT p.title FROM post p WHERE p.id = 1
    
SELECT p.title FROM post p WHERE p.id = 2
     
SELECT p.title FROM post p WHERE p.id = 3
     
SELECT p.title FROM post p WHERE p.id = 4

Việc khắc phục vấn đề N+1 query trong trường hợp này rất dễ dàng. Tất cả những gì ta cần làm là trích xuất tất cả dữ liệu ta cần trong truy vấn SQL gốc như thế này:

List<Tuple> comments = entityManager.createNativeQuery("""
    SELECT
        pc.id AS id,
        pc.review AS review,
        p.title AS postTitle
    FROM post_comment pc
    JOIN post p ON pc.post_id = p.id
    """, Tuple.class)
.getResultList();
 
for (Tuple comment : comments) {
    String review = (String) comment.get("review");
    String postTitle = (String) comment.get("postTitle");
 
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        postTitle,
        review
    );
}

Lần này, chỉ một truy vấn SQL được thực thi để tìm nạp tất cả dữ liệu mà chúng ta cần.

2.2. Vấn đề N+1 queries với Hibernate

Khi sử dụng Hibernate, có một số cách ta có thể kích hoạt vấn đề N+1 queries, vì vậy điều quan trọng là ta phải biết cách tránh những tình huống này.

Đối với các ví dụ tiếp theo, hãy xem xét việc chúng ta đang ánh xạ các bảng post và post_comments tới các entities sau: image.png

Code ánh xạ JPA sẽ như thế này:

@Entity(name = "Post")
@Table(name = "post")
public class Post {
 
    @Id
    private Long id;
 
    private String title;
 
    //Getters and setters
}
 
@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {
 
    @Id
    private Long id;
 
    @ManyToOne
    private Post post;
 
    private String review;
 
    //Getters and setters
}

2.2.1 FetchType.EAGER

Sử dụng FetchType.EAGER một cách ngầm định hoặc rõ ràng cho các liên kết JPA là một ý tưởng tồi vì ta sẽ tìm nạp nhiều dữ liệu hơn mà ta cần. Hơn nữa, chiến lược FetchType.EAGER cũng dễ gặp phải các vấn đề về truy vấn N+1.

Thật không may, các liên kết @ManyToOne@OneToOne sử dụng FetchType.EAGER theo mặc định, vì vậy nếu code ánh xạ của chúng ta trông như thế này:

@ManyToOne
private Post post;

Chúng ta đang sử dụng chiến lược FetchType.EAGER và mỗi khi ta quên sử dụng JOIN FETCH khi tải một số thực thể PostComment bằng truy vấn JPQL hoặc Criteria API query:

List<PostComment> comments = entityManager
.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

Ta sẽ kích hoạt vấn đề N+1 queries:

SELECT
    pc.id AS id1_1_,
    pc.post_id AS post_id3_1_,
    pc.review AS review2_1_
FROM
    post_comment pc
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4

Lưu ý các câu lệnh SELECT bổ sung được thực thi vì liên kết với bảng post phải được tìm nạp trước khi trả về list các entities PostComment.

Không giống như plan tìm nạp mặc định mà bạn đang sử dụng khi gọi phương thức findfind của EntityManager class, truy vấn JPQL hoặc API tiêu chí xác định một plan rõ ràng mà Hibernate không thể thay đổi bằng cách tự động thêm JOIN FETCH. Vì vậy, ta cần phải làm điều đó một cách thủ công.

Nếu ta hoàn toàn không cần dữ liệu liên kết ở bảng post, ta sẽ không gặp may khi sử dụng FetchType.EAGER vì không có cách nào để tránh tìm nạp nó. Đó là lý do tại sao nên sử dụng FetchType.LAZY theo mặc định sẽ tốt hơn.

Tuy nhiên, nếu ta muốn sử dụng dữ liệu liên kết ở bảng post, thì ta có thể sử dụng JOIN FETCH để tránh vấn đề truy vấn N+1:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    join fetch pc.post p
    """, PostComment.class)
.getResultList();
 
for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        comment.getPost().getTitle(),
        comment.getReview()
    );
}

Lần này, Hibernate sẽ thực thi một câu lệnh SQL:

SELECT
    pc.id as id1_1_0_,
    pc.post_id as post_id3_1_0_,
    pc.review as review2_1_0_,
    p.id as id1_0_1_,
    p.title as title2_0_1_
FROM
    post_comment pc
INNER JOIN
    post p ON pc.post_id = p.id

2.2.2 FetchType.LAZY

Ngay cả khi ta chuyển sang sử dụng FetchType.LAZY một cách rõ ràng cho tất cả các liên kết, ta vẫn có thể gặp phải vấn đề N+1 queries.

Lần này, bảng post được ánh xạ như thế này:

@ManyToOne(fetch = FetchType.LAZY)
private Post post;

Bây giờ, khi ta fetch các PostComment entities:

List<PostComment> comments = entityManager
.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

Hibernate sẽ thực thi một câu lệnh SQL duy nhất:

SELECT
    pc.id AS id1_1_,
    pc.post_id AS post_id3_1_,
    pc.review AS review2_1_
FROM
    post_comment pc

Tuy nhiên, nếu sau đó, bạn sẽ gọi tới liên kết của bảng post mà ta đang đánh dấu là lazy-loaded:

for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        comment.getPost().getTitle(),
        comment.getReview()
    );
}

Ta sẽ gặp vấn đề N+1 queries:

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4

Vì các liên kết với bảng post được fetch một cách lazy nên một câu lệnh SQL phụ sẽ được thực thi khi truy cập vào liên kết này.

Một lần nữa, cách khắc phục bao gồm việc thêm mệnh đề JOIN FETCH vào truy vấn JPQL:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    join fetch pc.post p
    """, PostComment.class)
.getResultList();
 
for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        comment.getPost().getTitle(),
        comment.getReview()
    );
}

Và giống như trong ví dụ FetchType.EAGER, truy vấn JPQL này sẽ tạo ra một câu lệnh SQL duy nhất.

2.2.3 Second-level cache

Chi tiết về loại cache này các bạn có thể tham khảo thêm ở đây hoặc ở đây

Phần này của bài viết mình tham khảo ở đây image.png

Được ánh xạ như sau:

@Entity(name = "Post")
@Table(name = "post")
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE
)
public class Post {
 
    @Id
    @GeneratedValue
    private Long id;
 
    private String title;
 
    //Getters and setters
}
 
@Entity(name = "PostComment")
@Table(name = "post_comment")
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE
)
public class PostComment {
 
    @Id
    @GeneratedValue
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;
 
    private String review;
 
    //Getters and setters
}

Cả thực thể Post và PostComment đều có thể lưu vào bộ nhớ đệm và sử dụng READ_WRITE CacheConcurrencyStrategy.

Query Cache
Để cho phép Query Cache, chúng ta cũng cần cho phép second-level cache. Do đó, các thuộc tính cấu hình sau đây phải được cung cấp:

<property
    name="hibernate.cache.use_second_level_cache"
    value="true"
/>
 
<property
    name="hibernate.cache.use_query_cache"
    value="true"
/>
 
<property
    name="hibernate.cache.region.factory_class"
    value="ehcache"
/>

Mặc dù chúng ta đã cho phép Query Cache nhưng nó không tự động áp dụng cho bất kỳ truy vấn nào và chúng ta cần thông báo rõ ràng cho Hibernate những truy vấn nào sẽ được lưu vào bộ đệm. Để làm như vậy, ta cần sử dụng gợi ý truy vấn org.hibernate.cacheable như minh họa trong ví dụ sau:

public List<PostComment> getLatestPostComments(
        EntityManager entityManager) {
    return entityManager.createQuery(
        "select pc " +
        "from PostComment pc " +
        "order by pc.post.id desc", PostComment.class)
    .setMaxResults(10)
    .setHint(QueryHints.HINT_CACHEABLE, true)
    .getResultList();
}

Bây giờ, nếu chúng ta gọi method getLatestPostComments hai lần, chúng ta có thể thấy rằng kết quả được tìm nạp từ bộ đệm vào lần thứ hai chúng ta thực thi phương thức này.
Vì vậy, khi thử thực thi test case sau:

printCacheRegionStatistics(
    StandardQueryCache.class.getName()
);
assertEquals(
    3,
    getLatestPostComments(entityManager).size()
);
 
printCacheRegionStatistics(
    StandardQueryCache.class.getName()
);
assertEquals(
    3,
    getLatestPostComments(entityManager).size()
);

Hibernate tạo ra kết quả sau:

Region: org.hibernate.cache.internal.StandardQueryCache,
Statistics: SecondLevelCacheStatistics[
    hitCount=0,
    missCount=0,
    putCount=0,
    elementCountInMemory=0,
    elementCountOnDisk=0,
    sizeInMemory=0
],
Entries: {}
 
-- Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache
-- Query results were not found in cache
 
SELECT pc.id AS id1_1_,
       pc.post_id AS post_id3_1_,
       pc.review AS review2_1_
FROM post_comment pc
ORDER BY pc.post_id DESC
LIMIT 10
 
-- Caching query results in region: org.hibernate.cache.internal.StandardQueryCache; timestamp=6244549098291200
 
Region: org.hibernate.cache.internal.StandardQueryCache,
Statistics: SecondLevelCacheStatistics[
    hitCount=0,
    missCount=1,
    putCount=1,
    elementCountInMemory=1,
    elementCountOnDisk=0,
    sizeInMemory=776
],
Entries: {
sql: select pc.id as id1_1_, pc.post_id as post_id3_1_, pc.review as review2_1_ from post_comment pc order by pc.post_id desc; parameters: ;
named parameters: {};
max rows: 10;
transformer: org.hibernate.transform.CacheableResultTransformer@110f2=[
    6244549098291200,
    4,
    3,
    2
]}
 
-- Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache
-- Checking query spaces are up-to-date: [post_comment]
-- [post_comment] last update timestamp: 6244549098266628, result set timestamp: 6244549098291200
-- Returning cached query results

Như bạn có thể thấy trong log, chỉ lệnh gọi đầu tiên đã thực thi truy vấn SQL vì lệnh gọi thứ hai sử dụng tập kết quả được lưu trong bộ nhớ đệm.

N+1 query
Bây giờ, hãy xem điều gì sẽ xảy ra nếu chúng ta loại bỏ tất cả các thực thể PostComment trước khi chạy lệnh gọi thứ hai đến phương thức getLatestPostComments.

doInJPA(entityManager -> {
    entityManager
    .getEntityManagerFactory()
    .getCache()
    .evict(PostComment.class);
});
 
doInJPA(entityManager -> {
    assertEquals(
        3,
        getLatestPostComments(entityManager).size()
    );
});

Khi chạy test case ở trên, Hibernate tạo ra kết quả đầu ra sau:

-- Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache
-- Checking query spaces are up-to-date: [post_comment]
-- [post_comment] last update timestamp: 6244574473195524, result set timestamp: 6244574473207808
-- Returning cached query results
 
SELECT pc.id AS id1_1_0_,
       pc.post_id AS post_id3_1_0_,
       pc.review AS review2_1_0_
FROM post_comment pc
WHERE pc.id = 4
 
SELECT pc.id AS id1_1_0_,
       pc.post_id AS post_id3_1_0_,
       pc.review AS review2_1_0_
FROM post_comment pc
WHERE pc.id = 3
 
SELECT pc.id AS id1_1_0_,
       pc.post_id AS post_id3_1_0_,
       pc.review AS review2_1_0_
FROM post_comment pc
WHERE pc.id = 2

Như bạn có thể thấy trong log, ngay cả khi entity identifiers được tìm nạp từ Query Cache, vì không tìm thấy các thực thể trong second-level cache, nên các thực thể PostComment sẽ được fetch bằng truy vấn SQL.

Nếu kết quả của Query Cache chứa N entity identifiers thì N truy vấn phụ sẽ được thực thi, điều này thực sự có thể tệ hơn so với việc thực thi truy vấn mà chúng ta đã lưu vào bộ đệm trước đó.

Đây là vấn đề N+1 queries điển hình, chỉ là truy vấn đầu tiên được cung cấp từ bộ đệm trong khi N queries sau truy cập vào cơ sở dữ liệu.

Phòng tránh trong trường hợp này
Để tránh sự cố này, bạn phải đảm bảo rằng entities được cache phải lưu trữ trong second-level cache.
Đảm bảo rằng các entities PostComment có thể lưu vào bộ nhớ đệm, nghĩa là bạn đã chú thích nó bằng chú thích @Cache dành riêng cho Hibernate. Mặc dù JPA định nghĩa chú thích @Cacheable, nhưng điều đó là chưa đủ vì Hibernate cần biết CacheConcurrencycStrategy mà ta muốn sử dụng cho thực thể được đề cập.

3. Khi sử dụng Spring Data JPA

Phần này mình tham khảo ở đây

Ta có Entity Model như sau:

@Entity
@Table(name = "t_users")
public class User {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;
    private String name;

    @ManyToMany(fetch = FetchType.LAZY)                   
    private Set<Role> roles;
    //Getter and Setters removed for brevity
 }

@Entity
@Table(name = "t_roles")
public class Role {

    @Id
    @GeneratedValue(strategy= GenerationType.AUTO)
    private Long id;

    private String name;
    //Getter and Setters removed for brevity
 }

Xử lý Vấn đề N+1 queries trong trường hợp này

Nếu chúng ta đang sử dụng Spring Data JPA, thì chúng ta có hai tùy chọn để đạt được điều này - sử dụng EntityGraph hoặc sử dụng truy vấn fetch join.

public interface UserRepository extends CrudRepository<User, Long> {

    List<User> findAllBy();                       (1) 

    @Query("SELECT p FROM User p LEFT JOIN FETCH p.roles")  
    List<User> findWithoutNPlusOne();             (2)

    @EntityGraph(attributePaths = {"roles"})                
    List<User> findAll();                          (3) 
}

(1) Tương tự như trên phần Hibernate, giả sử ta lấy được list user ra, sau đó in ra toàn bộ roles của từng user(Được lấy ra 1 cách lazy) => vấn đề N+1 queries
(2) Sử dụng left join fetch, chúng ta giải quyết vấn đề N+1 queries
(3) Sử dụng EntityGraph, chúng ta giải quyết vấn đề N+1 queries

Trong EntityGraph, ngoài thuộc tính attributePaths, còn 1 thuộc tính nữa là type

Type Mô tả
EntityGraphType.FETCH Các thuộc tính có trong attributePath được coi là FetchType.EAGER. Các thuộc tính khác được tìm thấy trong các entity JPA không được bao gồm trong attributePath sẽ được xử lý như FetchType.LAZY.
EntityGraphType.LOAD Các thuộc tính có trong attributePath được coi là FetchType.EAGER. Các thuộc tính khác được tìm thấy trong các entity JPA không được bao gồm trong attributePath sẽ được xử lý theo đặc tả riêng của chúng hoặc FetchType mặc định.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.