DDevArchive
Đăng nhập

Tối ưu database: indexing và N+1 query

Đa số endpoint chậm không phải do code xấu, mà do database bị hỏi sai cách: thiếu index hoặc bắn N+1 truy vấn trong vòng lặp. Học đọc EXPLAIN và sửa đúng chỗ là kỹ năng phân biệt Junior và Mid.

Index là gì, và khi nào được dùng

Index là cấu trúc B-tree cho phép tìm nhanh theo cột. Với 10 triệu dòng, tìm không index là quét tuần tự; có index là xuống cây vài tầng. Nhưng index tốn bộ nhớ và làm ghi chậm hơn — chỉ tạo cho cột bạn thật sự lọc/sắp xếp.

// File: index.sql
-- Tạo index cho cột hay xuất hiện trong WHERE/JOIN/ORDER BY
CREATE INDEX idx_jobs_recruiter ON jobs (recruiter_id);
CREATE INDEX idx_jobs_created   ON jobs (created_at DESC);

-- Index ghép cho bộ lọc thường dùng cùng nhau
CREATE INDEX idx_jobs_role_status ON jobs (role, status);

-- Kiểm tra kế hoạch thực thi
EXPLAIN ANALYZE
SELECT * FROM jobs WHERE recruiter_id = 42 ORDER BY created_at DESC;
💡 Quy tắc tiền tố trái

Index ghép (a, b, c) phục vụ WHERE a, WHERE a AND b, WHERE a AND b AND c — nhưng KHÔNG phục vụ WHERE b hay WHERE c một mình. Đặt cột lọc đều nhất lên đầu.

N+1 query: sát thủ thầm lặng của ORM

// File: n1.js
// 1 truy vấn lấy jobs + N truy vấn lấy recruiter
const jobs = await db.job.findMany({ take: 100 });

for (const j of jobs) {
  j.recruiter = await db.user.findUnique({
    where: { id: j.recruiter_id },   // +100 truy vấn!
  });
}
// File: n2.js
// Một truy vấn, JOIN lấy sẵn recruiter
const jobs = await db.job.findMany({
  take: 100,
  include: { recruiter: { select: { id: true, name: true } } },
});

// Hoặc nạp theo lô 2 truy vấn
const ids = [...new Set(jobs.map((j) => j.recruiter_id))];
const users = await db.user.findMany({ where: { id: { in: ids } } });
const byId = new Map(users.map((u) => [u.id, u]));

Đọc EXPLAIN ANALYZE: rows và loops

// File: explain.txt
Nested Loop  (cost=0.43..8412.11 rows=1 width=64)
             (actual time=0.089..842.310 rows=4821 loops=1)
  ->  Seq Scan on jobs  (actual time=0.011..12.4 rows=4821 loops=1)
        Filter: (status = 'active')
  ->  Index Scan using users_pkey on users
        (actual time=0.171..0.171 rows=1 loops=4821)
        Index Cond: (id = jobs.recruiter_id)
Planning Time: 0.214 ms
Execution Time: 843.907 ms

Nhìn loops=4821: node tìm user chạy 4821 lần dù mỗi lần 0.17ms — tổng ~820ms. Dấu hiệu chắc chắn của N+1. Sửa bằng JOIN hoặc include như trên.

Thống kê cũ và ANALYZE

Rows ước lượng 1 nhưng thật 4821 → optimizer chọn sai kế hoạch vì thống kê cũ. Chạy ANALYZE jobs sau khi đổ dữ liệu lớn, và cân nhắc autovacuum đúng tần suất.

❓ Endpoint trả 100 jobs kèm tên recruiter, log DB ghi 101 truy vấn. Chẩn đoán đúng nhất?

  • Tạo index cho cột lọc/sắp xếp hay dùng
  • Nhận diện N+1 qua số truy vấn
  • Đọc rows ước lượng vs thật và loops
  • Chạy ANALYZE sau khi dữ liệu thay đổi lớn