Mini-challenge M05 — TaskFlow dashboard 2.1s → 50ms qua index strategy
Setup 1M task dataset. EXPLAIN baseline 2.1s. Iterate 4 index version: single → composite → INCLUDE → partial. Measure ratio + Index Only Scan.
Dashboard TaskFlow từ M02.7 ban đầu chạy 2.1s trên 1M task dataset. Mục tiêu dưới 50ms — tức 40x speedup. Bài này không dạy concept mới — bài này build từng bước: seed 1M row, chạy EXPLAIN ANALYZE baseline, iterate 4 version index (single column → composite → INCLUDE → partial), đo ratio sau mỗi bước. Áp dụng trực tiếp M05.1 đến M05.4 vào use case thực chiến.
1. Spec query target
Query dashboard hiển thị task active của một assignee, due trong 3 ngày tới, paginate 20:
-- Dashboard query (tu M02.7)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = $1
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
Goal: 2100ms xuống dưới 50ms. Ba điều kiện filter (assignee_id, status, due_at) + ORDER BY + LIMIT là bộ đặc trưng để thiết kế composite index.
2. Step 1 — Setup 1M task dataset
Truncate và seed lại bảng với 1 triệu row phân bổ realistic:
-- Truncate va re-seed
TRUNCATE tasks RESTART IDENTITY CASCADE;
-- Generate 1M task realistic
INSERT INTO tasks (project_id, assignee_id, title, status, due_at, created_at, updated_at)
SELECT
(random() * 4 + 1)::int, -- 5 project (1-5)
(random() * 9 + 1)::int, -- 10 assignee (1-10)
'Task ' || g,
(ARRAY['todo', 'doing', 'done', 'archived'])[1 + (random() * 3)::int],
CASE WHEN random() < 0.7
THEN now() + (random() * 30 - 15) * INTERVAL '1 day'
ELSE NULL
END,
now() - (random() * 180) * INTERVAL '1 day',
now() - (random() * 30) * INTERVAL '1 day'
FROM generate_series(1, 1000000) g;
-- Update statistics
ANALYZE tasks;
SELECT COUNT(*) FROM tasks; -- expected: 1000000
Phân bố dữ liệu sau seed:
- 10 assignee → mỗi user ~100k row
- 4 status → todo/doing ~50% (500k row)
- due_at NULL ~30%, còn lại random trong 30 ngày → match cửa sổ 3 ngày khoảng 7%
3. Step 2 — EXPLAIN baseline (không có index ngoài PK)
Bật timing và chạy EXPLAIN với ANALYZE + BUFFERS để thấy I/O thực:
\timing on
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = 5
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
Output mong đợi (tham khảo — số thực tế dao động):
Limit (cost=... rows=20 width=...)
-> Sort (cost=...)
Sort Key: due_at NULLS LAST, id
-> Seq Scan on tasks (cost=... rows=...)
Filter: ((assignee_id = 5) AND
(status = ANY ('{todo,doing}')) AND
(due_at >= now()) AND
(due_at <= now() + interval '3 days'))
Rows Removed by Filter: ~990000
Planning Time: 0.5 ms
Execution Time: 2100.00 ms
Phân tích:
- Seq Scan toàn bộ 1M row — không có index nào để dùng
- Filter loại bỏ 99%: 10 user (10x), ~50% match status, ~7% match due range → còn ~3500 row
- Sort 3500 row matching trước LIMIT 20
- Tổng 2.1s — CPU + I/O đọc toàn bộ bảng
4. Step 3 — v1: Single column index trên assignee_id
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = 5
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
Output mong đợi:
Limit (cost=...)
-> Sort (cost=...)
Sort Key: due_at NULLS LAST, id
-> Bitmap Heap Scan on tasks (cost=...)
Recheck Cond: (assignee_id = 5)
Filter: ((status = ANY ('{todo,doing}')) AND ...)
-> Bitmap Index Scan on idx_tasks_assignee
Index Cond: (assignee_id = 5)
Execution Time: ~250.00 ms
10x improvement (2100ms xuống 250ms). Index narrow xuống 100k row của assignee=5 — tránh scan 900k row kia. Nhưng vẫn phải fetch heap cho 100k row đó, áp filter status và due_at trên heap, rồi sort. Cần composite để push filter vào index.
5. Step 4 — v2: Composite index (assignee_id, status, due_at)
DROP INDEX idx_tasks_assignee;
CREATE INDEX idx_tasks_dashboard_v2 ON tasks(assignee_id, status, due_at);
ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = 5
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
Output mong đợi:
Limit
-> Index Scan using idx_tasks_dashboard_v2 on tasks
Index Cond: ((assignee_id = 5) AND
(status = ANY ('{todo,doing}')) AND
(due_at >= now()) AND
(due_at <= now() + interval '3 days'))
Execution Time: ~5.00 ms
420x improvement (2100ms xuống 5ms). Ba điều kiện filter đều đẩy vào index — index trả về khoảng 3500 row matching. Không cần Sort bước riêng vì index đã sắp xếp theo due_at. Nhưng vẫn fetch heap để lấy id và title — hai column không có trong index.
Lý do column order (assignee_id, status, due_at) đúng:
assignee_idequality trước — cardinality cao (10 user), thu hẹp nhanh nhấtstatusequality tiếp theo — cardinality thấp nhưng filter mạnh (loại 50%)due_atrange cuối — range column phải đặt cuối để index traverse hiệu quả (M05.3)
6. Step 5 — v3: INCLUDE covering — Index Only Scan
Thêm id và title vào INCLUDE để cover toàn bộ SELECT list:
DROP INDEX idx_tasks_dashboard_v2;
CREATE INDEX idx_tasks_dashboard_v3
ON tasks(assignee_id, status, due_at)
INCLUDE (id, title);
-- Update visibility map (can thiet cho Index Only Scan)
VACUUM ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = 5
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
Output mong đợi:
Limit
-> Index Only Scan using idx_tasks_dashboard_v3 on tasks
Index Cond: ((assignee_id = 5) AND
(status = ANY ('{todo,doing}')) AND
(due_at >= now()) AND
(due_at <= now() + interval '3 days'))
Heap Fetches: 0
Execution Time: ~2.00 ms
1050x improvement. Heap Fetches: 0 — toàn bộ data cần thiết (id, title, status, due_at) đều có trong index leaf page, bỏ qua heap hoàn toàn. I/O giảm cực mạnh khi table lớn không fit vào RAM.
7. Step 6 — v4: Partial index — tiết kiệm storage
Index v3 index toàn bộ 1M row kể cả done và archived — những row đó không bao giờ xuất hiện trong dashboard query. Partial index loại bỏ chúng:
DROP INDEX idx_tasks_dashboard_v3;
CREATE INDEX idx_tasks_dashboard_v4
ON tasks(assignee_id, due_at)
INCLUDE (id, title)
WHERE status IN ('todo', 'doing');
VACUUM ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = 5
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
Output mong đợi:
Limit
-> Index Only Scan using idx_tasks_dashboard_v4 on tasks
Index Cond: ((assignee_id = 5) AND
(due_at >= now()) AND
(due_at <= now() + interval '3 days'))
Filter: (status = ANY ('{todo,doing}'))
Heap Fetches: 0
Execution Time: ~1.50 ms
Lưu ý: planner vẫn show Filter: status dù partial index chỉ chứa row thỏa status IN ('todo', 'doing') — PG giữ verify step để đảm bảo correctness. Nhưng filter này không tốn I/O heap.
So sánh index size sau partial:
-- Kiem tra size
SELECT pg_size_pretty(pg_relation_size('idx_tasks_dashboard_v3'));
-- ~80 MB (full index -- index ca 1M row)
SELECT pg_size_pretty(pg_relation_size('idx_tasks_dashboard_v4'));
-- ~25 MB (partial -- chi ~500k row co status todo/doing)
Partial index nhỏ hơn 70% — fit vào buffer cache tốt hơn, maintenance (INSERT/UPDATE) nhanh hơn vì bỏ qua row done/archived.
8. Bảng so sánh kết quả
| Version | Index | Execution time | Ratio | Index size | Note |
|---|---|---|---|---|---|
| Baseline | (none) | 2100 ms | 1x | — | Seq Scan toàn bo |
| v1 | (assignee_id) | 250 ms | 8.4x | ~22 MB | Bitmap Heap Scan |
| v2 | (assignee_id, status, due_at) | 5 ms | 420x | ~50 MB | Index Scan, bo Sort |
| v3 | v2 + INCLUDE (id, title) | 2 ms | 1050x | ~80 MB | Index Only Scan |
| v4 | v3 + WHERE partial | 1.5 ms | 1400x | ~25 MB | Nho hon, cung speed |
Goal 50ms đạt được tại v2 (5ms). v3 và v4 là incremental gains — có giá trị ở production scale khi table tiếp tục tăng trưởng.
9. Pitfall — VACUUM quan trọng cho Index Only Scan
Index Only Scan bỏ qua heap — nhưng chỉ khi visibility map xác nhận page đó đã visible (toàn bộ row trên page committed). Sau UPDATE/DELETE lớn mà chưa VACUUM, nhiều page chưa update visibility map → PG phải fetch heap để verify → Heap Fetches tăng cao → hiệu năng giảm về gần Index Scan.
Demo tình huống visibility map cũ:
-- Simulate UPDATE lon -- lam visibility map outdated
UPDATE tasks SET status = 'doing' WHERE status = 'todo' LIMIT 1000;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, status, due_at,
EXTRACT(DAY FROM (due_at - now())) AS days_to_due
FROM tasks
WHERE assignee_id = 5
AND status IN ('todo', 'doing')
AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at ASC NULLS LAST, id
LIMIT 20;
-- Index Only Scan
-- Heap Fetches: 50 -- co heap fetch (visibility map stale cho ~50 page)
-- Fix: chay VACUUM de refresh visibility map
VACUUM ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS) -- chay lai cung query
-- Heap Fetches: 0 -- visibility map fresh, skip heap hoan toan
Production: autovacuum thường xử lý đủ nếu workload bình thường. Workload heavy UPDATE (như batch processing) → cân nhắc tune autovacuum_vacuum_scale_factor hoặc schedule VACUUM ANALYZE thủ công sau batch. Module 11 của khoá này bàn autovacuum tuning chi tiết.
10. Hướng dẫn production — quyết định index strategy
| Quyết định | Recommendation |
|---|---|
| Thêm index vì query có vẻ chậm | Đo trước bằng pg_stat_statements — không add blind (Module 11 của khoá này) |
| Composite column order | Equality trước range; column cardinality cao trước trong equality group |
| Khi nào dùng INCLUDE | Khi Index Only Scan worth tradeoff storage — SELECT list nhỏ, table lớn |
| Khi nào dùng partial | Filter phổ biến trong query + subset row chiếm dưới 50% — storage save đáng kể |
| Drop index không dùng | SELECT * FROM pg_stat_user_indexes WHERE idx_scan = 0 sau 30 ngày |
| REINDEX production | Phát hiện bloat qua Module 11 của khoá này — pg_repack zero-downtime |
Thứ tự column trong composite index: equality columns trước, range column cuối. Trong equality columns: cardinality cao trước nếu filter strength ngang nhau. Composite key (assignee_id, status, due_at) đúng vì assignee_id equality thu hẹp nhanh nhất, status equality tiếp, due_at range đặt cuối.
11. Deliverable
Tạo 1 file dashboard_tuning.sql với:
-- ==============================================
-- Section 1: Setup 1M row seed
-- ==============================================
TRUNCATE tasks RESTART IDENTITY CASCADE;
-- ... (seed script tu Step 1)
ANALYZE tasks;
-- ==============================================
-- Section 2: Baseline EXPLAIN (no index)
-- ==============================================
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
-- Record: Seq Scan, Execution Time ~2100ms
-- ==============================================
-- Section 3: v1 -- single column
-- ==============================================
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
-- Record: Bitmap Heap Scan, ~250ms
-- ==============================================
-- Section 4: v2 -- composite
-- ==============================================
DROP INDEX idx_tasks_assignee;
CREATE INDEX idx_tasks_dashboard_v2 ON tasks(assignee_id, status, due_at);
ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
-- Record: Index Scan, ~5ms
-- ==============================================
-- Section 5: v3 -- INCLUDE covering
-- ==============================================
DROP INDEX idx_tasks_dashboard_v2;
CREATE INDEX idx_tasks_dashboard_v3
ON tasks(assignee_id, status, due_at) INCLUDE (id, title);
VACUUM ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
-- Record: Index Only Scan, Heap Fetches 0, ~2ms
-- ==============================================
-- Section 6: v4 -- partial index
-- ==============================================
DROP INDEX idx_tasks_dashboard_v3;
CREATE INDEX idx_tasks_dashboard_v4
ON tasks(assignee_id, due_at) INCLUDE (id, title)
WHERE status IN ('todo', 'doing');
VACUUM ANALYZE tasks;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
-- Record: Index Only Scan, ~1.5ms
-- ==============================================
-- Section 7: Index size comparison
-- ==============================================
SELECT pg_size_pretty(pg_relation_size('idx_tasks_dashboard_v4'));
-- ==============================================
-- Section 8: Cleanup
-- ==============================================
DROP INDEX IF EXISTS idx_tasks_dashboard_v4;
TRUNCATE tasks RESTART IDENTITY CASCADE;
Kèm 1 short note (comment cuối file) ghi lại:
- Insight nào bất ngờ nhất (vd: composite 420x vs single 8x — gap lớn hơn dự kiến)
- Forward link sang Module 7 của khoá này để đọc EXPLAIN cost model chi tiết hơn
12. Forward
| Topic | Module |
|---|---|
| EXPLAIN deep — cost model, scan strategy selection | Module 7 của khoá này |
pg_stat_statements identify slow query production | Module 11 của khoá này |
pg_repack zero-downtime REINDEX — bloat management | Module 11 của khoá này |
| Capstone — TaskFlow full analytics platform | Module 12 của khoá này |
13. Tóm tắt
- Iterate 4 version: single column (8x) → composite (420x) → INCLUDE covering (1050x) → partial (1400x)
- Goal 50ms đạt được tại v2 (5ms); v3 và v4 là incremental gains quan trọng cho production scale
- Composite column order phải đúng: equality trước, range cuối — sai thứ tự → planner không dùng index optimal
- Index Only Scan cần
Heap Fetches: 0— phụ thuộc visibility map cập nhật, VACUUM sau UPDATE lớn là bắt buộc - Partial index tiết kiệm 70% storage với cùng performance — worth khi filter phổ biến + subset nhỏ
- Production rule: đo trước bằng
pg_stat_statements(Module 11 của khoá này) trước khi thêm index - Forward Module 7 của khoá này (EXPLAIN cost model), Module 11 của khoá này (index lifecycle), Module 12 của khoá này (capstone)
14. Tự kiểm tra
- Q1Vì sao single column index (assignee_id) chỉ cải thiện 8x trong khi composite (assignee_id, status, due_at) cải thiện 420x? Giải thích cơ chế filter chain.
- Q2Index Only Scan cần Heap Fetches = 0 để đạt hiệu năng tối đa. Vì sao VACUUM ảnh hưởng đến điều này? Giải thích cơ chế visibility map.
- Q3Partial index v4 nhỏ hơn v3 khoảng 70% nhưng đạt cùng hiệu năng. Khi nào đáng dùng partial thay full index? Khi nào không đáng?
- Q4Bạn có 5 query khác nhau trên cùng bảng tasks với các WHERE clause khác nhau. Thiết kế 1 composite index đủ rộng cover cả 5, hay 5 index riêng? Phân tích tradeoff write cost.
Bài tiếp theo: Module 6 — Transactions, ACID & MVCC
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên