Dataset TechMart chỉ có 28 đơn hàng — dù thêm hay bỏ index, mọi truy vấn đều nhanh tới mức không thể đo được sự khác biệt. Đây chính là lý do bài này cần 1 dataset lớn hơn hẳn: bài học tự sinh 100.000 dòng dữ liệu giả ngay trong phiên làm việc để bạn tự mắt thấy và tự đo chênh lệch tốc độ thật giữa quét toàn bảng và tra cứu qua index — không phải lý thuyết suông.

1. B-Tree Index Hoạt Động Thế Nào?

Không có index, tìm 1 hàng trong bảng 100.000 dòng đồng nghĩa engine phải đọc lần lượt từng dòng để kiểm tra điều kiện — giống lật từng trang sách để tìm 1 từ. Index là 1 cấu trúc dữ liệu B-Tree riêng, lưu sẵn giá trị cột đã sắp xếp kèm con trỏ trỏ ngược về đúng hàng gốc — giống mục lục cuối sách: tra thẳng tới đúng trang, không cần đọc hết. Chi phí: index chiếm thêm dung lượng đĩa, và mỗi lần ghi (INSERT/UPDATE/DELETE) engine phải cập nhật thêm cả cấu trúc B-Tree đó, không chỉ bảng gốc.

Để có dữ liệu đủ lớn minh hoạ, sân chơi bên dưới tự tạo bảng big_orders với 100.000 dòng bằng 1 kỹ thuật SQL thuần, không cần vòng lặp JavaScript chậm chạp — CTE đệ quy sinh dãy số liên tiếp rồi INSERT ... SELECT trực tiếp từ dãy đó:

CREATE TABLE big_orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER,
  order_date TEXT,
  status TEXT,
  total_amount REAL
);

WITH RECURSIVE seq(x) AS (
  SELECT 1
  UNION ALL
  SELECT x + 1 FROM seq WHERE x < 100000
)
INSERT INTO big_orders (order_id, customer_id, order_date, status, total_amount)
SELECT
  x,
  1 + (abs(random()) % 5000),                                    -- customer_id ngẫu nhiên 1-5000
  date('2024-01-01', '+' || (abs(random()) % 700) || ' days'),   -- ngày ngẫu nhiên trong ~2 năm
  CASE abs(random()) % 5
    WHEN 0 THEN 'delivered' WHEN 1 THEN 'shipped' WHEN 2 THEN 'cancelled'
    WHEN 3 THEN 'refunded' ELSE 'pending' END,
  ROUND(10 + (abs(random()) % 40000) / 100.0, 2)
FROM seq;

random() của SQLite trả về số nguyên có dấu — abs() đảm bảo luôn dương trước khi lấy phần dư (%). Đây là bảng bonus riêng cho bài này (giống category_tree ở Bài 5, routes ở Bài 6) — không phải phần cố định của dataset TechMart.

2. EXPLAIN QUERY PLAN: Nhìn Thấy Engine Đang Nghĩ Gì

Đặt EXPLAIN QUERY PLAN trước bất kỳ câu SELECT nào để xem engine dự định thực thi như thế nào, không chạy thật câu lệnh:

EXPLAIN QUERY PLAN
SELECT * FROM big_orders WHERE customer_id = 2500;

Trên bảng big_orders vừa tạo (chưa có index nào ngoài khoá chính), kết quả là:

SCAN big_orders

SCAN nghĩa là quét toàn bảng — đọc tuần tự cả 100.000 dòng, kiểm tra từng dòng có khớp customer_id = 2500 hay không. Đây chính xác là "lật từng trang sách" đã nói ở mục 1.

3. Full Scan vs Index Seek: Đo Bằng Số Thật

Tạo index trên cột đang lọc, rồi chạy lại đúng EXPLAIN QUERY PLAN ở trên:

CREATE INDEX idx_customer ON big_orders(customer_id);

EXPLAIN QUERY PLAN
SELECT * FROM big_orders WHERE customer_id = 2500;
SEARCH big_orders USING INDEX idx_customer (customer_id=?)

SEARCH ... USING INDEX nghĩa là engine tra thẳng B-Tree của idx_customer để nhảy ngay tới đúng vị trí, không quét toàn bảng nữa. Chạy thử trong sân chơi bên dưới và tự đo: trên máy phát triển bài này, quét toàn bảng mất ~4-6ms, tra qua index chỉ còn ~0.03-0.3ms — nhanh hơn 10-100 lần. Với bảng 100 triệu dòng thay vì 100.000, chênh lệch này còn kịch tính hơn nữa: sự khác biệt giữa "tức thì" và "treo hàng giây, thậm chí hàng phút".

ℹ️ Vì sao dataset TechMart (28 đơn) không thể minh hoạ được điều này?
Với 28 dòng, cả quét toàn bảng lẫn tra index đều xong trong chưa tới 0.1ms — độ trễ do overhead gọi hàm JS/WASM còn lớn hơn cả bản thân phép tính, khiến 2 con số gần như giống hệt nhau dù đo nhiều lần. Đây chính xác là lý do B-Tree index chỉ thực sự tạo khác biệt khi dữ liệu đủ lớn — với bảng nhỏ, chi phí quản lý thêm 1 cấu trúc index có khi còn không đáng, đó cũng là lý do SQLite (và mọi engine khác) không tự động đánh index mọi cột.

4. Composite Index: Nhiều Cột Trong 1 Index, Thứ Tự Quan Trọng

Index cũng gộp được nhiều cột — nhưng thứ tự cột lúc khai báo quyết định index có giúp được truy vấn nào, theo quy tắc gọi là left-prefix (tiền tố bên trái): index (A, B) giúp được truy vấn lọc theo A, hoặc AB cùng lúc — nhưng không giúp được truy vấn chỉ lọc theo B đơn lẻ (giống mục lục sách sắp theo "chương rồi tới trang" — muốn tìm 1 trang cụ thể mà không biết chương nào thì mục lục đó vô dụng).

CREATE INDEX idx_status_date ON big_orders(status, order_date);
Truy vấn lọc theo Kế hoạch thực thi Index có giúp không?
WHERE status = 'delivered' SEARCH ... USING INDEX idx_status_date (status=?) ✅ Có — status là cột đầu (left-prefix)
WHERE status = 'delivered' AND order_date > '2024-06-01' SEARCH ... USING INDEX idx_status_date (status=? AND order_date>?) ✅ Có — cả 2 cột theo đúng thứ tự khai báo
WHERE order_date > '2024-06-01' (chỉ lọc cột thứ 2) SCAN big_orders ❌ Không — thiếu điều kiện trên cột đầu tiên (status)

Kết quả đo thật khớp chính xác quy tắc left-prefix: lọc theo order_date đơn lẻ vẫn quay về SCAN big_orders dù index idx_status_date đã tồn tại — vì order_date không phải cột đầu. Muốn cả 2 hướng lọc đều nhanh, cần 2 index riêng (hoặc đảo thứ tự tuỳ query nào chạy thường xuyên hơn).

5. Covering Index: Không Cần Chạm Vào Bảng Gốc

Khi index chứa đủ mọi cột câu lệnh cần (cả cột lọc lẫn cột hiển thị), engine không cần quay lại đọc bảng gốc nữa — chỉ đọc index là đủ trả lời toàn bộ câu hỏi. Đây gọi là covering index (index bao phủ):

-- Với idx_status_date(status, order_date) đã có ở mục 4:
EXPLAIN QUERY PLAN
SELECT customer_id, order_date FROM big_orders WHERE status = 'delivered';
-- → SEARCH ... USING INDEX idx_status_date (status=?)
--   VẪN PHẢI đọc bảng gốc để lấy customer_id (không có trong index này)

CREATE INDEX idx_covering ON big_orders(status, customer_id, order_date);

EXPLAIN QUERY PLAN
SELECT customer_id, order_date FROM big_orders WHERE status = 'delivered';
-- → SEARCH ... USING COVERING INDEX idx_covering (status=?)
--   Không cần đọc bảng gốc nữa — mọi cột cần đều có sẵn trong index

Chú ý từ khoá COVERING xuất hiện thêm trong dòng kế hoạch thứ 2 — đây là tín hiệu trực tiếp từ SQLite xác nhận truy vấn không cần động tới bảng gốc, chỉ cần quét index. Đánh đổi: index càng nhiều cột càng chiếm thêm dung lượng và làm chậm thao tác ghi — không nên biến mọi index thành covering index cho mọi truy vấn có thể có, chỉ áp dụng cho các truy vấn thật sự chạy thường xuyên và quan trọng về tốc độ.

Bảng Tổng Kết: Khi Nào Nên/Không Nên Tạo Index

Tình huống Khuyến nghị
Cột thường xuất hiện trong WHERE/JOIN ON, bảng lớn Nên đánh index — lợi ích đọc vượt xa chi phí ghi
Cột có ít giá trị khác nhau (ví dụ is_active chỉ 0/1) Thường không đáng — index không giúp nhiều khi 1 giá trị chiếm phần lớn bảng
Bảng nhỏ (như 4 bảng TechMart, <100 dòng) Không cần thiết — full scan đã đủ nhanh, thêm index chỉ tốn công quản lý
Bảng ghi liên tục (insert/update tần suất cao), ít đọc Cân nhắc kỹ — mỗi index thêm là 1 cấu trúc phải cập nhật mỗi lần ghi

Sân chơi tương tác: So Sánh Full Scan vs Index Seek Trên 100.000 Dòng

Bấm "Tạo bảng 100k dòng" trước (mất khoảng vài trăm mili-giây), rồi thử các câu lệnh EXPLAIN QUERY PLAN và đo thời gian thực thi trước/sau khi tạo index.

🗄️ Sân chơi tương tác: SQL Workbench (big_orders — 100.000 dòng)

big_orders (bấm "Tạo bảng 100k dòng" để tạo)

Cột Kiểu
order_id INTEGER PRIMARY KEY
customer_id INTEGER (1-5000, ngẫu nhiên)
order_date TEXT (ISO-8601, ~2 năm)
status TEXT (5 giá trị)
total_amount REAL
Bấm "Tạo bảng 100k dòng" bên dưới để bắt đầu...
Đang tải SQLite-WASM engine...
sql-index-query-plan.js

Trắc nghiệm ôn tập

Câu 1: EXPLAIN QUERY PLAN trả về dòng SCAN big_orders. Điều này nghĩa là gì?

Trắc nghiệm ôn tập

Câu 2: Vì sao dataset TechMart (28 đơn hàng) không thể minh hoạ được chênh lệch tốc độ giữa full scan và index seek, buộc bài học phải tạo bảng 100.000 dòng riêng?

Trắc nghiệm ôn tập

Câu 3: Với index idx_status_date ON big_orders(status, order_date), truy vấn WHERE order_date > '2024-06-01' (chỉ lọc theo order_date, không lọc status) có dùng được index này không?

Trắc nghiệm ôn tập

Câu 4: EXPLAIN QUERY PLAN hiện dòng SEARCH ... USING COVERING INDEX idx_covering. Từ khoá COVERING ở đây có ý nghĩa gì?

Trắc nghiệm ôn tập

Câu 5: Vì sao không nên đánh index lên mọi cột của mọi bảng "cho chắc"?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 9: Query Optimizer Sâu 🐳 Bài 7: Window Functions Quay lại Lộ trình Series SQL