PostgreSQL — Schema, Index & Internals/Mini-challenge M05 — TaskFlow dashboard 2.1s → 50ms qua index strategy
5/16
Bài 5 / 16~30 phútIndexing internalsMiễn phí 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
0/4 câu đã trả lời
  1. 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.
  2. 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.
  3. 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?
  4. 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.

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

Đặt 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

Bài tiếp theo

MVCC internals — xmin, xmax, snapshot, và vì sao SELECT không block UPDATE