# 🔁 Truy vấn N+1: nhận diện và sửa

By Better Wise

Bài trước bạn đã mở EXPLAIN và đọc được một truy vấn có đi qua index hay không. Nhưng có một kiểu chậm mà EXPLAIN từng câu đều "đẹp": mỗi truy vấn riêng lẻ đều nhanh, chỉ có điều có quá nhiều truy vấn. Đó là N+1, và đây là lỗi hiệu năng phổ biến nhất trong code do AI sinh ra.

Sau bài này, bạn sẽ:

- Nhận ra một vòng lặp gọi database trong code AI viết.
- Đếm được một trang sinh ra bao nhiêu truy vấn.
- Sửa bằng JOIN hoặc truy vấn gộp `IN`.
- Biết trường hợp nào JOIN làm sai kết quả.
- Đo lại sau khi sửa, thay vì tin rằng đã nhanh.

## N+1 trông như thế nào trong code thật

Một trang "20 đơn hàng gần đây", AI viết ra như sau:

```js
const orders = await db.query(
  "SELECT id, customer_id FROM orders ORDER BY created_at DESC LIMIT 20"
);

for (const o of orders) {
  const c = await db.query("SELECT name FROM customers WHERE id = $1", [o.customer_id]);
  o.customer_name = c[0].name;
}
```

Đếm cho kỹ: 1 truy vấn lấy danh sách, rồi 20 truy vấn lấy tên khách. Tổng **21 truy vấn** cho một trang. Nếu trang hiển thị 200 đơn thì thành 201 truy vấn. Đây chính là công thức: 1 truy vấn cha cộng N truy vấn con, gọi tắt là N+1.

Query log sẽ cho bạn thấy rõ điều này:

```text
SELECT id, customer_id FROM orders ... LIMIT 20        (1 lần)
SELECT name FROM customers WHERE id = $1               (20 lần)
```

AI viết kiểu này vì nó kể chuyện rất tự nhiên: lấy danh sách trước, rồi với mỗi dòng lấy thông tin liên quan. Nhiều ORM cũng khuyến khích đúng thói quen đó qua lazy loading: bạn chạm vào `order.customer` ở đâu, ORM tự bắn một truy vấn ở đó.

## Đếm truy vấn trước khi sửa

Nguyên tắc cũ vẫn đúng: đo trước khi đoán. Ba bước, làm mất khoảng hai phút:

1. Bật query log ở môi trường dev (`log_statement = 'all'` với Postgres, general log với MySQL).
2. Mở trang một lần, đếm số dòng log.
3. Tìm trong code những lời gọi query nằm trong `for`, `map`, `forEach`.

Dấu hiệu nhận biết nhanh: trang 20 dòng chạy ổn, trang 200 dòng chậm hẳn. Thời gian tăng gần tuyến tính theo số dòng, đó là chữ ký của N+1. Và đúng như bài trước, EXPLAIN của từng truy vấn con có thể vẫn đẹp, vì vấn đề không nằm ở một truy vấn mà ở số lượng.

## Sửa bằng JOIN khi quan hệ là many-to-one

Ở ví dụ trên, mỗi đơn thuộc về đúng một khách. Vậy gộp lại:

```sql
SELECT o.id, o.created_at, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC
LIMIT 20;
```

Còn 1 truy vấn. `LIMIT` ở đây an toàn vì mỗi đơn chỉ ra đúng một dòng khách, JOIN không nhân dòng. Cột `c.id` là primary key nên đã có index sẵn, không cần thêm gì.

## Sửa bằng truy vấn gộp IN

Khi bạn đã có danh sách trong tay và không muốn viết JOIN, hoặc khi ORM xử lý quan hệ lộn xộn, dùng cách gộp hai bước:

```js
const orders = await db.query(
  "SELECT id, customer_id FROM orders ORDER BY created_at DESC LIMIT 20"
);

const ids = [...new Set(orders.map(o => o.customer_id))];

const customers = await db.query(
  "SELECT id, name FROM customers WHERE id = ANY($1)", [ids]
);
```

Tổng cộng **2 truy vấn**, không phụ thuộc số dòng. Chú ý `new Set`: 20 đơn có thể chỉ của 5 khách, danh sách id ngắn hơn thì truy vấn cũng nhẹ hơn.

## Khi quan hệ là one-to-many, JOIN sẽ nhân dòng

Đây là chỗ người mới hay sửa sai. Ví dụ mỗi đơn có nhiều `order_items`. Nếu bạn JOIN rồi `LIMIT 20`, database nhân 20 đơn thành 80 dòng, sau đó `LIMIT 20` cắt còn 20 dòng, nghĩa là bạn mất dữ liệu của các đơn phía sau. Trang vẫn chạy, chỉ là thiếu hàng, kiểu lỗi khó thấy hơn nhiều so với chậm.

Cách an toàn: lấy trang cha trước, rồi lấy con theo lô id.

```sql
-- bước 1: trang 20 đơn, giữ nguyên LIMIT
SELECT id FROM orders ORDER BY created_at DESC LIMIT 20;

-- bước 2: một truy vấn cho toàn bộ item của 20 đơn đó
SELECT order_id, product_name, quantity
FROM order_items
WHERE order_id = ANY($1);
```

Hai truy vấn, không nhân dòng, không mất dữ liệu. Nếu bạn thích một truy vấn duy nhất, dùng JOIN kèm gom nhóm (`json_agg` với Postgres) thay vì `LIMIT` trần.

```mermaid
flowchart TD
    Start["🔁<br/>Thấy query trong vòng lặp"] --> Q1{"Quan hệ?"}
    Q1 -->|"many-to-one"| Join["JOIN một truy vấn"]
    Q1 -->|"one-to-many"| Batch["Lấy cha trước,<br/>con theo lô id"]
    Join --> Measure["Đếm lại số truy vấn"]
    Batch --> Measure
```

Điền nhanh để tự kiểm tra:

Với N dòng, một trang N+1 sinh ra _____ truy vấn.
Dấu hiệu rõ nhất của N+1 là lời gọi database nằm trong _____.
Với quan hệ many-to-one, cách gọn nhất là _____ hai bảng trong một truy vấn.
Cách sửa không dùng JOIN là lấy danh sách id rồi truy vấn gộp bằng _____.
Khi quan hệ là one-to-many, JOIN sẽ _____ và LIMIT có thể cắt mất dữ liệu.
Trước khi sửa, hãy _____ số truy vấn bằng query log.

## Thẻ ôn tập

Front: N+1 là gì?
Back: 1 truy vấn lấy danh sách N dòng, rồi N truy vấn lấy dữ liệu liên quan cho từng dòng.

Front: Một trang N+1 với N dòng sinh ra bao nhiêu truy vấn?
Back: N + 1 truy vấn, chưa tính các truy vấn lồng sâu hơn.

Front: Dấu hiệu N+1 trong code là gì?
Back: Một lời gọi database nằm bên trong for, map hoặc forEach.

Front: Vì sao EXPLAIN từng truy vấn vẫn đẹp mà trang vẫn chậm?
Back: Vấn đề nằm ở số lượng truy vấn, không phải ở tốc độ một truy vấn.

Front: Trang 20 dòng nhanh, 200 dòng chậm hẳn, đó là dấu hiệu gì?
Back: Số truy vấn tăng theo số dòng, thời gian tăng gần tuyến tính.

Front: Sửa N+1 cho quan hệ many-to-one bằng cách nào?
Back: JOIN hai bảng trong một truy vấn duy nhất.

Front: Sửa N+1 mà không dùng JOIN thì làm sao?
Back: Thu thập id rồi truy vấn gộp bằng IN hoặc ANY, tổng cộng hai truy vấn.

Front: Vì sao nên khử trùng id trước khi dùng IN?
Back: Nhiều dòng có thể trỏ tới cùng một bản ghi cha, danh sách id sẽ ngắn hơn.

Front: Khi nào JOIN làm sai kết quả?
Back: Khi quan hệ là one-to-many: JOIN nhân dòng và LIMIT cắt mất dữ liệu.

Front: Cách xử lý one-to-many an toàn?
Back: Lấy trang cha trước, rồi truy vấn con theo lô id; hoặc JOIN kèm gom nhóm.

Front: Lazy loading của ORM liên quan gì tới N+1?
Back: Nó tự bắn một truy vấn cho mỗi dòng khi bạn truy cập quan hệ, đúng khuôn N+1.

Front: Cột dùng để JOIN hoặc IN nên có gì?
Back: Index, nếu không mỗi truy vấn con lại phải quét toàn bảng.

Front: Sau khi sửa N+1 cần làm gì?
Back: Đếm lại số truy vấn và đo thời gian, đừng đoán.

## Ba cái bẫy khi sửa N+1

- **JOIN rồi LIMIT trên quan hệ one-to-many.** Dữ liệu bị cắt âm thầm, trang không báo lỗi. Nếu thấy số dòng trả về khác số dòng bạn yêu cầu, dừng lại kiểm tra ngay.
- **IN với danh sách quá lớn.** Vài nghìn id trong một câu `IN` làm câu lệnh phình to và khó dùng index. Chia thành lô vài trăm id một lần.
- **Quên index trên cột nối.** Với Postgres, khoá ngoại không tự sinh index trên cột tham chiếu (khác với InnoDB của MySQL). Không có index thì truy vấn gộp vẫn quét bảng, và bạn vừa sửa N+1 lại vừa giữ nguyên cái chậm cũ.
- **Sửa xong không đo lại.** N+1 rất dễ quay lại khi AI thêm một quan hệ mới vào cùng vòng lặp.

## Chốt lại

- N+1 là 1 truy vấn cha cộng N truy vấn con, và nó không hiện ra trong EXPLAIN của từng câu.
- Nhận diện bằng cách đếm truy vấn trong query log và tìm query nằm trong vòng lặp.
- many-to-one thì JOIN, one-to-many thì lấy cha trước rồi lấy con theo lô id, cả hai đều đưa về 1 hoặc 2 truy vấn cố định.
- Sửa xong phải đếm lại và đo lại.

Bài tiếp theo là checkpoint của module: bạn sẽ nhìn một đoạn code và một EXPLAIN do AI sinh ra, rồi tự kết luận nó nhanh hay chậm, có dùng index hay không, và có mắc N+1 hay không.

---

## Contents

Part of [🗄️ Database cho vibe coder: index, N+1 và backup](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup.md)

- **⚡ Đọc hiệu năng truy vấn AI viết**
  - [🔍 Index là gì và khi nào nên thêm](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/index-la-gi-va-khi-nao-nen-them.md)
  - [🧭 Đọc EXPLAIN cơ bản để biết truy vấn có dùng index](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/doc-explain-co-ban-truy-van-dung-index.md) (sign-in required)
  - [🔁 Truy vấn N+1: nhận diện và sửa](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/truy-van-n-plus-1-nhan-dien-va-sua.md) — this lesson
  - [✅ Checkpoint: hiệu năng truy vấn](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/checkpoint-hieu-nang-truy-van.md)
- **🔐 Ghi dữ liệu an toàn với transaction và migration**
  - [🧱 Transaction: khi nào cần và AI hay bỏ sót gì](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/transaction-khi-nao-can-va-ai-hay-bo-sot.md)
  - [🛠️ Migration an toàn khi AI sinh schema](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/migration-an-toan-khi-ai-sinh-schema.md) (premium content)
  - [✅ Checkpoint: ghi dữ liệu an toàn](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/checkpoint-ghi-du-lieu-an-toan.md) (premium content)
- **🧰 Backup, khôi phục và chọn SQL hay NoSQL**
  - [💾 Backup và khôi phục: kiểm tra bản backup thật sự](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/backup-va-khoi-phuc-kiem-tra-ban-backup.md)
  - [⚖️ SQL vs NoSQL: chọn công cụ theo nhu cầu](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/sql-vs-nosql-chon-cong-cu-theo-nhu-cau.md)
  - [✅ Checkpoint: backup và chọn công cụ](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/checkpoint-backup-va-chon-cong-cu.md)
- [Course cheatsheet](https://everwise.study/courses/database-cho-vibe-coder-index-n-1-va-backup/cheatsheet.md) (premium content)
