Skip to content

Indexing, EXPLAIN ANALYZE và N+1 Problem

"Query này chạy 3 giây trên staging, nhưng production có 50 triệu dòng thì mất 45 giây!" — Mọi backend developer đều sẽ gặp cơn ác mộng này ít nhất một lần.

Bạn có thể viết SQL đúng logic, trả về đúng kết quả, nhưng nếu không hiểu cách database thực thi query — bạn đang lái xe mà không nhìn đồng hồ tốc độ. Production sẽ là nơi bạn nhận hóa đơn.

Bài này trang bị BA vũ khí tối ưu bắt buộc:

  1. Index — Phòng bệnh (ngăn query chậm từ đầu)
  2. EXPLAIN ANALYZE — Chẩn đoán (xem database đang làm gì)
  3. N+1 Awareness — Phát hiện (nhận diện kẻ giết thầm lặng)

🎯 Mục tiêu

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

  • Vẽ được mental model của B-Tree và giải thích tại sao index nhanh
  • Đọc hiểu output của EXPLAIN ANALYZE — biết Seq Scan vs Index Scan
  • Áp dụng Leftmost Prefix Rule cho composite index
  • Nhận diện và fix N+1 problem trong cả raw SQL lẫn ORM
  • Biết khi nào không nên đánh index

PART 1: Indexing — B-Tree Mental Model 🌳

Index là gì? — Mục lục cuốn sách

Tưởng tượng bạn có cuốn sách 1000 trang. Cần tìm "Nguyễn Văn A":

  • Không có mục lục → Lật từng trang, 1 → 1000. Chậm kinh hoàng 😩
  • Có mục lục → Tra "Nguyễn Văn A" → trang 582. Lật thẳng tới 🚀

Index trong database hoạt động y hệt vậy — một cấu trúc dữ liệu riêng, sắp xếp sẵn, giúp database nhảy thẳng tới dòng cần tìm thay vì quét toàn bộ bảng.

sql
-- Không có index → Quét TOÀN BỘ 10 triệu dòng để tìm 1 dòng!
SELECT * FROM users WHERE email = 'tom@example.com';

-- Tạo index:
CREATE INDEX idx_users_email ON users(email);
-- Giờ chỉ cần 3-4 bước nhảy cho 10 triệu dòng!

B-Tree — The King of Indexes

B-Tree (Balanced Tree) là loại index mặc định của hầu hết database. Cấu trúc:

                         ┌──────────┐
                         │  [50]    │           ← Root
                         └────┬─────┘
                    ┌─────────┴─────────┐
               ┌────┴────┐        ┌────┴────┐
               │ [20,35] │        │ [70,85] │  ← Internal
               └──┬──┬───┘        └──┬──┬───┘
              ┌───┘  │  └───┐   ┌───┘  │  └───┐
           ┌──┴─┐ ┌──┴─┐ ┌─┴──┐┌──┴─┐┌──┴─┐┌──┴─┐
           │5,10│ │25,30│ │40,45││55,60││75,80││90,95│  ← Leaf
           └────┘ └────┘ └────┘└────┘└────┘└────┘
                                                   
    Tìm id = 75: Root(50) → phải → Internal(70,85) → giữa → Leaf(75,80) ✅
    Chỉ 3 bước nhảy cho 1 triệu dòng! O(log n)

Mỗi bước nhảy loại bỏ ~một nửa dữ liệu — giống trò đoán số, cắt đôi phạm vi mỗi lần:

Số dòngBước nhảy tối đaSo sánh Seq Scan
1,000~10Thay vì quét 1,000 dòng
1,000,000~20Thay vì quét 1 triệu dòng
1,000,000,000~30Thay vì quét 1 tỷ dòng!

B-Tree hỗ trợ: =, >, <, >=, <=, BETWEEN, LIKE 'prefix%'

B-Tree KHÔNG hỗ trợ: LIKE '%suffix', LIKE '%keyword%'

Composite Index & Leftmost Prefix Rule

Đây là kiến thức phân biệt seniorfresher:

sql
CREATE INDEX idx_region_status_date ON orders(region, status, created_at);

Index (region, status, created_at) chỉ hoạt động khi query dùng cột từ trái sang phải, không bỏ cách:

sql
WHERE region = 'HCM'                              → Dùng Index
WHERE region = 'HCM' AND status = 'active'        → Dùng Index
WHERE region = 'HCM' AND status = 'active' 
     AND created_at > '2024-01-01'                    → Dùng Index (tối ưu nhất!)
WHERE status = 'active'                            → KHÔNG dùng Index!
WHERE created_at > '2024-01-01'                    → KHÔNG dùng Index!
⚠️ WHERE region = 'HCM' AND created_at > '2024-01-01' → Chỉ dùng phần "region"

💡 Mẹo nhớ: Danh bạ điện thoại

Danh bạ sắp xếp theo (Họ, Tên). Bạn có thể:

  • ✅ Tìm theo Họ: "Nguyễn" → lật tới phần Nguyễn
  • ✅ Tìm theo Họ + Tên: "Nguyễn Văn A" → chính xác
  • ❌ Tìm theo Tên đơn lẻ: "Văn A" → phải lật toàn bộ danh bạ!

Composite index y hệt — cột trái là "Họ", phải có nó trước.

Thứ tự cột tối ưu: Equality (=) đặt trước, Range (>, <, BETWEEN) đặt sau, ORDER BY đặt cuối.

Khi nào Index KHÔNG hoạt động 💀

1. Function trên cột index — "Index Killer" số 1:

sql
-- ❌ YEAR() vô hiệu hóa index trên created_at
WHERE YEAR(created_at) = 2024    -- Seq Scan! Phải tính YEAR() cho MỖI dòng

-- ✅ Viết lại dùng range
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'  -- Index Scan! 🚀

2. Implicit type cast:

sql
-- ❌ Cột phone là VARCHAR, so sánh với số → cast mỗi dòng
WHERE phone = 0912345678

-- ✅ So sánh đúng kiểu
WHERE phone = '0912345678'

3. LIKE '%keyword%' → Luôn full table scan. Dùng Full-Text Search thay thế.

4. OR across different columns:

sql
-- ⚠️ Optimizer khó dùng index
WHERE user_id = 42 OR product_id = 100

-- ✅ Viết lại bằng UNION
SELECT * FROM orders WHERE user_id = 42
UNION
SELECT * FROM orders WHERE product_id = 100

PART 2: EXPLAIN ANALYZE — Bác sĩ khám bệnh cho Query 🩺

EXPLAIN vs EXPLAIN ANALYZE

LệnhHành viKhi nào dùng
EXPLAINHiện kế hoạch dự kiến (không chạy query)An toàn, dùng mọi lúc
EXPLAIN ANALYZEChạy query thật và đo thời gianCần số liệu chính xác

🚨 EXPLAIN ANALYZE + DELETE/UPDATE = Nguy hiểm!

EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'old' sẽ XÓA THẬT dữ liệu! Luôn wrap trong transaction:

sql
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'old';
ROLLBACK;  -- Hoàn tác ngay!

Cách đọc Execution Plan

Tìm những "tín hiệu" sau trong EXPLAIN output:

Seq Scan    → 🔴 BAD (full table scan — quét toàn bộ!)
Index Scan  → 🟢 GOOD (dùng index, lấy data từ table)
Bitmap Scan → 🟡 OK (hybrid approach)
Sort        → ⚠️ Check: có thể tránh bằng index?
Hash Join   → Phổ biến cho JOINs
Nested Loop → Fine cho small tables, bad cho large

Ví dụ thực tế: Trước vs Sau khi có Index

🔴 TRƯỚC — Không index (bảng 1 triệu dòng):

sql
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42;

-- Seq Scan on orders  (cost=0.00..25000.00 rows=50 width=120)
--   Filter: (user_id = 42)
--   Rows Removed by Filter: 999950
--   Planning Time: 0.1 ms
--   Execution Time: 350 ms     ← 😱 Quét gần 1 triệu dòng!

🟢 SAU — Có index:

sql
CREATE INDEX idx_orders_user_id ON orders(user_id);

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42;

-- Index Scan using idx_orders_user_id on orders  (cost=0.42..15.50 rows=50 width=120)
--   Index Cond: (user_id = 42)
--   Planning Time: 0.1 ms
--   Execution Time: 0.5 ms     ← 🚀 700x faster!
TRƯỚC (Seq Scan):  ████████████████████████████████████████ 350 ms
SAU (Index Scan):  █ 0.5 ms                   Tăng tốc ~700 lần!

💡 Index Only Scan — "Thánh grail" của tối ưu

Nếu tất cả cột trong SELECT đều nằm trong index, database không cần đọc table — chỉ đọc index:

sql
CREATE INDEX idx_covering ON orders(region, status, created_at DESC) 
  INCLUDE (order_id, total);
-- → Index Only Scan: bỏ qua bước "heap fetch", nhanh nhất có thể!

PART 3: N+1 Problem — Kẻ giết thầm lặng 🔪

N+1 là performance killer phổ biến nhất trong web applications. Không gây lỗi, không throw exception — chỉ âm thầm biến app thành rùa bò.

N+1 là gì?

Scenario: Hiển thị 100 đơn hàng kèm tên khách hàng.

sql
🚫 N+1 Pattern (101 queries!):
Query 1:   SELECT * FROM orders LIMIT 100;            -- 1 query
Query 2:   SELECT name FROM users WHERE id = 1;       -- +1 
Query 3:   SELECT name FROM users WHERE id = 2;       -- +1
...
Query 101: SELECT name FROM users WHERE id = 100;     -- +1
Total: 1 + 100 = 101 queries! 😱

✅ Fixed với IN clause (2 queries):
Query 1: SELECT * FROM orders LIMIT 100;
Query 2: SELECT * FROM users WHERE id IN (1, 2, ..., 100);
Total: 2 queries ✅

✅ Even better — JOIN (1 query):
SELECT o.*, u.name 
FROM orders o JOIN users u ON o.user_id = u.id 
LIMIT 100;
Total: 1 query 🚀

Mỗi round-trip tới database mất ~1-5ms network latency. 101 queries = 100-500ms chỉ riêng network, chưa tính query time!

N+1 trong ORM

ORM giấu SQL đằng sau syntax đẹp → dễ viết N+1 mà không biết:

Django (Python)

python
# 🚫 N+1 — Mỗi lần truy cập .user → 1 query mới!
orders = Order.objects.all()[:100]
for order in orders:
    print(order.user.name)  # ← Query mỗi vòng lặp! 💀

# ✅ Fix: select_related (JOIN — 1 query)
orders = Order.objects.select_related('user').all()[:100]

# ✅ Fix: prefetch_related (IN clause — 2 queries)
orders = Order.objects.prefetch_related('user').all()[:100]

SQLAlchemy (Python)

python
from sqlalchemy.orm import joinedload, subqueryload

# 🚫 N+1 — Lazy loading mặc định
orders = session.query(Order).limit(100).all()
for order in orders:
    print(order.user.name)     # ← 1 query mỗi lần!

# ✅ Fix: joinedload (JOIN) hoặc subqueryload (subquery)
orders = session.query(Order).options(joinedload(Order.user)).limit(100).all()

Rails ActiveRecord (Ruby)

ruby
# 🚫 N+1
Order.limit(100).each { |o| puts o.user.name }

# ✅ Fix: includes (Rails tự chọn strategy)
Order.includes(:user).limit(100).each { |o| puts o.user.name }

Cách phát hiện N+1 🔍

Dấu hiệu nhận dạng: Cùng 1 câu query lặp lại nhiều lần, chỉ khác giá trị WHERE (thường là id).

Enable query logging (Django):

python
# settings.py — Log mọi SQL query
LOGGING = {
    'loggers': {
        'django.db.backends': { 'level': 'DEBUG', 'handlers': ['console'] },
    },
}
# Thấy pattern: SELECT * FROM users WHERE id = ? lặp lại → N+1!

Tools chuyên dụng:

FrameworkToolMô tả
DjangoDjango Debug ToolbarHiện số queries per request
Djangonplusone packageAuto-detect N+1
RailsBullet gemCảnh báo N+1 trong development
PostgreSQLpg_stat_statementsThống kê queries phổ biến nhất

💡 Quy tắc 2-query

Hầu hết trang web chỉ cần 2-5 queries mỗi request. Thấy >20 queries → gần như chắc chắn có N+1.


🏋️ Bài tập nhanh

Bài 1: Đọc EXPLAIN — Chẩn đoán và kê thuốc

Cho EXPLAIN output từ bảng products (5 triệu dòng):

Seq Scan on products  (cost=0.00..125000.00 rows=500 width=200)
  Filter: (category_id = 5 AND price > 100000)
  Rows Removed by Filter: 4999500
  Execution Time: 890 ms

Câu hỏi: Vấn đề là gì? Viết câu lệnh tạo index phù hợp.

💡 Xem đáp án

Chẩn đoán: 🔴 Seq Scan — quét 5 triệu dòng, chỉ cần 500. Execution Time: 890 ms → gần 1 giây.

Fix:

sql
-- Equality trước (category_id =), Range sau (price >)
CREATE INDEX idx_products_category_price ON products(category_id, price);
-- → Execution Time: ~1-2 ms. Nhanh hơn ~500 lần!

Bài 2: Tìm N+1

python
def order_list(request):
    orders = Order.objects.filter(status='pending')[:50]
    result = []
    for order in orders:
        result.append({
            'id': order.id,
            'customer': order.user.name,         # ← ???
            'product_count': order.items.count(), # ← ???
        })
    return JsonResponse(result, safe=False)
💡 Xem đáp án

Phân tích: 1 query (orders) + 50 (.user.name) + 50 (.items.count()) = 101 queries!

Fix:

python
orders = (
    Order.objects.filter(status='pending')
    .select_related('user')
    .annotate(product_count=Count('items'))[:50]
)
# → 2-3 queries thay vì 101! ✅

⚠️ Gotcha: Function trên cột Index

Sai lầm phổ biến nhất — đáng dán lên monitor:

sql
-- ❌ Index bị VÔ HIỆU HÓA hoàn toàn
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- Database gọi YEAR() cho MỖI dòng → Seq Scan!

-- ✅ Viết lại dùng range
SELECT * FROM orders 
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- → Index Scan! 🚀

⚠️ Cạm bẫy

"Tôi đã tạo index rồi mà vẫn Seq Scan!" — Checklist debug:

  1. ☐ Function trên cột index? (YEAR(), LOWER(), COALESCE())
  2. ☐ Implicit type cast? (VARCHAR so sánh với INT)
  3. LIKE '%...'? (wildcard ở đầu)
  4. ☐ Bảng quá nhỏ? (< 1000 dòng → optimizer chọn Seq Scan vì nhanh hơn!)
  5. ☐ Đã ANALYZE bảng chưa? (statistics cũ → optimizer đoán sai)

Ghi chú hiệu năng

Index có "giá" — không miễn phí! Mỗi khi INSERT/UPDATE/DELETE, database phải cập nhật tất cả indexes liên quan. 5 indexes = mỗi INSERT ghi 6 lần (1 table + 5 indexes).

Nên đánh IndexKhông nên đánh Index
Cột trong WHERE hay dùngBảng nhỏ (< 1000 dòng)
Cột trong JOIN ONCột ít distinct values (boolean)
Cột trong ORDER BYBảng write-heavy
📏 Bảng < 1,000 dòng   → Index hiếm khi cần
📏 Bảng > 100K dòng    → PHẢI có index, không bàn cãi
📏 1-3 indexes / bảng  → Bình thường
📏 > 6 indexes / bảng  → Review lại, có thể over-indexing

🚫 Anti-pattern: Index Everything

🚨 "Thấy query chậm → đánh index" — SAI!

Quy trình đúng:

Query chậm? → EXPLAIN ANALYZE → Đọc execution plan
  → Xác định bottleneck → Tạo index PHÙ HỢP → EXPLAIN lại để verify

Đôi khi vấn đề ở query, không phải thiếu index. Over-indexing chậm writes, tốn disk.


🎮 Playground: Tự tay trải nghiệm Index

sql
-- 1. Tạo bảng test 100K dòng
CREATE TABLE test_products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    category_id INT,
    price DECIMAL(10,2),
    created_at TIMESTAMP DEFAULT NOW()
);

INSERT INTO test_products (name, category_id, price, created_at)
SELECT 
    'Product ' || i,
    (random() * 20)::int,
    (random() * 1000000)::decimal,
    NOW() - (random() * 365)::int * INTERVAL '1 day'
FROM generate_series(1, 100000) AS i;

-- 2. EXPLAIN TRƯỚC index
EXPLAIN ANALYZE
SELECT * FROM test_products WHERE category_id = 5 AND price > 500000;
-- → Seq Scan, ~50-100ms

-- 3. Tạo index
CREATE INDEX idx_test_cat_price ON test_products(category_id, price);

-- 4. EXPLAIN SAU index
EXPLAIN ANALYZE
SELECT * FROM test_products WHERE category_id = 5 AND price > 500000;
-- → Index Scan, ~1-2ms 🚀

-- 5. Thử violate leftmost prefix
EXPLAIN ANALYZE
SELECT * FROM test_products WHERE price > 500000;
-- → Vẫn Seq Scan! (Thiếu category_id ở đầu)

-- 6. Clean up
DROP TABLE test_products;

🧠 Quiz nhanh

🧠 Quiz

Câu 1: B-Tree index hỗ trợ phép so sánh nào?

  • [ ] A) LIKE '%keyword%'
  • [x] B) BETWEEN '2024-01-01' AND '2024-12-31'
  • [ ] C) LIKE '%keyword'
  • [ ] D) Cả A và C

💡 Giải thích: B-Tree hỗ trợ =, >, <, >=, <=, BETWEEN, LIKE 'prefix%'. Wildcard ở đầu luôn require full table scan.

Câu 2: Composite index (a, b, c) — query nào KHÔNG dùng được index?

  • [ ] A) WHERE a = 1
  • [ ] B) WHERE a = 1 AND b = 2
  • [x] C) WHERE b = 2 AND c = 3
  • [ ] D) WHERE a = 1 AND b = 2 AND c = 3

💡 Giải thích: Leftmost Prefix Rule — phải dùng từ trái sang. Bỏ qua cột a → không dùng được index.

Câu 3: EXPLAIN ANALYZE hiển thị Seq Scan trên bảng 10 triệu dòng. Bước tiếp theo?

  • [ ] A) Tạo index ngay trên tất cả cột trong WHERE
  • [x] B) Đọc kỹ plan, xác định cột cần index, tạo composite index phù hợp
  • [ ] C) Tăng RAM server
  • [ ] D) Cache kết quả query

💡 Giải thích: Đừng "đánh index mù". Đọc plan kỹ, xem cột nào equality vs range, rồi tạo đúng composite index.

Câu 4: Code Django sau phát sinh bao nhiêu queries?

python
users = User.objects.all()[:10]
for u in users:
    print(u.profile.bio)
    print(u.posts.count())
  • [ ] A) 1 query
  • [ ] B) 11 queries
  • [x] C) 21 queries
  • [ ] D) 2 queries

💡 Giải thích: 1 (users) + 10 (.profile.bio) + 10 (.posts.count()) = 21 queries. Fix: select_related('profile').annotate(post_count=Count('posts')).

Câu 5: Tại sao WHERE YEAR(created_at) = 2024 không dùng được index?

  • [ ] A) YEAR() là hàm không hợp lệ
  • [x] B) Database phải gọi YEAR() trên mỗi dòng, không thể dùng B-Tree lookup
  • [ ] C) Index không hỗ trợ năm
  • [ ] D) Cần index trên YEAR thay vì created_at

💡 Giải thích: B-Tree sắp xếp theo giá trị gốc. Wrap function lên → database phải tính cho mọi dòng rồi mới so sánh. Viết lại dùng range: WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'.


📍 Trang tiếp theo


🔑 Tóm tắt chương

┌─────────────────────────────────────────────────────────────┐
│                    3 VŨ KHÍ TỐI ƯU SQL                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. INDEX (Phòng bệnh)                                     │
│     • B-Tree: O(log n) — 1 tỷ dòng ≈ 30 hops              │
│     • Composite: Equality trước, Range sau                  │
│     • Leftmost Prefix: dùng từ trái sang phải              │
│     • Không dùng function trên cột index!                   │
│                                                             │
│  2. EXPLAIN ANALYZE (Chẩn đoán)                             │
│     • Seq Scan 🔴 → Index Scan 🟢                          │
│     • Luôn EXPLAIN trước khi tạo index                      │
│     • Wrap DELETE/UPDATE trong BEGIN...ROLLBACK              │
│                                                             │
│  3. N+1 AWARENESS (Phát hiện)                               │
│     • 1 query + N queries = N+1 problem                     │
│     • Fix: JOIN hoặc batch IN clause                        │
│     • ORM: select_related / prefetch_related                │
│     • Target: 2-5 queries per request                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘