1. N+1 Query Problem là gì?

N+1 Query Problem là vấn đề hiệu năng xảy ra khi application thực hiện một truy vấn để lấy danh sách bản ghi, sau đó tiếp tục thực hiện thêm một truy vấn riêng cho từng bản ghi để lấy dữ liệu liên quan.

Vấn đề thường xuất hiện khi làm việc với quan hệ giữa các bảng, đặc biệt khi backend sử dụng ORM để truy vấn dữ liệu. Tuy nhiên, N+1 không chỉ xuất hiện ở ORM; developer cũng có thể vô tình tạo ra vấn đề này khi viết SQL thủ công và đặt truy vấn bên trong vòng lặp.

Tên gọi N+1 xuất phát từ số lượng truy vấn: một query ban đầu để lấy danh sách, cộng thêm N query để lấy dữ liệu liên quan cho N bản ghi.

Ví dụ thực tế về N+1 Query

Giả sử hệ thống có hai bảng users và posts. Một user có thể có nhiều bài viết, còn mỗi bài viết thuộc về một user.

users
- id
- name

posts
- id
- user_id
- title

Quan hệ giữa hai bảng là one-to-many. Backend cần tạo API trả danh sách user kèm các bài viết tương ứng.

Bước đầu tiên, application truy vấn danh sách user:

SELECT id, name
FROM users;

Giả sử kết quả có 100 user. Tiếp theo, application cần lấy bài viết của từng người:

SELECT id, title
FROM posts
WHERE user_id = 1;

Sau đó, query tương tự được chạy với user_id = 2, user_id = 3 và tiếp tục cho đến user cuối cùng.

Kết quả là:

  • Một query lấy danh sách user.
  • Một trăm query lấy bài viết của từng user.

Vì sao N+1 gây chậm?

Nguyên nhân chính là application thực hiện quá nhiều lần giao tiếp với database cho một yêu cầu logic.

Ngay cả khi mỗi query riêng lẻ chỉ mất vài mili giây, hàng trăm query tuần tự vẫn có thể làm tổng thời gian xử lý tăng đáng kể. Ngoài thời gian database thực thi SQL, application còn phải trả chi phí cho network round trip, xử lý kết quả và quản lý tài nguyên truy vấn.

N+1 cũng làm tăng số lần database phải tiếp nhận và xử lý câu lệnh. Khi nhiều request cùng xảy ra, lượng query phát sinh có thể tạo thêm áp lực lên connection pool và database server.

2. Nguyên nhân thường gặp của N+1 Query

N+1 thường xuất hiện khi application tải một danh sách bản ghi trước, rồi truy xuất dữ liệu liên quan riêng cho từng bản ghi.

Ví dụ phổ biến là lấy danh sách order rồi lấy thông tin user của từng order; lấy danh sách user rồi lấy posts của từng người; lấy danh sách posts rồi tiếp tục lấy comments của từng post.

Với một ORM, vấn đề có thể xuất hiện khi quan hệ được tải một cách trì hoãn (lazy loading) và application vô tình truy cập quan hệ trong vòng lặp. Khi đó, mỗi lần truy cập quan hệ có thể kích hoạt thêm một query.

Cách khắc phục thứ nhất: Eager Loading

Eager Loading là cách tải dữ liệu liên quan một cách chủ động ngay khi truy vấn dữ liệu chính, thay vì đợi đến lúc cần từng quan hệ mới truy vấn riêng.

Giả sử các model được khai báo như sau:

type User struct {
    ID    uint
    Name  string
    Posts []Post `gorm:"foreignKey:UserID"`
}

type Post struct {
    ID     uint
    UserID uint
    Title  string
}

Cách dễ phát sinh N+1:

var users []User

if err := db.Find(&users).Error; err != nil {
    return err
}

for i := range users {
    if err := db.
        Where("user_id = ?", users[i].ID).
        Find(&users[i].Posts).Error; err != nil {
        return err
    }
}

Ở đây, query đầu tiên lấy danh sách user. Sau đó, mỗi user tạo thêm một query để lấy posts.

Cách cải thiện bằng GORM:

var users []User

if err := db.Preload("Posts").Find(&users).Error; err != nil {
    return err
}

Thay vì truy vấn posts riêng cho từng user, GORM thường thực hiện hai query:

SELECT * FROM users;

SELECT * FROM posts
WHERE user_id IN (1, 2, 3, 4, ...);

Query thứ hai lấy posts của nhóm user và GORM ánh xạ các kết quả về đúng association tương ứng.

Như vậy, nếu cần tải posts cho 100 user, số query cơ bản thường giảm từ 101 xuống còn 2, không tính những query khác mà application có thể thực hiện. Đây là lợi ích chính của eager loading.

Cách khắc phục thứ hai: Batch Query

Batch Query là cách lấy dữ liệu liên quan cho nhiều bản ghi trong một query thay vì truy vấn từng bản ghi một.

Đây là ý tưởng cốt lõi mà eager loading thường sử dụng. Tuy nhiên, batch query cũng có thể được triển khai thủ công nếu application dùng SQL thuần hoặc không muốn phụ thuộc vào tính năng ORM.

Thay vì:

SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;

application lấy các user ID rồi truy vấn một lần:

SELECT *
FROM posts
WHERE user_id IN (1, 2, 3);

Sau đó, application gom các posts theo user_id trong bộ nhớ và gắn chúng vào từng user.

Cách làm này giảm số query, nhưng cần chú ý các trường hợp không có bài viết, tập ID rỗng và giới hạn số lượng tham số của database driver.

Với danh sách rất lớn, có thể chia ID thành từng batch nhỏ để tránh câu query quá lớn hoặc tiêu thụ quá nhiều bộ nhớ. Kích thước batch nên được chọn theo kích thước dữ liệu, giới hạn driver và hiệu năng thực tế.

3. Tổng kết

N+1 Query Problem xảy ra khi application lấy một tập bản ghi bằng một query, rồi thực hiện thêm query riêng cho từng bản ghi để truy xuất dữ liệu liên quan. Vấn đề làm số query tăng theo quy mô dữ liệu, tạo thêm network round trip và tải xử lý database.

Những cách khắc phục quan trọng nhất là:

  • Eager Loading: Chủ động tải association theo nhóm, ví dụ Preload() trong GORM.
  • Batch Query: Dùng IN (...) hoặc cách truy vấn theo nhóm để lấy dữ liệu cho nhiều bản ghi cùng lúc.
  • JOIN: Kết hợp bảng trong một truy vấn khi cấu trúc kết quả và quan hệ phù hợp.
  • Query tối thiểu: Chỉ lấy dữ liệu mà response thực sự cần.
  • Logging và EXPLAIN: Kiểm tra query thực tế và kế hoạch thực thi thay vì đoán.

 

Bài viết khác

Database Indexes

1. Database Index là gì? Database Index (chỉ mục cơ sở dữ liệu) là một cấu trúc dữ liệu được database duy trì để tìm kiếm và truy xuất bản ghi hiệu quả hơn, thay vì phải kiểm tra toàn bộ các dòng trong bảng ở nhiều trường hợp. Hãy tưởng tượng một bảng users […]

OpenAPI và Swagger là gì?

1. OpenAPI và Swagger là gì? Khi xây dựng Backend, lập trình viên không chỉ cần triển khai API mà còn phải mô tả cách API hoạt động để những người khác có thể sử dụng và kiểm thử. Ví dụ, một ứng dụng quản lý sản phẩm cung cấp các endpoint: GET /products: lấy […]

Series 4 – React Hooks – State: Hooks, useState và useEffect

Series 4 – React Hooks – State   Bài 1 : Hooks, useState và useEffect – Quản lý dữ liệu và side effects trong React 1. React Hooks là gì? Hooks là những hàm do React cung cấp để Function Component sử dụng các khả năng như quản lý state, đồng bộ với hệ thống bên […]

Series 4 – React Fundamentals: List Rendering, Forms, Form Handling

Series 4 – React Fundamentals   Bài 3: List Rendering, Forms và Form Handling – Hiển thị danh sách và xử lý biểu mẫu trong React   1. Mở đầu Trong một ứng dụng thực tế, dữ liệu thường được hiển thị dưới dạng danh sách: danh sách sản phẩm, bài viết, người dùng hoặc công việc […]

Series 4 – React Fundamentals:  State, Events và Conditional Rendering

Series 4 – React Fundamentals   Bài 2: State, Events và Conditional Rendering – Cách React xử lý tương tác người dùng 1. Mở đầu Một giao diện website không chỉ hiển thị nội dung cố định. Khi người dùng nhấn nút, nhập dữ liệu, đăng nhập hoặc thêm sản phẩm vào giỏ hàng, giao diện cần […]

Series 4 — React Fundamentals : JSX, Components và Props – Ba nền tảng đầu tiên khi học React

Series 4 – React Fundamentals   Bài 1:JSX, Components và Props – Ba nền tảng đầu tiên khi học React 1. Mở đầu Khi xây dựng một website bằng HTML, CSS và JavaScript, chúng ta thường tạo cấu trúc giao diện bằng HTML, sau đó sử dụng JavaScript để xử lý dữ liệu và tương tác. […]

Leave a Reply

Your email address will not be published. Required fields are marked *