SQL & Database — Thực chiến PostgreSQL/Tại sao cần index — heap scan 800ms vs index scan 0.2ms
~20 phútIndexing internals lượt xem

Tại sao cần index — heap scan 800ms vs index scan 0.2ms

Heap = unsorted data. Index = sorted lookup table. 4000x speedup. 3 scan strategy: Seq/Index/Index Only. When index NOT help.

Team TaskFlow có bảng tasks với 10 triệu row. Dashboard load chậm — QA report: query SELECT * FROM tasks WHERE id = 12345 chạy mất 800ms. Không ai để ý vì local chỉ có vài trăm row, test pass sạch. Production traffic tăng, response time leo thang — đến lúc khách hàng bắt đầu complain.

Sau khi EXPLAIN ANALYZE lần đầu: Seq Scan on tasks, actual time=0.05..800ms rows=1. Database đọc toàn bộ 10 triệu row chỉ để tìm một dòng. CREATE INDEX idx_tasks_id ON tasks(id) — query chạy lại: 0.2ms. Chênh 4000 lần. Vì sao chênh khủng vậy? Bài này giải thích cơ chế heap vs index, 3 loại scan strategy, demo EXPLAIN before/after, và 4 trường hợp index không giúp gì.

1. Analogy — Mục lục sách

Hình dung bạn cầm cuốn sách 1000 trang. Muốn tìm tất cả chỗ nhắc đến "B-tree" — bạn có hai cách: đọc từ trang 1 đến trang 1000, hoặc lật thẳng ra mục lục cuối sách, thấy "B-tree: trang 42, 87, 156", đến thẳng ba trang đó.

Heap (bảng dữ liệu) = 1000 trang đọc tuần tự. Index = mục lục cuối sách → đến thẳng trang cần tìm. Trade-off: in mục lục tốn thêm giấy và phải cập nhật khi sách in lại bổ sung nội dung.

Sách inDatabase
1000 trang nội dungHeap — data pages chứa row
Mục lục cuối sáchIndex — sorted lookup structure
Trang số trong mục lụcHeap page + offset pointer trong index leaf
Tìm từ đầu đến cuốiSequential scan (Seq Scan)
Tra mục lục rồi đến trangIndex Scan
Mục lục đủ thông tin, khỏi mở sáchIndex Only Scan
In thêm mục lục tốn giấyIndex chiếm storage thêm
Mỗi lần in lại phải cập nhật mục lụcMỗi INSERT/UPDATE/DELETE phải update index
💡 Cách nhớ

Index là mục lục — tra nhanh, nhưng tốn giấy và phải đồng bộ khi nội dung thay đổi. Không có index: đọc toàn bộ. Có index: nhảy thẳng đến chỗ cần. Over-index: mỗi lần ghi phải cập nhật nhiều mục lục cùng lúc.

2. Heap = unsorted data, Index = sorted lookup

Heap là nơi PostgreSQL lưu data thực tế. Không có thứ tự. Row được insert vào bất kỳ page nào còn chỗ trống — page 1 có thể chứa row id=42, id=7, id=99 xen kẽ nhau.

Heap (tasks table):
[Page 1] row(id=42, title='Fix login'), row(id=7, title='Write docs'), row(id=99, title='Deploy')
[Page 2] row(id=15, title='Review PR'), row(id=3, title='Setup CI'), row(id=88, title='Load test')
[Page 3] row(id=1, title='Init repo'), row(id=55, title='Write tests'), ...
...
[Page N] ... (10 million rows total, no ordering)

Seq Scan = đọc từ Page 1 đến Page N, check mọi row. Với 10 triệu row trải trên hàng nghìn page = 800ms.

B-tree Index lưu riêng một cấu trúc sorted theo column được index. Mỗi leaf entry chứa giá trị column + pointer (heap_page, offset) để fetch row gốc.

B-tree index ON tasks(id):

            [Root: 5000000]
           /               \
   [Node: 1-2500000]   [Node: 2500001-10000000]
        |                       |
[Leaf: 1,3,7,15,42,...]  [Leaf: 2500001,2500003,...]
      |      |                  |
  (ptr: P1   (ptr: P2       (ptr: P47
   off=2)     off=0)         off=1)

Lookup id = 12345: navigate tree từ root xuống leaf, O(log N) bước, thấy pointer (heap_page=38, offset=3), fetch đúng 1 page = 0.2ms.

PostgreSQL tự tạo B-tree index cho mọi PRIMARY KEYUNIQUE constraint. Index trên column khác phải CREATE INDEX explicit.

3. Ba loại scan strategy

3.1 Setup demo

-- Tao bang demo 10 trieu row
CREATE TABLE tasks_demo (
  id BIGSERIAL PRIMARY KEY,
  title TEXT,
  status TEXT,
  due_at TIMESTAMPTZ
);

INSERT INTO tasks_demo(title, status, due_at)
SELECT
  'Task ' || g,
  (ARRAY['todo','doing','done'])[1 + (random() * 2)::int],
  now() + (random() * 30) * INTERVAL '1 day'
FROM generate_series(1, 10000000) g;

CREATE INDEX idx_tasks_demo_status ON tasks_demo(status);
CREATE INDEX idx_tasks_demo_due    ON tasks_demo(due_at);

ANALYZE tasks_demo;  -- cap nhat statistics sau bulk insert

3.2 Seq Scan — đọc toàn heap

-- Query khong co WHERE -> bat buoc Seq Scan
EXPLAIN ANALYZE SELECT * FROM tasks_demo;
-- Seq Scan on tasks_demo
-- actual time=0.05..800.00 rows=10000000
-- Buffers: shared hit=88496 (doc ~88k page)

-- Query tren column khong co index
EXPLAIN ANALYZE SELECT * FROM tasks_demo WHERE title = 'Task 12345';
-- Seq Scan (khong co index tren title)
-- actual time=0.05..750.00 rows=1

Planner chọn Seq Scan khi:

  • Không có index phù hợp.
  • Selectivity thấp: query trả về hơn 5–10% số row → random I/O từ index lookup đắt hơn đọc tuần tự heap.
  • Bảng nhỏ (dưới vài trăm page): cost-based planner thấy full scan rẻ hơn.

3.3 Index Scan — lookup B-tree rồi fetch heap

-- Selectivity cao: tim 1 row trong 10 trieu
EXPLAIN ANALYZE SELECT * FROM tasks_demo WHERE id = 12345;
-- Index Scan using tasks_demo_pkey on tasks_demo
-- Index Cond: (id = 12345)
-- actual time=0.05..0.20 rows=1
-- Buffers: shared hit=4 (chi doc 4 page: 3 B-tree node + 1 heap page)

Cost = O(log N) navigate tree + số heap fetch tương ứng số row trả về. Càng ít row trả về, Index Scan càng vượt trội so với Seq Scan.

-- Selectivity thap: status = 'todo' ~ 33% row -> planner co the chon Seq Scan
EXPLAIN ANALYZE SELECT * FROM tasks_demo WHERE status = 'todo';
-- Seq Scan (33% = ~3.3 trieu row, random fetch 3.3M heap page dat hon seq scan)

3.4 Index Only Scan — không cần chạm heap

-- Index co du moi column query can (status filter + id return)
CREATE INDEX idx_tasks_status_id
  ON tasks_demo(status)
  INCLUDE (id);

VACUUM ANALYZE tasks_demo;  -- can thiet: cap nhat visibility map

EXPLAIN ANALYZE SELECT id FROM tasks_demo WHERE status = 'done';
-- Index Only Scan using idx_tasks_status_id on tasks_demo
-- actual time=0.05..12.00 rows=3333000
-- Heap Fetches: 0  (khong touch heap)

Index Only Scan hoạt động khi index chứa mọi column query cần (status để filter, id để SELECT). Planner bỏ qua heap hoàn toàn. Điều kiện thêm: visibility map phải đánh dấu page là "all-visible" — đảm bảo row đủ điều kiện MVCC mà không cần kiểm tra heap. VACUUM cập nhật visibility map — production cần autovacuum chạy thường xuyên.

Module 6 của khoá này đi sâu vào MVCC và visibility map.

4. EXPLAIN demo before/after — TaskFlow scenario

-- TaskFlow: 100k task, query dashboard theo assignee
-- Truoc khi co index
EXPLAIN ANALYZE
SELECT * FROM tasks
WHERE assignee_id = 5;
-- Seq Scan on tasks
-- actual time=5.00..250.00 rows=412 loops=1
-- Planning Time: 0.5 ms
-- Execution Time: 250.3 ms
-- Tao index
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
ANALYZE tasks;  -- cap nhat statistics ngay sau khi tao index

-- Sau khi co index
EXPLAIN ANALYZE
SELECT * FROM tasks
WHERE assignee_id = 5;
-- Index Scan using idx_tasks_assignee on tasks
-- Index Cond: (assignee_id = 5)
-- actual time=0.05..1.20 rows=412 loops=1
-- Planning Time: 0.3 ms
-- Execution Time: 1.2 ms

200 lần nhanh hơn. ANALYZE quan trọng ngay sau khi tạo index hoặc bulk insert lớn: planner cần fresh statistics (row count, data distribution) để chọn đúng plan. Module 7 của khoá này đi sâu vào statistics và cost model.

5. Bốn trường hợp index không giúp gì

-- 1. Selectivity thap: status = 'todo' chiem ~33% bang
--    Planner chon Seq Scan vi random index fetch 3.3M heap page dat hon seq scan
EXPLAIN ANALYZE SELECT * FROM tasks WHERE status = 'todo';
-- Seq Scan (du co idx_tasks_demo_status)
-- 2. Function tren indexed column: index tren email, query dung LOWER()
--    B-tree index luu gia tri goc, khong luu LOWER() gia tri
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- Seq Scan (index tren email khong dung cho LOWER(email))

-- Fix: tao expression index
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- Index Scan using idx_users_email_lower
-- 3. Implicit cast mismatch: id BIGINT, query compare voi text
SELECT * FROM tasks WHERE id::text = '12345';
-- Seq Scan: cast BIGINT -> TEXT, index tren BIGINT khong applicable
-- Fix: bo cast, compare dung type
SELECT * FROM tasks WHERE id = 12345;
-- Index Scan
-- 4. Leading wildcard LIKE: % dau chuoi -> B-tree khong biet bat dau tu dau
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- Seq Scan: B-tree chi support prefix search (LIKE 'foo%'), khong support suffix
-- Fix: pg_trgm GIN index (Module 9 cua khoa nay Full Text Search deep)

Pattern chung: tạo index không đảm bảo được dùng. Phải EXPLAIN kiểm tra, ANALYZE giữ statistics mới.

6. Pitfall — over-index là write killer

Pitfall — index quá nhiều làm chậm write

Mỗi index trên một bảng là một cấu trúc riêng cần được cập nhật khi có INSERT/UPDATE/DELETE. Bảng 7 index = mỗi insert phải update 7 B-tree. Write throughput giảm tuyến tính, storage 2–5 lần data, VACUUM/autovacuum overhead tăng.

-- Anti-pattern: index moi column
CREATE INDEX idx1 ON tasks(title);
CREATE INDEX idx2 ON tasks(status);
CREATE INDEX idx3 ON tasks(due_at);
CREATE INDEX idx4 ON tasks(assignee_id);
CREATE INDEX idx5 ON tasks(project_id);
CREATE INDEX idx6 ON tasks(created_at);
CREATE INDEX idx7 ON tasks(updated_at);
-- Hau qua: INSERT tasks phai update 7 B-tree
-- Write throughput giam, storage phong len 3-5x

-- Fix: chi index column can thiet dua tren workload thuc te
-- Identify slow query truoc (Module 11 pg_stat_statements)
-- Track unused index sau khi deploy:
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

Nguyên tắc: đo trước, index sau. Không đoán mò.

7. Index lifecycle — tạo, xóa, maintain

-- Tao index (block writes trong luc build)
CREATE INDEX idx_tasks_due ON tasks(due_at);

-- Tao CONCURRENTLY: khong block writes, nen dung tren production
-- Lau hon (phai doc heap 2 lan), nhung safe
CREATE INDEX CONCURRENTLY idx_tasks_due ON tasks(due_at);

-- Xoa
DROP INDEX idx_tasks_due;
DROP INDEX CONCURRENTLY idx_tasks_due;  -- khong block reads

-- Phat hien index khong duoc dung (idx_scan = 0 sau khi deploy du lau)
SELECT schemaname, tablename, indexname,
       idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

-- Rebuild index bi bloat (forward Module 11)
REINDEX INDEX CONCURRENTLY idx_tasks_due;

Module 11 của khoá này đi sâu vào production index lifecycle: bloat detection với pgstattuple, rebuild không downtime với pg_repack.

8. Applied — TaskFlow dashboard query

Module 2 bài 7 của khoá này có dashboard query:

SELECT id, title, project_id, due_at
FROM tasks
WHERE assignee_id = $1
  AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '7 days'
ORDER BY due_at;

Naive index — chỉ index assignee_id:

CREATE INDEX ON tasks(assignee_id);
-- PG dung Index Scan tren assignee_id, roi filter status + due_at tren heap
-- Ket qua: 200ms (filter hang ngan row sau khi fetch heap)

Composite index — cover ca ba column filter (Module 5 bài 3 của khoá này deep):

CREATE INDEX ON tasks(assignee_id, status, due_at);
-- PG dung Index Scan, filter status va due_at trong index (khong can fetch heap row thua)
-- Ket qua: ~5ms

Covering index với INCLUDE — Index Only Scan:

CREATE INDEX ON tasks(assignee_id, status, due_at) INCLUDE (project_id, title);
-- PG dung Index Only Scan: moi column SELECT (id, title, project_id, due_at)
--   deu co trong index -> khong can fetch heap
-- Ket qua: ~1ms

Module 5 bài 5 của khoá này sẽ đo cả ba cách với EXPLAIN thực tế và so sánh số.

9. Deep Dive — Index foundations

📚 Deep Dive — Index foundations

Ghi chú: PG docs Ch.11 cho catalog tổng quan các loại index và khi nào dùng loại nào. Use The Index Luke cho intuition và cách đọc EXPLAIN output. Rogov Part V cho B-tree internal structure nếu muốn hiểu tận gốc — phần đọc thêm, không bắt buộc cho bài này.

10. Liên kết khoá học khác

11. Tóm tắt

  • Heap lưu row không có thứ tự — Seq Scan phải đọc mọi page từ đầu đến cuối.
  • B-tree index lưu riêng cấu trúc sorted — lookup O(log N) rồi fetch đúng heap page cần.
  • 3 loại scan: Seq Scan (không index hoặc selectivity thấp), Index Scan (selectivity cao, fetch heap), Index Only Scan (index chứa mọi column cần, không fetch heap — cần visibility map).
  • EXPLAIN ANALYZE luôn kiểm tra trước/sau khi thêm index — không đoán plan.
  • ANALYZE cập nhật statistics cho planner chọn đúng plan — quan trọng sau bulk insert hoặc tạo index mới.
  • 4 trường hợp index không giúp: selectivity thấp, function trên indexed column, type cast mismatch, leading wildcard LIKE.
  • Over-index = write killer: mỗi index thêm là một B-tree phải update trên mỗi INSERT/UPDATE/DELETE.
  • Forward: Module 5 bài 2 (B-tree internals — page split, fill factor), Module 5 bài 3 (composite index ordering), Module 7 (planner cost model và statistics), Module 11 (production index lifecycle) của khoá này.

12. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Seq Scan đôi khi rẻ hơn Index Scan dù đã có index? Cơ chế selectivity threshold hoạt động thế nào?

Index Scan thực hiện random I/O: với mỗi row khớp điều kiện, database phải fetch đúng heap page chứa row đó. Nếu query trả về nhiều row phân tán trên nhiều page khác nhau, số lần random I/O tăng tuyến tính theo số row — đắt hơn đọc tuần tự toàn heap.

Seq Scan ngược lại là sequential I/O: đọc từng page theo thứ tự vật lý, rất hiệu quả với disk cơ (prefetch) và cũng nhanh trên SSD. Khi query trả về hơn khoảng 5–10% số row trong bảng, chi phí random I/O của Index Scan vượt qua chi phí sequential I/O của Seq Scan.

PostgreSQL planner tính toán điểm crossover này dựa trên statistics (pg_class.reltuples, histogram từ ANALYZE) và các cost parameter (random_page_cost, seq_page_cost). Giá trị mặc định random_page_cost=4.0 phản ánh HDD. Trên SSD hoặc cloud storage latency thấp, có thể hạ xuống 1.1–1.5 để planner favor Index Scan hơn — Module 7 của khoá này đi sâu vào tuning này.

Q2
Phân biệt Index Scan và Index Only Scan. Khi nào INCLUDE column trong index thực sự quan trọng?

Index Scan: navigate B-tree tìm pointer, rồi fetch heap page để lấy row đầy đủ. Cần heap fetch vì index không chứa đủ column mà query SELECT cần.

Index Only Scan: navigate B-tree, lấy data trực tiếp từ index leaf — không fetch heap. Chỉ khả thi khi index chứa mọi column mà query SELECT và WHERE cần, và visibility map đánh dấu page là all-visible (đảm bảo MVCC an toàn mà không cần check heap).

INCLUDE (col1, col2) thêm column vào leaf node mà không đưa vào sort key của B-tree. Quan trọng khi: (1) query SELECT cần thêm column ngoài filter column; (2) column đó có giá trị thay đổi thường (không muốn nó ảnh hưởng đến tree structure); (3) muốn giảm heap fetch trên bảng có nhiều read. Ví dụ: index (assignee_id, status, due_at) INCLUDE (project_id, title) cover toàn bộ dashboard query mà không cần fetch heap.

Q3
Bạn CREATE INDEX xong query vẫn chậm — EXPLAIN show Seq Scan. Bốn nguyên nhân phổ biến và cách fix từng cái?
  • Selectivity thấp: query trả về quá nhiều row (hơn 5–10%). Planner đúng khi chọn Seq Scan. Fix: xem xét lại query, thêm filter selectivity cao hơn, hoặc chấp nhận Seq Scan là hợp lý.
  • Function trên indexed column: LOWER(email), DATE(created_at), id::text — index lưu giá trị gốc, không match expression. Fix: tạo expression index CREATE INDEX ON users(LOWER(email)), hoặc viết lại query để tránh function trên column.
  • Type cast mismatch: WHERE id = '12345' (string literal) hoặc WHERE id::text = '12345'. Fix: đảm bảo literal đúng type — WHERE id = 12345 (integer).
  • Statistics lỗi thời: sau bulk insert hoặc tạo index mới mà chưa ANALYZE, planner có row count estimate sai — chọn plan sai. Fix: ANALYZE table_name ngay sau thao tác bulk, hoặc để autovacuum chạy.
Q4
Over-index 7 index trên một bảng — write 7 lần chậm hơn. Khi nào tradeoff này đáng chấp nhận?

Tradeoff write overhead đáng chấp nhận khi bảng là read-heavy với tỉ lệ read/write cao. Ví dụ: bảng products trong e-commerce — hàng triệu user read mỗi giờ, nhưng catalog chỉ update vài lần mỗi ngày. Ở đây, 7 index làm chậm write vài giây mỗi lần catalog update — không đáng kể so với lợi ích query nhanh cho triệu user.

Ngược lại, bảng write-heavy như logs, events, metrics — insert liên tục hàng nghìn row mỗi giây — mỗi index thêm tạo bottleneck rõ rệt. Ở đây tối thiểu hóa index, dùng BRIN thay B-tree cho timestamp column (Module 5 bài 4 của khoá này), hoặc batch insert vào partition riêng.

Nguyên tắc: chạy pg_stat_user_indexes sau khi deploy đủ lâu để thấy idx_scan thực tế. Index có idx_scan = 0 sau vài tuần production = candidate để drop.

Q5
TaskFlow thêm index (assignee_id) nhưng dashboard query từ Module 2 bài 7 của khoá này vẫn 250ms — EXPLAIN show Index Scan, không phải Index Only Scan. Vấn đề gì? Cách fix?

Index (assignee_id) chỉ cover filter column. Query SELECT cần thêm title, project_id, due_at, status — những column này không có trong index, nên planner bắt buộc fetch heap cho mỗi row khớp. Với 412 row của assignee_id=5, đó là 412 random heap fetch — 250ms là kết quả của random I/O lặp đi lặp lại.

Fix cấp 1 — composite index giảm số row fetch heap: CREATE INDEX ON tasks(assignee_id, status, due_at). Planner lọc status IN ('todo','doing')due_at BETWEEN ngay trong index, chỉ fetch heap cho row thực sự match — giảm random I/O đáng kể.

Fix cấp 2 — covering index loại bỏ heap fetch hoàn toàn: CREATE INDEX ON tasks(assignee_id, status, due_at) INCLUDE (project_id, title). Mọi column SELECT đều có trong index → Index Only Scan, target 1ms. Module 5 bài 3 của khoá này giải thích thứ tự column trong composite index và tại sao (assignee_id, status, due_at) tốt hơn các thứ tự khác.

Bài tiếp theo: B-tree internals — page split, fill factor, height log_branching

Bài này có giúp bạn hiểu bản chất không?