Các bài trước đều đo một truy vấn chạy nhanh cỡ nào. Nhưng một trong những vấn đề hiệu năng phổ biến nhất trong ứng dụng thật không nằm ở một truy vấn chậm — mà ở quá nhiều truy vấn nhanh. Đó là N+1, cái bẫy kinh điển mà mọi ORM đều dễ dẫn bạn vào. Bài này đo nó, và vấp đúng lý do vì sao đo N+1 trên máy cục bộ lại giấu mất mức độ nghiêm trọng thật của nó.
N+1: một truy vấn cha, N truy vấn con
Mẫu N+1 xuất hiện khi bạn lấy một danh sách rồi cần dữ liệu liên quan của từng phần tử. Ví dụ: lấy 100 bài viết (một truy vấn), rồi hiển thị số bình luận của mỗi bài. Cách "tự nhiên" mà ORM hay sinh ra là: với mỗi bài, chạy thêm một truy vấn đếm bình luận của bài đó. Tổng cộng: 1 + N = 101 truy vấn thay vì một.
Điều làm N+1 nguy hiểm là mỗi truy vấn con đều nhanh. Đếm bình luận theo bai_id có index chỉ tốn dưới một mili giây. Nhìn từng truy vấn trong log, mọi thứ trông lành mạnh — không có truy vấn nào "chậm" để mà nghi ngờ. Vấn đề chỉ lộ ra khi bạn cộng tất cả lại, và — như tôi sắp kể — mức độ nghiêm trọng còn phụ thuộc một biến mà đo cục bộ giấu mất.
Đo: 101 truy vấn so với 1 JOIN
Tôi tạo 100 bài viết và 50.000 bình luận (index trên bai_id), rồi so hai cách lấy "số bình luận mỗi bài":
| Cách lấy dữ liệu | Số truy vấn | Thời gian (server) |
|---|---|---|
| N+1 (1 cha + 100 con) | 101 | 18,2 ms |
| Một JOIN duy nhất | 1 | 5,9 ms |
N+1 chạy 101 truy vấn, tổng thời gian server 18,2 ms; một truy vấn JOIN gộp tất cả mất 5,9 ms. Và pg_stat_statements (bảng thống kê từng câu lệnh của PostgreSQL) phơi bày dấu hiệu rõ ràng: câu SELECT count(*) FROM binh_luan WHERE bai_id = $1 có calls = 100 — một câu lệnh được gọi đúng 100 lần, đúng khuôn mặt của N+1. Còn câu JOIN: calls = 1. Đây là điểm hay của pg_stat_statements: nó không nhìn từng lần chạy riêng lẻ mà gộp các câu cùng khuôn lại và đếm, nên một mẫu N+1 nổi bật ngay dưới dạng "một câu, gọi trăm lần", điều mà đọc log từng dòng khó thấy.
Nhưng nhìn hai con số 18,2 ms và 5,9 ms — chỉ chậm khoảng 3 lần — tôi suýt kết luận sai.
Một lần tôi đo hớ: round-trip bị giấu
Thấy N+1 chỉ chậm 3 lần một JOIN, và mỗi truy vấn con dưới 0,2 ms, tôi định chốt: "N+1 cũng đâu đến nỗi, chậm ba lần thôi mà". Con số đo được ủng hộ kết luận đó.
Nhưng theo kỷ luật của sê-ri, một con số "nhẹ nhàng hơn kỳ vọng" cũng đáng nghi — và tôi tìm ra biến ẩn: tôi đo qua unix socket cục bộ, nơi cơ sở dữ liệu nằm ngay trong cùng container, nên round-trip gần như bằng không. Con số 18,2 ms chỉ là thời gian server xử lý 101 truy vấn; nó không tính chi phí thật sự giết chết N+1: round-trip mạng.
Trong một ứng dụng thật, cơ sở dữ liệu thường nằm trên một máy chủ khác, cách ứng dụng qua mạng. Mỗi truy vấn là một chuyến khứ hồi: gửi câu lệnh đi, chờ, nhận kết quả về — mất ít nhất khoảng nửa tới một mili giây độ trễ mạng, bất kể truy vấn nhanh cỡ nào. Với N+1, bạn trả cái độ trễ đó N lần một cách tuần tự (query này xong mới gửi query kia): 100 truy vấn con = 100 round-trip = 100 × ~1 ms = 100 ms chỉ riêng chờ mạng, lấn át hoàn toàn phần server 18 ms. Còn một JOIN = một round-trip duy nhất, độ trễ mạng trả đúng một lần bất kể lấy về bao nhiêu bản ghi. Nên trên hệ thống thật, N+1 không chậm 3 lần mà 10 tới 100 lần một JOIN — và càng chậm khi độ trễ mạng càng cao (ví dụ cơ sở dữ liệu ở vùng khác).
Cái tôi đo hớ là để môi trường đo (kết nối cục bộ) giấu mất cái giá thật (round-trip mạng). Đây đúng là bài học lặp lại từ sê-ri hệ điều hành và bài WAL: con số bạn đo chỉ đúng cho môi trường đo được nó, và một phép đo N+1 trên socket cục bộ không nói lên gì về N+1 qua mạng. Số truy vấn (round-trip) mới là đại lượng quyết định độ đau của N+1, không phải thời gian server của từng câu. Bài học đo lường: khi đo thứ liên quan tới nhiều lần giao tiếp, phải đếm số round-trip và biết độ trễ mỗi round-trip trong môi trường thật — nếu không, con số cục bộ đẹp đẽ sẽ ru ngủ bạn trước một vấn đề nghiêm trọng ở production.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: N+1 là thủ phạm hiệu năng số một trong ứng dụng dùng ORM, và nó ẩn mình giỏi vì mỗi truy vấn con đều nhanh. Một trang "danh sách 50 đơn hàng, mỗi đơn hiện tên khách" dễ âm thầm bắn 51 truy vấn. Cách chữa là gộp: dùng một JOIN để lấy tất cả trong một truy vấn, hoặc lấy danh sách id rồi một truy vấn WHERE id IN (...). Hầu hết ORM có cơ chế "eager loading" (JOIN FETCH, includes, prefetch_related, .Include()) đúng để tránh N+1 — biết bật nó là kỹ năng bắt buộc.
Hệ quả thứ hai: pg_stat_statements là công cụ săn N+1 tốt nhất. Nó gộp các truy vấn giống nhau (thay hằng số bằng $1) và đếm calls. Một câu lệnh có calls cao bất thường (hàng trăm, hàng nghìn cho một lần tải trang) gần như chắc chắn là N+1. Trong khi tối ưu, đừng chỉ tìm truy vấn chậm nhất (total_time cao vì mỗi lần đắt); hãy tìm cả truy vấn gọi nhiều nhất (calls cao) — tổng chi phí của một câu nhanh gọi nghìn lần có thể lớn hơn một câu chậm gọi một lần.
Hệ quả thứ ba là bài học đo lường. Con số mang theo: N+1 chạy 1+N truy vấn (mỗi câu con nhanh <0,2ms, tổng server 18ms so với 6ms của một JOIN); nhưng cái giá thật là N round-trip mạng — đo cục bộ giấu nó (chỉ thấy 3x), qua mạng N+1 chậm 10-100 lần. Với vấn đề nhiều lần giao tiếp, hãy đếm số round-trip, đừng chỉ nhìn thời gian server; và pg_stat_statements với calls = N là dấu hiệu để nhận ra.
Thử ba mươi giây
Nếu ứng dụng của bạn có pg_stat_statements (một extension), chạy SELECT calls, mean_exec_time, query FROM pg_stat_statements ORDER BY calls DESC LIMIT 10; — nhìn cột calls. Một truy vấn SELECT ... WHERE id = $1 với calls hàng nghìn trong khi trang chỉ tải vài lần là N+1 kinh điển: cùng một câu bắn đi bắn lại cho từng bản ghi. Đối chiếu với log ứng dụng: nếu một request sinh ra hàng chục truy vấn gần giống nhau chỉ khác id, đó là N+1 cần gộp thành một JOIN hoặc IN. Và nhớ: đừng chỉ tin con số thời gian đo trên máy dev (kết nối cục bộ) — trên production với cơ sở dữ liệu qua mạng, N+1 đắt hơn nhiều vì mỗi truy vấn là một round-trip.