SydexaSydexa

SQL Nâng Cao

Index và B+TreeCái giá của IndexEquality & EXPLAIN PlanConcatenated Index & Thứ tự cột3 chiến thuật ScanKhi Function phá IndexPlan Caching & SQL InjectionRange, LIKE & Bitmap CombinePartial Index & Bẫy NULLJoin - Nested Loop & IndexJoin - Hash Join & Sort MergeClustering Data - Index Filter & Index Only ScanClustering Data - Index Organized TablesSorting & Grouping với IndexPartial Result, Top N, Pagination & Window FunctionsInsert, Update, Delete - tác động của Index lên DML
Trang chủKhoá họcSQL Nâng Cao
Cái giá của Index
3 bước của Index Lookup, bước nào thực sự tốn kém, khi nào index phản tác dụng, và trade-offs khi đánh index.

Bạn tạo index xong, rồi sao?

Thứ Hai, bạn tạo index cho bảng orders có 5 triệu dòng. Query WHERE customer_id = ? từ 8 giây xuống 2ms. Merge PR, đi uống cà phê.

Thứ Sáu, sếp gọi: “INSERT mất 500ms, trước chỉ 10ms. Em thay đổi gì vậy hả?”

Bạn kiểm tra lại database: bảng giờ có 7 indexes. Mỗi INSERT phải cập nhật 8 cấu trúc (1 bảng + 7 cây B+Tree). Mỗi cây có thể split node, re-balance. 7 cây nhân lên = thảm họa write.

Bạn chợt nhân ra Index không phải “thêm cho chắc”. Index là sự đánh đổi. Bài này sẽ cho bạn thấy chính xác sự đánh đổi đó nằm ở đâu.

Warm-up: Kiểm tra kiến thức bài trước

1.Trong cấu trúc B+Tree, dữ liệu (data entries) được lưu ở đâu?
1/2

Lộ trình bài học

  • 3 bước của Index Lookup - Bước 1 luôn nhanh. Bước 3 có thể giết performance. Bạn sẽ thấy tận mắt qua animation mô tả.
  • Khi nào index phản tác dụng? - Optimizer đủ thông minh để bỏ qua index khi nó chậm hơn Full Scan. Hiểu tại sao là chìa khóa để debug slow queries.
  • Trade-offs & Thực hành - Mỗi index = 1 cây B+Tree mới trên disk. Bạn sẽ tự tay tạo index, chạy EXPLAIN, và chứng minh lý thuyết.

Phần 1: 3 bước của Index Lookup

Mọi index lookup dù là tìm 1 email hay lọc 100.000 đơn hàng đều đi qua đúng 3 bước. Hiểu rõ từng bước giúp bạn dự đoán được khi nào index nhanh và khi nào nó trở thành gánh nặng.

Bước 1: Tree Traversal

Từ root node, database so sánh search key với các separator keys để chọn đường đi xuống. Mỗi tầng = 1 disk read. Cây cao 3-4 tầng = 3-4 disk reads. Bảng 10 triệu dòng vẫn chỉ 3-4 bước. Bước này luôn nhanh, O(log n), biết trước được tối đa là bao nhiêu.

Bước 2: Leaf Node Chain

Tại leaf node đầu tiên, database duyệt theo linked list sang các leaf tiếp theo để thu thập tất cả entries khớp. Primary Key lookup → dừng ngay (1 entry) nhưng nếu nó không phải Primary Key thì có thể khớp rất nhiều kết quả nữa → duyệt hàng ngàn leaf nodes. Chi phí phụ thuộc hoàn toàn vào số kết quả.

Bước 3: Table Access

Mỗi entry trong leaf chứa ROWID, con trỏ đến vị trí dòng trong bảng gốc (heap). Database phải nhảy đến heap lấy dữ liệu đầy đủ. Vấn đề: các dòng nằm rải rác trên disk → mỗi lần lấy = 1 random I/O. Đây là bước tốn kém nhất.

3 bước Index Lookup — Tìm key = 57

Root39Branch21Branch5783Leaf1118Leaf212739Leaf535757Leaf575862Leaf758357TABLE HEAP(dữ liệu rải rác trên disk)Block reads: 0

Bấm “Bước 1” để xem Index Lookup từ Root xuống Leaf

BướcTênChi phíGiới hạn?
1Tree TraversalO(log n)Có, luôn nhanh (3-4 bước)
2Leaf Node ChainO(k): k = số matchKhông, phụ thuộc kết quả
3Table AccessO(k) × random I/OKhông, đây là "sát thủ"

PostgreSQL EXPLAIN cho thấy rõ 3 bước này

Mỗi lần chạy EXPLAIN, PostgreSQL cho biết nó dùng chiến lược scan nào. Dưới đây là 3 chiến lược phổ biến nhất, cùng output thật để bạn nhận diện ngay.

Index Only Scan: Bước 1 + 2, bỏ bước 3

Chỉ lấy cột có trong index
1EXPLAIN SELECT email FROM users WHERE email = 'thanh@sydexa.com';
 Index Only Scan using idx_users_email on users  (cost=0.42..4.44 rows=1 width=32)
   Index Cond: (email = 'thanh@sydexa.com'::text)

Cột email nằm sẵn trong index, database tìm đến leaf (bước 1), duyệt leaf chain nếu nhiều kết quả (bước 2), rồi trả dữ liệu ngay từ index. Bỏ hoàn toàn bước 3, không cần đọc bảng gốc.

Index Scan: Đầy đủ bước 1 + 2 + 3

Cần các cột ngoài index
1EXPLAIN SELECT * FROM users WHERE email = 'thanh@sydexa.com';
 Index Scan using idx_users_email on users  (cost=0.42..8.44 rows=1 width=128)
   Index Cond: (email = 'thanh@sydexa.com'::text)

Cùng index, cùng WHERE, nhưng SELECT * cần cột name, created_at... không có trong index. Buộc phải nhảy vào heap lấy dữ liệu (bước 3). Cost tăng từ 4.44 → 8.44, width từ 32 → 128, đó là chi phí của Table Access.

Bitmap Scan: Bước 3 được tối ưu thành sequential I/O

12,500 dòng khớp
1EXPLAIN SELECT * FROM orders WHERE status = 'pending';
 Bitmap Heap Scan on orders  (cost=125.17..5765.43 rows=12500 width=64)
   Recheck Cond: (status = 'pending'::text)
   ->  Bitmap Index Scan on idx_orders_status  (cost=0.00..122.05 rows=12500 width=0)
         Index Cond: (status = 'pending'::text)

12,500 kết quả: Index Scan thường sẽ nhảy ngẫu nhiên 12,500 lần vào heap. PostgreSQL chuyển sang bitmap: Bitmap Index Scan quét index gom tất cả vị trí dòng → Bitmap Heap Scan đọc heap theo thứ tự page vật lý, biến random I/O thành sequential.

Quick Quiz

1.Trong 3 bước của index lookup, bước nào có chi phí không biết trước được tối đa là bao nhiêu?
1/1
Vậy ai quyết định dùng scan nào?:

Đó là query optimizer, bộ não có trong mọi database. Mỗi khi bạn chạy một câu query, optimizer phân tích cấu trúc query, ước lượng số rows sẽ khớp dựa trên thống kê của bảng, rồi chọn chiến lược scan có chi phí thấp nhất. Ba loại scan ở trên không phải bạn chọn, optimizer tự quyết định. Và đôi khi, nó quyết định... bỏ qua index luôn.

Phần 2: Khi "Người dẫn đường" phản bội bạn

“Index hỏng rồi, REBUILD thôi!” Có đúng không?

Khi một câu truy vấn dùng Index bỗng trở nên chậm chạp, phản xạ đầu tiên của nhiều Developer và DBA là: “Index dùng lâu bị hỏng rồi, cấu trúc lệch hết, phải đập đi xây lại Index thôi!”

Nhưng thực tế thì sao? B-Tree là cấu trúc tự cân bằng (Self-balancing). Dù bạn INSERT hay DELETE hàng tỷ lần, độ cao cây vẫn ổn định ở mức O(log n) - cấu trúc cây không bao giờ bị “lệch” hay “hỏng”. Điều có thể xảy ra là index bloat(index bị “phình” ra, lãng phí dung lượng): page splits (trang dữ liệu bị tách đôi khi chèn thêm) và dead tuples (các bản ghi cũ chưa được dọn dẹp, đặc biệt trong PostgreSQL do cơ chế MVCC) khiến các page bị thưa thớt, tốn I/O hơn cần thiết. Tuy nhiên, nguyên nhân phổ biến nhất khiến index chậm không phải do bloat, mà là do cách ổ đĩa đọc dữ liệu - cụ thể là vấn đề Random I/O mà ta sẽ phân tích ngay sau đây.

Nếu Index không hỏng, tại sao nó chậm? Câu trả lời nằm ở sự kết hợp của 2 nguyên liệu chết người:

Nguyên liệu 1: Cơn ác mộng “Chọn lọc kém” (Low Selectivity)

Hãy tưởng tượng bạn nhờ Index tìm những user có status = 'ACTIVE'. Vấn đề là: 80% user trong bảng 1 triệu dòng đều đang ACTIVE. Thay vì chỉ dẫn bạn đến một vị trí cụ thể, Index bắt bạn đi dọc theo chuỗi Linked List qua 800.000 entries. Bạn phải duyệt qua hàng ngàn Node lá liên tiếp. Đây là một cuộc hành xác, không phải tìm kiếm.

Nguyên liệu 2: Chiếc bẫy Random I/O (Table Access)

0:00/0:00

Khi nào optimizer bỏ qua index?

Query optimizer thông minh: nó ước tính số rows sẽ khớp dựa trên statistics. Nếu dự đoán >5-10% bảng sẽ khớp → nó chủ động chọn Full Table Scan thay vì dùng index. Đây KHÔNG phải bug, đây là quyết định đúng!

Tình huốngSelectivityOptimizer chọn
Tìm 1 user theo emailRất caoIndex Scan
Tìm 10 orders theo customer_idCaoIndex Scan
Tìm status='ACTIVE' (80% bảng)Rất thấpFull Table Scan
Tìm gender='M' (50% bảng)ThấpFull Table Scan

Index nhanh khi: ít row khớp (high selectivity). Bước 2+3 gần như không dùng.

Index chậm khi: nhiều row khớp (low selectivity). Chi phí random I/O lớn hơn sequential scan.

Biểu đồ dưới đây cho thấy chi phí của 3 chiến lược scan thay đổi theo tỷ lệ dòng trả về. Chú ý điểm giao: Index Scan chỉ thắng khi tỷ lệ thấp.

Khi nào chiến lược nào thắng?

% dòng trả vềThời gian0%25%50%75%100%Index Scan thắngBitmap Scan thắngSeq Scan thắngIndex ScanBitmap ScanSeq ScanCác điểm giao phụ thuộc vào random_page_cost, loại storage, và correlation

Quick Quiz

1.Bảng 1 triệu rows. Query WHERE status = 'ACTIVE' khớp 800.000 rows. Optimizer sẽ làm gì?
1/2

Phần 3: Cái giá phải trả: Tại sao không đánh index hết?

Giả sử bạn có bảng transactions với 100 triệu dòng. Bạn đánh tới 12 indexes, query theo cột nào cũng nhanh, mọi thứ trông thật hoàn hảo. Nhưng rồi bạn nhận ra INSERT từ 5ms nhảy vọt lên 200ms. Đến ngày sale lớn, hàng đợi tràn ngập, timeout liên tục, hệ thống gần như đứng hình. Lúc này bạn ngồi lại rà soát, xóa bớt 8 indexes thừa, chỉ giữ đúng 4 cái thực sự cần thiết. INSERT lập tức về 15ms. Vậy bài học ở đây là gì? Index không hề miễn phí.

0:00/0:00
Đọc (SELECT)Ghi (INSERT/UPDATE/DELETE)Disk Space
0 indexChậm (Full Scan)Nhanh nhấtÍt nhất
2-3 index (hợp lý)NhanhChấp nhận đượcVừa phải
10+ index (quá nhiều)NhanhRất chậmRất tốn
Nguyên tắc vàng: Chỉ đánh index cho những cột thực sự xuất hiện trong WHERE, JOIN, hoặc ORDER BYthường xuyên. Đừng index “cho chắc”, hãy index “cho đúng”.
Câu hỏi phỏng vấn phổ biến: “Tại sao không đánh index cho tất cả các cột?” - câu trả lời cần đề cập 3 điểm:
1. Mỗi index tốn disk space
2. Mỗi write operation phải cập nhật tất cả index liên quan
3. optimizer có thể bỏ qua index nếu selectivity thấp.
Nắm được 3 điểm này là đủ ghi điểm với mọi nhà tuyển dụng

Quick Quiz

1.Bảng có 5 index. Khi INSERT 1 dòng mới, database phải cập nhật bao nhiêu cấu trúc?
1/3

Phần 4: Thực hành & Bài tập

Bài tập 1: Xây B-Tree (Interactive)

Xây dựng B-Tree từ Dataset

Bước 1: Kéo các số vào Leaf Nodes
55
48
42
60
27
5
67
18
31
23
12
35
Leaf 1
?
?
?
Leaf 2
?
?
?
Leaf 3
?
?
?
Leaf 4
?
?
?

Kéo các số vào đúng vị trí trong Leaf Nodes

Cho tập dữ liệu: [5, 12, 18, 23, 27, 31, 35, 42, 48, 55, 60, 67] (12 entries), mỗi node chứa tối đa 3 entries.

  • Bước 1 - Vẽ tầng Leaf Nodes: chia 12 entries thành 4 nhóm × 3 entries. Vẽ doubly linked list nối chúng.
  • Bước 2 - Vẽ tầng Branch: lấy max key mỗi leaf → [18, 31, 48, 67] → vừa đủ 1 branch/root node.
  • Bước 3 - Mô phỏng tìm key = 35: đi qua bao nhiêu nodes?
  • Bước 4 - Mô phỏng tìm key = 27 khi có 2 entries trùng key: bước 2 (leaf chain) hoạt động thế nào?

Bài tập 2: Thực hành trên PostgreSQL

PostgreSQL 17 · Sandbox
Premium

Mở khoá SQL Sandbox: thực hành trên database thật

SQL Playground chỉ mở cho học viên đã mua khoá “SQL Nâng Cao”. Khi mua, bạn được chạy trực tiếp query trên PostgreSQL với dataset thật của bài học.

  • Chạy SQL trực tiếp trên PostgreSQL 17 thật, không phải kết quả mô phỏng
  • Mỗi học viên có schema riêng, tự do CREATE / INSERT / EXPLAIN
  • Reset dữ liệu về trạng thái gốc bất cứ lúc nào để thử lại
299.000₫

Phần 5: Câu hỏi kiểm tra hiểu biết

Trả lời các câu hỏi sau:

Kiểm tra cuối bài

1.Index depth = 4. Bảng có 10 triệu rows. Tìm 1 row bằng PK tốn bao nhiêu block reads?
1/3

3 bước của Index Lookup là gì?

Nhấp vào thẻ để lật

1) Tree Traversal (luôn nhanh, O(log n)). 2) Leaf Node Chain (duyệt kết quả). 3) Table Access (random I/O: bước tốn kém nhất).

Nhấp vào thẻ để lật lại
1/3
Bức tranh toàn cảnh (cả 2 bài): Từ Full Table Scan → BST (thất bại trên disk) → B-Tree (nhét đầy page) → B+Tree (data ở leaf + linked list) → 3 bước Index Lookup → Khi nào index phản tác dụng → Trade-offs.

Còn nhớ câu query email 12 giây → 3ms ở đầu bài 1? Bây giờ bạn biết chính xác chuyện gì xảy ra trong 3ms đó: root node (đã nằm sẵn trong RAM) → 1-2 branch node → leaf node chứa email → theo ROWID về bảng gốc. Tổng cộng 2-3 lần đọc disk. Đó là sức mạnh của B+Tree.

Index không phải “thêm một cái gì đó”, bạn đang yêu cầu database xây nguyên một cây B+Tree mới, với hàng triệu node, được tinh chỉnh để mỗi lần đọc disk đều xứng đáng. Hiểu được điều này, bạn sẽ thiết kế schema tốt hơn, chọn Primary Key thông minh hơn, và debug những query chậm hiệu quả hơn.
Bài tiếp theo: WHERE Clause & Equality: Bạn đã biết khi nào dùng index và cái giá phải trả. Nhưng viết WHERE email = ? khác gì WHERE last_name = ?? Tại sao cùng toán tử = mà một cái tốn 0.01ms, cái kia tốn 12ms? Bài 3 sẽ đi sâu vào Primary Key Lookup, Unique Scan vs Range Scan, và cách đọc EXPLAIN Plan để phân biệt, kỹ năng bạn sẽ dùng mỗi ngày khi debug slow queries.
Bài trướcBài tiếp theo