Bạn cần mua 100 món ở một cửa hàng cách nhà nửa cây số. Cách một: đi 100 chuyến, mỗi chuyến mua đúng một món. Cách hai: đi một chuyến, mua cả 100 món. Bản thân việc nhặt một món lên khỏi kệ nhanh như nhau ở cả hai cách — cái giết bạn là 100 lần đi-về. Nếu cửa hàng ngay trong sân nhà (nửa bước chân), bạn gần như không thấy khác biệt; nhưng nếu nó cách nửa cây số, 100 chuyến là cả ngày trời. Truy vấn N+1 là đúng cái người đi 100 chuyến này — và bài này là lần tôi đo nó khi "cửa hàng ở ngay trong sân", thấy chẳng sao, rồi nhận ra production đặt cửa hàng cách nửa cây số.
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.
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. Đúng cửa hàng ở ngay trong sân. 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 trật 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. 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ề đến code hằng ngày
Điều đầ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 của Hibernate, includes của Rails, prefetch_related của Django, .Include() của EF Core) đúng để tránh N+1 — biết bật nó là kỹ năng bắt buộc.
Điều 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.
Và cái nguyên tắc "cái giết hiệu năng không phải công việc mỗi lần mà là số lần đi-về" này vượt xa ORM và CSDL — nó là cùng một bài toán của chatty interface ở mọi ranh giới có độ trễ. Một vòng lặp gọi REST API cho từng phần tử chậm thảm hại vì mỗi lần là một round-trip HTTP, cách chữa là một endpoint batch — y hệt gộp N+1 thành IN. Một microservice gọi service khác trong vòng lặp, một hàm đọc file bằng nghìn lần read() một byte, một client Redis gọi GET từng khóa thay vì MGET — tất cả cùng một mẫu, và cùng cách chữa: gộp N lần giao tiếp thành một. Mẫu số chung đáng mang theo: ở bất cứ ranh giới nào có độ trễ cố định mỗi lần vượt qua (mạng, đĩa, IPC), chi phí thật tỉ lệ với số lần vượt chứ không với công việc mỗi lần — nên đếm số round-trip, và gộp chúng lại; và cẩn thận vì đo ở môi trường không có độ trễ đó (localhost, cache nóng) sẽ giấu mất chính cái chi phí sẽ giết bạn ở production. Gói lại bằng số: N+1 chạy 1+N truy vấn (mỗi câu con <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.
Muốn tự thấy thì nếu ứng dụng của bạn có pg_stat_statements, 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.