SQL & Database — Thực chiến PostgreSQL/Mini-challenge M05 — TaskFlow dashboard 2.1s → 50ms qua index strategy
~30 phútIndexing internals lượt xem

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 idtitle — hai column không có trong index.

Lý do column order (assignee_id, status, due_at) đúng:

  • assignee_id equality trước — cardinality cao (10 user), thu hẹp nhanh nhất
  • status equality tiếp theo — cardinality thấp nhưng filter mạnh (loại 50%)
  • due_at range 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 idtitle 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ả donearchived — 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ả

VersionIndexExecution timeRatioIndex sizeNote
Baseline(none)2100 ms1xSeq Scan toàn bo
v1(assignee_id)250 ms8.4x~22 MBBitmap Heap Scan
v2(assignee_id, status, due_at)5 ms420x~50 MBIndex Scan, bo Sort
v3v2 + INCLUDE (id, title)2 ms1050x~80 MBIndex Only Scan
v4v3 + WHERE partial1.5 ms1400x~25 MBNho 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

Pitfall — Index Only Scan cần visibility map cập nhật

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 địnhRecommendation
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 orderEquality trước range; column cardinality cao trước trong equality group
Khi nào dùng INCLUDEKhi Index Only Scan worth tradeoff storage — SELECT list nhỏ, table lớn
Khi nào dùng partialFilter phổ biến trong query + subset row chiếm dưới 50% — storage save đáng kể
Drop index không dùngSELECT * FROM pg_stat_user_indexes WHERE idx_scan = 0 sau 30 ngày
REINDEX productionPhát hiện bloat qua Module 11 của khoá này — pg_repack zero-downtime
💡 Rule of thumb composite index

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

TopicModule
EXPLAIN deep — cost model, scan strategy selectionModule 7 của khoá này
pg_stat_statements identify slow query productionModule 11 của khoá này
pg_repack zero-downtime REINDEX — bloat managementModule 11 của khoá này
Capstone — TaskFlow full analytics platformModule 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

Tự kiểm tra
Q1
Vì 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.

Single column index (assignee_id) chỉ narrow xuống 100k row của assignee=5 — bằng 1/10 bảng. Sau đó PostgreSQL phải fetch heap cho toàn bộ 100k row đó để áp filter statusdue_at, rồi sort kết quả. I/O heap 100k row vẫn tốn thời gian đáng kể.

Composite index (assignee_id, status, due_at) đẩy cả ba điều kiện vào index — planner traverse index theo thứ tự: assignee=5 (100k) → status IN (50k) → due_at range (3500 row). Chỉ ~3500 row thực sự matching mới cần fetch heap. Từ 100k xuống 3500 là 28x ít heap fetch hơn. Thêm nữa, index đã sắp xếp theo due_at nên bỏ qua Sort bước riêng. Tổng hợp hai yếu tố tạo ra 420x so với baseline.

Q2
Index 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.

PostgreSQL dùng visibility map — một bitmap song song với heap, mỗi bit tương ứng một page. Bit được set khi toàn bộ row trên page đó đã committed và visible với mọi transaction. Index Only Scan dùng visibility map để quyết định có cần fetch heap không: nếu page đã marked visible → skip heap hoàn toàn; nếu chưa → phải fetch heap để verify row visibility.

Sau UPDATE hoặc DELETE, dead version row xuất hiện trên page → page không còn "all visible" → bit bị clear. VACUUM chạy qua, dọn dead tuple, đánh dấu lại page là all-visible → bit được set lại. Trước khi VACUUM chạy, Index Only Scan phải fetch heap cho từng page chưa all-visible → Heap Fetches tăng → hiệu năng giảm về gần Index Scan thường. Production: autovacuum xử lý nếu workload bình thường; sau batch UPDATE lớn cần VACUUM thủ công nếu cần Index Only Scan hiệu quả ngay.

Q3
Partial 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?

Đáng dùng partial khi:

  • Điều kiện WHERE của partial index xuất hiện trong phần lớn query thực tế (vd WHERE status IN ('todo', 'doing') là dashboard query phổ biến nhất)
  • Subset row thỏa điều kiện chiếm tỷ lệ nhỏ — dưới 50%, lý tưởng 20–30%. Storage save thực sự đáng kể → index fit cache tốt hơn
  • Table lớn và write heavy — partial index maintenance chỉ update khi row thỏa WHERE clause thay đổi → bỏ qua row done/archived khi chúng UPDATE

Không đáng khi:

  • Query không luôn include điều kiện matching — planner không thể dùng partial index cho query không có WHERE clause tương ứng → phải maintain full index song song
  • Subset quá nhỏ (dưới 5%) hoặc quá lớn (trên 70%) — storage gain không đủ offset thêm complexity quản lý
  • Điều kiện partial thay đổi thường xuyên theo business logic — phải DROP và CREATE lại index
Q4
Bạ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.

Không có câu trả lời tuyệt đối — phụ thuộc vào workload đọc/ghi cụ thể:

1 composite index rộng: mỗi INSERT/UPDATE/DELETE chỉ cần update 1 index structure. Write cost thấp hơn. Nhưng composite index chỉ có ích cho query dùng leftmost prefix — query không dùng leftmost prefix không được lợi, planner có thể fallback seq scan.

5 index riêng: mỗi query có index tối ưu riêng → read performance tốt nhất cho từng query. Nhưng mỗi write phải update tối đa 5 index structure → write amplification 5x. Table write heavy (vd 1000 INSERT/s) → 5000 index update/s → WAL volume tăng, lock contention tăng.

Approach thực tế: dùng pg_stat_statements (Module 11 của khoá này) identify 2–3 query tốn thời gian nhất → thiết kế index cho chúng trước. Merge khi possible (shared leftmost prefix). Monitor pg_stat_user_indexes.idx_scan sau 30 ngày — index nào idx_scan = 0 thì drop. Ưu tiên giảm số index hơn là cover mọi query pattern.

Bài tiếp theo: Module 6 — Transactions, ACID & MVCC

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