Skip to content

Zoom-in: Database Index

Karify98·
Cover Image for 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.

Vấn đề còn lại: cần cách tìm mà không phải quét toàn bộ bảng.

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 đó.

Vấn đề còn lại: B-tree cần được cập nhật mỗi khi data trong bảng thay đổi.

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.

Vấn đề còn lại: quá nhiều index làm chậm ghi — thiếu index làm chậm đọc. Còn cách nào tối ưu hơn không?

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