
Ngừng Truy Vấn Trong Vòng Lặp: Trị Bệnh N+1 Bằng DataLoader
Khám phá cách lỗi truy vấn N+1 đang âm thầm giết chết hiệu năng ứng dụng của bạn và học cách giải quyết dứt điểm bằng kỹ thuật batching với DataLoader.
Khi xây dựng các ứng dụng backend, dù là REST API hay GraphQL endpoint, một trong những nút thắt cổ chai về hiệu năng phổ biến nhất chính là lỗi truy vấn N+1 (N+1 query problem). Nó thường bắt đầu một cách rất vô hại. Bạn viết một câu truy vấn để lấy danh sách các bản ghi, và sau đó, với mỗi bản ghi, bạn lại thực hiện thêm một truy vấn để lấy dữ liệu liên quan.
Chẳng mấy chốc, một API endpoint trước đây chỉ mất 50 milliseconds để phản hồi giờ đây mất tới 2 giây, và CPU của máy chủ database thì đang kêu gào thảm thiết. Hãy cùng tìm hiểu nguyên nhân tại sao điều này lại xảy ra và cách chúng ta có thể sử dụng một công cụ như DataLoader để giải quyết nó một cách triệt để.
Nhận Diện Cái Bẫy N+1
Hãy tưởng tượng bạn đang xây dựng một nền tảng blog. Bạn cần hiển thị danh sách 50 bài viết mới nhất, và bên cạnh mỗi bài viết, bạn muốn hiển thị tên của tác giả.
Đây là cách tiếp cận ngây ngô thường thấy trong code Node.js / TypeScript:
async function getArticlesWithAuthors() {
// 1 truy vấn để lấy danh sách bài viết
const articles = await db.query('SELECT * FROM articles ORDER BY created_at DESC LIMIT 50');
const result = [];
for (const article of articles) {
// N truy vấn để lấy thông tin tác giả! (N = 50)
const author = await db.query('SELECT * FROM users WHERE id = ?', [article.author_id]);
result.push({ ...article, author });
}
return result;
}
Bạn có nhận ra vấn đề không? Chúng ta thực thi 1 câu truy vấn để lấy bài viết, và sau đó là 50 câu truy vấn bổ sung bên trong vòng lặp để lấy từng tác giả. Đó chính là lý do nó được gọi là N+1.
Nếu database của bạn được host trên một máy chủ riêng biệt, mỗi câu truy vấn đều sẽ phải chịu độ trễ mạng (network latency). Nếu độ trễ chỉ là 5ms, 50 câu truy vấn sẽ cộng dồn thành 250ms thời gian chờ đợi thuần túy, chưa kể đến thời gian thực thi truy vấn thực tế. Khi lượng người dùng tăng lên, cách tiếp cận này sớm muộn gì cũng sẽ đánh sập hệ thống của bạn.
Giải Pháp: Gộp Truy Vấn (Batching) Với DataLoader
Để giải quyết vấn đề này, chúng ta cần thay đổi tư duy từ việc "lấy từng cái một" sang "lấy theo lô" (batch). Thay vì hỏi database 50 lần để lấy 50 tác giả, chúng ta chỉ nên hỏi một lần duy nhất: "Hãy cho tôi thông tin các tác giả có các ID này."
Bạn có thể thu thập các ID và tự viết một câu truy vấn dạng WHERE id IN (...), nhưng làm như vậy trên một codebase lớn và phức tạp (đặc biệt là trong các GraphQL resolver) sẽ rất nhanh chóng trở nên rườm rà. Đây là lúc DataLoader tỏa sáng.
DataLoader là một tiện ích được Facebook tạo ra cho Node.js. Nó cung cấp một API nhất quán để gộp (batch) và bộ nhớ đệm (cache) các yêu cầu tới database.
Triển Khai DataLoader Trong Thực Tế
Hãy xem cách chúng ta có thể refactor đoạn code trên sử dụng DataLoader.
Đầu tiên, bạn định nghĩa một "loader". Loader về cơ bản là một hàm batch. Nó nhận vào một mảng các keys (ví dụ: mảng author ID) và trả về một Promise chứa mảng các values (ví dụ: các object tác giả) có độ dài và thứ tự tương ứng.
import DataLoader from 'dataloader';
// Hàm batch
async function batchGetUsersByIds(userIds: readonly number[]) {
// 1 truy vấn duy nhất cho TẤT CẢ các ID được yêu cầu
const users = await db.query(
'SELECT * FROM users WHERE id IN (?)',
[userIds]
);
// Ánh xạ (map) kết quả về lại các ID đã yêu cầu để đảm bảo đúng thứ tự
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
}
// Khởi tạo DataLoader
// Lưu ý: Bạn NÊN tạo một instance DataLoader mới cho MỖI request
// để tránh việc cache dữ liệu chéo giữa các user khác nhau!
const userLoader = new DataLoader(batchGetUsersByIds);
Bây giờ, hãy viết lại hàm ban đầu của chúng ta để dùng loader này:
async function getArticlesWithAuthors() {
// 1 truy vấn để lấy bài viết
const articles = await db.query('SELECT * FROM articles ORDER BY created_at DESC LIMIT 50');
// Dùng Promise.all, nhưng thay vì gọi trực tiếp DB, ta gọi qua loader
const result = await Promise.all(
articles.map(async (article) => {
const author = await userLoader.load(article.author_id);
return { ...article, author };
})
);
return result;
}
Phép Thuật Của DataLoader Nằm Ở Đâu?
Khi bạn gọi userLoader.load(id), DataLoader không ngay lập tức chạy truy vấn database. Thay vào đó, nó đợi cho đến khi "tick" hiện tại của event loop kết thúc, gom tất cả các ID đã được yêu cầu thông qua .load(), và sau đó gọi hàm batch của bạn (batchGetUsersByIds) đúng MỘT lần với mảng các ID đã gom.
Trong ví dụ trên, mặc dù chúng ta gọi userLoader.load() 50 lần trong một vòng lặp, DataLoader sẽ gộp tất cả 50 lần gọi đó thành một truy vấn database duy nhất. Chúng ta đã thu gọn thành công bài toán N+1 về đúng 2 truy vấn: một cho bài viết, và một cho tác giả.
Điểm Cộng Tuyệt Vời: Per-Request Caching
Bên cạnh batching, DataLoader còn hỗ trợ caching. Nếu nhiều bài viết trong danh sách của chúng ta có cùng một author_id, userLoader.load(id) sẽ trả về promise đã được cache cho các lần gọi tiếp theo với cùng ID đó trong cùng một event loop. Hàm batch sẽ chỉ nhận được một danh sách các ID duy nhất (unique).
Tính năng này cực kỳ mạnh mẽ trong các kiến trúc phức tạp như GraphQL, nơi nhiều resolver độc lập có thể vô tình yêu cầu cùng một entity ở sâu bên trong cây dữ liệu.
REST và GraphQL: Nơi N+1 Hoành Hành
Mặc dù vấn đề N+1 vẫn luôn tồn tại trong các kiến trúc REST tiêu chuẩn, nó thực sự trở thành một thảm họa trong môi trường GraphQL. Bởi vì GraphQL cho phép client yêu cầu các cấu trúc dữ liệu quan hệ lồng nhau rất sâu, máy chủ sẽ phải giải quyết (resolve) các trường này một cách độc lập. Nếu không có một lớp batching, một truy vấn yêu cầu posts { comments { author { name } } } sẽ dễ dàng kích hoạt hàng trăm truy vấn database nối tiếp nhau.
DataLoader hoạt động như một middleware hoàn hảo cho tình huống này, nó tổng hợp các yêu cầu dữ liệu riêng lẻ của toàn bộ cây truy vấn GraphQL thành một vài truy vấn cơ sở dữ liệu được tối ưu hóa. Nó tách biệt logic lấy dữ liệu ra khỏi cấu trúc phân cấp của resolver, giúp code của bạn luôn gọn gàng và dễ dàng bảo trì.
Khi Nào Nên Sử Dụng?
Bạn nên cân nhắc sử dụng DataLoader (hoặc một pattern batching tương tự) bất cứ khi nào bạn có các quan hệ 1-N (has-many) hoặc N-1 (belongs-to) và cần tải dữ liệu liên quan cho một danh sách các bản ghi.
Tuy nhiên, hãy luôn nhớ quy tắc vàng của DataLoader: Tạo một instance mới cho mỗi HTTP request. Nếu bạn tạo một instance DataLoader ở scope global cho toàn bộ ứng dụng Node.js, bạn sẽ vô tình cache dữ liệu chung giữa nhiều user, dẫn đến rò rỉ dữ liệu nghiêm trọng (User A có thể nhìn thấy dữ liệu bảo mật của User B).
Lời Kết
Bài toán truy vấn N+1 là một sát thủ thầm lặng len lỏi vào hầu hết các ứng dụng lấy dữ liệu làm trung tâm. Bằng cách thay đổi tư duy sang việc truy vấn gộp và tận dụng các công cụ như DataLoader, bạn có thể giảm tải đáng kể cho database và giữ cho tốc độ phản hồi của API luôn ở mức xuất sắc.
Hãy ngừng truy vấn trong vòng lặp, bắt đầu sử dụng batching, và database của bạn sẽ thầm cảm ơn bạn.
viết bởi
Nguyên Tech
Phản hồi
Đang tải bình luận…