Zoom-in: Database Index

Câu query chạy chậm. AI gợi ý thêm index. Thêm vào, nhanh hơn 10 lần. Commit, xong.
graph LR
Q(["🔍 Query"]) -->|"WHERE email = 'x@y.com'"| T(["📋 Table"])
T -->|"kết quả"| R(["✅ Row"])
style Q fill:#1e293b,stroke:#475569,color:#cbd5e1
style T fill:#1e293b,stroke:#475569,color:#cbd5e1
style R fill:#1e293b,stroke:#475569,color:#cbd5e1
Phóng to dần vào black box đó.
Layer 1 — Bảng không có index: mỗi query quét từng row một
Rows trong bảng được lưu theo thứ tự chèn vào — không có thứ tự logic nào cả. Database gọi vùng lưu trữ không có thứ tự này là heap.
graph LR
Q["🔍 WHERE email\n= 'x@y.com'"] -->|"quét"| R1["Row 1\nuser_a"]
Q -->|"quét"| R2["Row 2\nuser_b"]
Q -->|"quét"| R3["Row 3\nuser_c"]
Q -->|"quét"| Rn["... Row N"]
style Q fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style R1 fill:#1e293b,stroke:#334155,color:#94a3b8
style R2 fill:#1e293b,stroke:#334155,color:#94a3b8
style R3 fill:#1e293b,stroke:#334155,color:#94a3b8
style Rn fill:#1e293b,stroke:#334155,color:#94a3b8
Để tìm row theo điều kiện WHERE, database đọc từng row một từ đầu đến cuối rồi kiểm tra. Với 10.000 rows thì chưa thấy vấn đề. Với 10 triệu rows, database phải kiểm tra 10 triệu row cho mỗi query.
Layer 2 — B-tree Index: tìm kiếm O(log n)
Index là một cấu trúc dữ liệu riêng, được database duy trì song song với bảng. Phổ biến nhất là B-tree.
graph TD
root["50"] --> L["25"]
root --> R["75"]
L --> LL["10"]
L --> LR["37"]
R --> RL["60"]
R --> RR["90"]
style root fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style L fill:#1e293b,stroke:#475569,color:#cbd5e1
style R fill:#1e293b,stroke:#475569,color:#cbd5e1
style LL fill:#1e293b,stroke:#334155,color:#94a3b8
style LR fill:#1e293b,stroke:#334155,color:#94a3b8
style RL fill:#1e293b,stroke:#334155,color:#94a3b8
style RR fill:#1e293b,stroke:#334155,color:#94a3b8
Thay vì đọc từng row, database đi theo cây: so sánh tại mỗi node, rẽ trái hoặc phải. Với 10 triệu rows, B-tree cần tối đa 23 bước để tìm bất kỳ giá trị nào (log₂ 10.000.000 ≈ 23).
Mỗi node lá trong B-tree lưu giá trị cột và pointer trỏ về row tương ứng trong heap. Tìm thấy trong B-tree → nhảy thẳng đến row đó.
Layer 3 — Chi phí ghi: mỗi thay đổi đều có giá
Khi thêm một index, mọi thao tác ghi đều phải duy trì hai cấu trúc: heap và B-tree.
sequenceDiagram
participant A as Ứng dụng
participant DB as Database
A->>DB: INSERT INTO users (email, ...)
DB->>DB: Ghi row vào heap
DB->>DB: Cập nhật B-tree index
DB-->>A: OK
A->>DB: UPDATE users SET email = ...
DB->>DB: Cập nhật row trong heap
DB->>DB: Xóa node cũ + chèn node mới vào B-tree
DB-->>A: OK
Một bảng có 5 index → mỗi INSERT cập nhật 6 cấu trúc. UPDATE trên cột được đánh index phải xóa node cũ khỏi cây và chèn node mới vào đúng vị trí — hai thao tác thay vì một.
Layer 4 — Covering Index: không cần quay lại bảng
Thông thường, tìm trong B-tree → lấy pointer → đọc lại heap để lấy các cột còn thiếu. Covering index bao gồm luôn tất cả cột mà query cần.
graph LR
Q["🔍 SELECT email, name\nWHERE email = 'x@y.com'"] --> I["🌳 Index\n(email, name)"]
I -->|"đủ data — không cần đọc heap"| R["✅ Kết quả"]
style Q fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style I fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style R fill:#1a3a2a,stroke:#22c55e,color:#86efac
CREATE INDEX idx_users_email_name ON users (email, name);
-- Query SELECT email, name WHERE email = ... không chạm vào heap
Database gọi đây là Index Only Scan — nhanh hơn Index Scan thông thường vì bỏ được một bước truy xuất heap. Đây là lý do một số query nhanh bất ngờ sau khi thêm index với nhiều cột.
Full picture
Một query đi qua gì trước khi trả về kết quả.
graph TD
Q["🔍 SELECT email, name\nWHERE email = 'x@y.com'"] --> P["Query Planner"]
P -->|"Không có index"| FS["Full Scan\nO(n) — đọc toàn bộ heap"]
P -->|"Index trên email"| IS["Index Scan\nO(log n) — B-tree\n→ pointer → đọc heap"]
P -->|"Covering index\n(email, name)"| CS["Index Only Scan\nO(log n) — B-tree\n→ kết quả, không cần heap"]
FS --> R["✅ Kết quả"]
IS --> R
CS --> R
style Q fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
style P fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
style FS fill:#3b1a1a,stroke:#ef4444,color:#fca5a5
style IS fill:#1e293b,stroke:#475569,color:#cbd5e1
style CS fill:#1a3a2a,stroke:#22c55e,color:#86efac
style R fill:#1a3a2a,stroke:#22c55e,color:#86efac
Ba nhầm lẫn phổ biến
Index luôn làm nhanh hơn
Sai với bảng nhỏ hoặc query trả về phần lớn bảng. Nếu query lấy 80% rows, full scan đôi khi nhanh hơn vì tránh được bước nhảy từ B-tree sang heap. Query planner biết điều này — đôi khi nó chủ ý bỏ qua index.
Thêm index trên mọi cột để an toàn
Mỗi index là thêm một cấu trúc phải duy trì. Bảng ghi nhiều với 10 index sẽ thấy INSERT chậm rõ rệt. Chỉ thêm index khi có query cụ thể cần tối ưu — và dùng EXPLAIN ANALYZE để đo trước và sau.
Thứ tự cột trong composite index không quan trọng
Index (last_name, first_name) giúp tốt cho WHERE last_name = 'Nguyen' nhưng không giúp gì cho WHERE first_name = 'Nam' một mình — database vẫn phải scan hết. Cột có tính chọn lọc cao (cardinality cao) nên đứng trước.
Takeaway
Index không làm database nhanh hơn — nó là sự đánh đổi: đọc nhanh hơn, ghi chậm hơn, tốn thêm không gian. B-tree tồn tại vì heap không có thứ tự; covering index tồn tại vì bước nhảy từ B-tree sang heap cũng có chi phí.
Khi query chậm, câu hỏi đúng không phải "thêm index chưa" mà là "query planner đang làm gì" — EXPLAIN ANALYZE sẽ cho thấy full scan, index scan, hay index only scan đang diễn ra.
Bài viết được hỗ trợ bởi Amy 🌸 - AI Assistant. Nội dung đã được kiểm duyệt bởi tác giả.
Related Posts
Zoom-in: TCP
Mọi HTTP request đều đi trên TCP — nhưng trước khi byte đầu tiên của dữ liệu đi qua, đã có 3 gói tin trao đổi mà không mang dữ liệu nào. TCP giải quyết vấn đề mà Internet không giải quyết được.
Zoom-in: OAuth 2.0
'Đăng nhập bằng Google' ẩn sau đó một cơ chế ủy quyền mà không bao giờ để password rời khỏi Google. OAuth 2.0 giải quyết bài toán delegation mà không hi sinh bảo mật.
Zoom-in: Load Balancer
Một domain, hàng triệu request mỗi ngày. Load balancer không chỉ phân phối traffic — nó là điểm quyết định routing, health check, và session management cho toàn bộ hệ thống.