PostgreSQL — Schema, Index & Internals/Composite index ordering — leftmost prefix rule + INCLUDE covering
3/16
Bài 3 / 16~22 phútIndexing internalsMiễn phí lượt xem

Composite index ordering — leftmost prefix rule + INCLUDE covering

(a, b, c) index không help WHERE b=x. Equality column trước range. INCLUDE cho Index Only Scan. Decision matrix theo query pattern.

Team TaskFlow tạo composite index để tăng tốc dashboard:

CREATE INDEX ON tasks(project_id, status, due_at);

Một tuần sau, dev thêm query mới:

SELECT * FROM tasks WHERE status = 'done';

EXPLAIN báo Seq Scan — index không được dùng. Tại sao? Index (project_id, status, due_at) sort theo project_id trước, status bên trong mỗi nhóm project_id, due_at bên trong mỗi nhóm (project_id, status). Query WHERE status = 'done' bỏ qua project_id — giống tra mục lục sách mà không biết chương nào, phải đọc toàn bộ. Không deterministic, không dùng được index.

Bài này giải thích leftmost prefix rule, 3 query pattern equality vs range, INCLUDE covering cho Index Only Scan, và decision matrix chọn thứ tự column theo workload thực tế.

1. Analogy — Mục lục đa cấp

Hình dung một cuốn sách kỹ thuật có mục lục 3 cấp: chủ đề → tiêu đề con → ngày xuất bản. Mục lục được sắp xếp theo thứ tự đó.

Muốn tìm tài liệu ngày 2026-05-01 mà không biết chủ đề hay tiêu đề con — bạn phải đọc toàn bộ mục lục vì ngày được sort trong từng nhóm (chủ đề, tiêu đề con), không có thứ tự toàn cục. Nhưng nếu biết chủ đề là "Database" — bạn đến đúng section, rồi tìm tiếp theo tiêu đề con hoặc ngày.

Mục lục đa cấpComposite index (a, b, c)
Cấp 1: chủ đềColumn a — sort key ngoài cùng
Cấp 2: tiêu đề con (trong chủ đề)Column b — sort trong group a
Cấp 3: ngày (trong tiêu đề con)Column c — sort trong group (a, b)
Biết chủ đề → tìm nhanhWHERE a = X → B-tree navigate đến subtree
Biết ngày, không biết chủ đềWHERE c = X → đọc toàn mục lục
💡 Cách nhớ

Composite index sort theo column 1 trước, column 2 trong group column 1, column 3 trong group (1, 2). Bỏ qua column 1 = không biết bắt đầu từ đâu trong mục lục — database đọc toàn bộ.

2. Composite index — cơ chế sort bên trong B-tree

CREATE INDEX idx_tasks_psd ON tasks(project_id, status, due_at);

B-tree lưu entry theo thứ tự từ điển trên tất cả 3 column:

-- Entry order (sort by all 3 columns lexicographically):
(1, 'doing', 2026-05-01)
(1, 'doing', 2026-05-03)
(1, 'done',  2026-04-28)
(1, 'todo',  2026-05-02)
(2, 'doing', 2026-05-01)
(2, 'done',  2026-05-05)
(2, 'todo',  2026-04-30)
...

Tree navigate: B-tree tìm project_id = 1 đến subtree project 1, rồi trong đó tìm status = 'done' đến sub-subtree, rồi trong đó range scan due_at. Mỗi bước thu hẹp phạm vi đọc.

Nếu bỏ qua project_id — B-tree không biết subtree nào chứa status = 'done' (mỗi project đều có status = 'done', phân tán khắp tree). Planner phải đọc toàn index — tốn kém như Seq Scan, nên planner chọn Seq Scan thẳng.

3. Leftmost prefix rule — query nào dùng được index

-- INDEX (project_id, status, due_at)

-- DUNG INDEX:
SELECT * FROM tasks WHERE project_id = 5;
-- prefix col 1 -> B-tree navigate den subtree project=5

SELECT * FROM tasks WHERE project_id = 5 AND status = 'done';
-- prefix col 1+2 -> subtree project=5, sub-subtree status='done'

SELECT * FROM tasks WHERE project_id = 5 AND status = 'done'
  AND due_at > '2026-05-01';
-- full 3 column -> narrow xuong range due_at

-- KHONG DUNG INDEX (skip col 1):
SELECT * FROM tasks WHERE status = 'done';
-- skip col 1 -> khong xac dinh duoc subtree nao

SELECT * FROM tasks WHERE due_at > '2026-05-01';
-- skip col 1 + col 2 -> khong navigate duoc

SELECT * FROM tasks WHERE status = 'done' AND due_at > '2026-05-01';
-- skip col 1 -> van khong dung duoc du co col 2+3

Quy tắc: WHERE phải bao gồm column 1 (leftmost prefix). Có thể thêm column 2, column 3 theo thứ tự liên tục. Nhảy qua column 1 thì index không dùng được — không phụ thuộc vào thứ tự viết trong WHERE clause, chỉ phụ thuộc vào cấu trúc index.

4. Ba pattern equality vs range — thứ tự column tối ưu

PatternThứ tự column tốt nhất
Tất cả equality WHERE a=X AND b=Y AND c=ZMọi thứ tự đều OK — B-tree navigate thẳng đến điểm
Equality + range WHERE a=X AND b BETWEEN Y AND Z(a, b) — equality trước, range sau
Multi-range WHERE a BETWEEN X AND Y AND b BETWEEN ...Chỉ range đầu tiên dùng được index — PG B-tree limitation

Quy tắc thumb: đặt equality column trước range column. Range column "ngắt" khả năng navigate tiếp — B-tree chỉ dùng range scan từ column range trở đi, các column sau không còn selective nữa.

-- DUNG INDEX OPTIMAL: equality truoc, range sau
CREATE INDEX ON tasks(status, due_at);

SELECT * FROM tasks WHERE status = 'done' AND due_at > '2026-05-01';
-- Plan: Index Scan
-- B-tree narrow den status='done' subtree, range scan due_at trong subtree do

-- KHONG OPTIMAL: range truoc, equality sau
CREATE INDEX ON tasks(due_at, status);

SELECT * FROM tasks WHERE status = 'done' AND due_at > '2026-05-01';
-- Plan: Index Scan (range tren due_at) + Filter (status='done')
-- Doc nhieu row hon vi status filter khong dung duoc index sau range column

Ví dụ thực tế TaskFlow — query "task cần làm trong 3 ngày tới":

-- Composite (assignee_id, status, due_at): equality assignee + status, range due_at
CREATE INDEX ON tasks(assignee_id, status, due_at);

SELECT * FROM tasks
WHERE assignee_id = 5
  AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days';
-- B-tree: assignee_id=5 subtree -> status IN subtree -> range scan due_at
-- Optimal: filter thu hep tu trai sang phai

5. INCLUDE covering — Index Only Scan

Composite index chỉ chứa các sort column. Nếu SELECT cần thêm column không trong index, planner phải fetch heap cho mỗi row match — Index Scan thay vì Index Only Scan.

-- Without INCLUDE: heap fetch sau index scan
CREATE INDEX ON tasks(status, due_at);

SELECT id, title FROM tasks WHERE status = 'done' AND due_at > '2026-05-01';
-- Plan: Index Scan -> Heap Fetch (id, title khong co trong index)
-- Moi row match can 1 random heap I/O
-- With INCLUDE: cover moi column SELECT
CREATE INDEX ON tasks(status, due_at) INCLUDE (id, title);

SELECT id, title FROM tasks WHERE status = 'done' AND due_at > '2026-05-01';
-- Plan: Index Only Scan (no heap fetch neu visibility map allow)
-- Toan bo data lay tu index leaf, khong can mo heap page

INCLUDE column có 3 đặc điểm quan trọng:

  • Không tham gia sort — không ảnh hưởng tree structure, không dùng được trong WHERE.
  • Stored at leaf — lưu cùng leaf entry, fetch không cần I/O thêm.
  • PG 11+ feature — không có trong PG 10 trở về trước.

Tradeoff: index size lớn hơn vì mang thêm column data. Đổi lại: read query bỏ qua random heap I/O — đặc biệt quan trọng khi heap lớn hoặc cold (buffer cache miss thường xuyên).

6. Decision matrix — chọn thứ tự column

Query: WHERE col1 = X [AND col2 OP Y [AND col3 OP Z]]

Heuristic chon thu tu column:
1. Equality column truoc range column
2. Trong equality column: high cardinality truoc
   (narrow subtree som, it row can scan o level sau)
3. Range column o cuoi sort key
4. ORDER BY column cuoi neu trung range column -> planner skip sort step
5. SELECT-only column -> INCLUDE (khong sort, chi stored at leaf)

Áp dụng cho TaskFlow dashboard query:

-- Query: dashboard assignee
SELECT id, title 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;

-- Equality: assignee_id (cardinality cao: nhieu assignee)
--           status (cardinality thap: 3 gia tri)
-- Range:    due_at
-- ORDER BY: due_at (cung range column -> bonus: planner skip sort)
-- SELECT:   id, title -> INCLUDE

-- Optimal index:
CREATE INDEX idx_tasks_dashboard
  ON tasks(assignee_id, status, due_at)
  INCLUDE (id, title);

Giải thích từng quyết định:

  • assignee_id trước status: cardinality cao hơn (nhiều assignee vs 3 status value) → narrow subtree sớm hơn.
  • status trước due_at: equality trước range.
  • due_at là range column → cuối; trùng ORDER BY → planner không cần Sort node.
  • id, title vào INCLUDE → Index Only Scan, không fetch heap.

7. Pitfall — column order khác nhau phá vỡ một trong nhiều query

Pitfall — 1 index không phục vụ được 2 query có leftmost prefix khác nhau

Khi workload có 2 query với WHERE column bắt đầu khác nhau, một composite index chỉ cover được 1 trong 2. Không thể dùng 1 index với thứ tự cố định cho cả 2 nếu chúng cần leftmost prefix khác nhau.

-- Q1: dashboard assignee
-- WHERE assignee_id = 5 AND status = 'done'

-- Q2: global status report (common)
-- WHERE status = 'done'

-- Index (assignee_id, status): Q1 OK, Q2 skip col 1 -> Seq Scan
CREATE INDEX ON tasks(assignee_id, status);

-- Option A: doi thu tu -> Q2 OK nhung Q1 van OK (prefix status)
CREATE INDEX ON tasks(status, assignee_id);
-- Q1: WHERE assignee_id=5 AND status='done'
--     -> prefix status (col 1), narrow them assignee_id (col 2) -> OK
-- Q2: WHERE status='done'
--     -> prefix status (col 1) -> OK

-- Option B: tach 2 index rieng
CREATE INDEX ON tasks(assignee_id, status);  -- Q1
CREATE INDEX ON tasks(status);               -- Q2 dedicated
-- Tradeoff: write cost tang them 1 index, nhung moi query co index toi uu rieng

Tradeoff hai option: đổi thứ tự tiết kiệm 1 index nhưng Q1 mất lợi thế narrow assignee_id sớm. Tách 2 index tốt cho cả hai query nhưng tăng write cost — Module 5 bài 1 của khoá này giải thích write cost per index.

8. PG-specific — index skip scan

PostgreSQL chỉ có index skip scan cho B-tree từ bản 18 (2025). Trên PG 17 trở về trước, query bỏ qua column 1 luôn dẫn đến Seq Scan hoặc cần index riêng — nếu cluster còn chạy bản cũ thì workaround dưới đây vẫn là cách xử lý chính.

-- Index (a, b)
-- Query WHERE b = X (skip a) -> Seq Scan tren PG <= 17

-- Co che skip scan: DB tu dong enumerate distinct gia tri cua a,
-- chay scan cho moi gia tri. Oracle co tu lau, MySQL co tu 8.0.13,
-- PostgreSQL co tu PG 18.

-- Workaround cho PG <= 17: tao index thu 2
CREATE INDEX ON tasks(status);  -- serve WHERE status = X

Một workaround khác là "loose index scan" qua recursive CTE — nhưng phức tạp và thường chậm hơn index riêng. Forward: Module 8 của khoá này giải thích recursive CTE pattern.

9. Applied — TaskFlow dashboard: iterate 4 version index

Dashboard query từ Module 2 bài 7 của khoá này:

SELECT id, title 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 NULLS LAST, id LIMIT 20;

Iterate 4 version index từ naive đến optimal:

-- v1: single column - poor selectivity
CREATE INDEX ON tasks(assignee_id);
-- Plan: Index Scan (lots of rows for assignee=5) + Filter status + Filter due_at + Sort
-- Estimated: ~200ms (filter hang ngan row tren heap)

-- v2: composite assignee + status
CREATE INDEX ON tasks(assignee_id, status);
-- Plan: Index Scan narrow assignee+status, then Filter due_at + Sort due_at
-- Estimated: ~30ms (it row hom nhung van can sort)

-- v3: composite full (equality + range, ORDER BY aligned)
CREATE INDEX ON tasks(assignee_id, status, due_at);
-- Plan: Index Scan narrow all 3, Sort skipped (due_at sorted in index)
-- Estimated: ~5ms (no sort step)

-- v4: voi INCLUDE - Index Only Scan
CREATE INDEX ON tasks(assignee_id, status, due_at) INCLUDE (id, title);
-- Plan: Index Only Scan - no heap fetch, sort skipped
-- Estimated: ~1ms (pure index read)

Module 5 bài 5 của khoá này sẽ measure 4 version với EXPLAIN ANALYZE thực tế và so sánh Buffers, actual time, Heap Fetches.

10. Deep Dive — Composite index

📚 Deep Dive — Composite index

Ghi chú: Use The Index Luke cho intuition + visual. PG docs 11.3 cho precise rules và planner behavior. PG docs 11.9 cho INCLUDE + visibility map — đọc khi cần hiểu tại sao Index Only Scan đôi khi vẫn có Heap Fetches khác 0.

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

12. Tóm tắt

  • Composite (a, b, c) sort theo ab trong group ac trong group (a, b).
  • Leftmost prefix rule: WHERE phải bao gồm column 1; có thể thêm column 2, 3 liên tục — bỏ qua column 1 thì index không dùng được.
  • Equality column đặt trước range column trong index — range column "ngắt" tree navigation.
  • INCLUDE column stored at leaf, không tham gia sort — cover SELECT column cho Index Only Scan (PG 11+).
  • ORDER BY trùng range column cuối → planner skip Sort node.
  • 2 query có leftmost prefix khác nhau → cần 2 index hoặc đổi thứ tự column với tradeoff rõ ràng.
  • PG không có index skip scan native — workaround: tạo index thứ 2 với column order phù hợp.
  • Forward: Module 5 bài 5 của khoá này (mini-challenge measure 4 index version với EXPLAIN thực tế), Module 7 của khoá này (EXPLAIN deep dive — cost model, planner statistics).

13. Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Vì sao composite (a, b, c) không help WHERE c = X? Giải thích theo cơ chế B-tree navigation.
  2. Q2
    Phân biệt khi nào đặt equality column trước vs range column trước. Cho ví dụ TaskFlow cho mỗi trường hợp.
  3. Q3
    INCLUDE vs thêm column vào sort key — khác biệt gì? Khi nào dùng cái nào?
  4. Q4
    Có 2 query thường xuyên: Q1 — WHERE a=X AND b=Y, và Q2 — WHERE b=Y riêng. Dùng 1 composite index hay 2 index riêng — tradeoff?
  5. Q5
    ORDER BY due_at + composite index (assignee_id, status, due_at) — planner skip sort. Cơ chế gì cho phép điều đó?
  6. Q6
    PG không có index skip scan native. Nếu query WHERE status = 'done' thường xuyên nhưng index chính là (assignee_id, status), workaround tốt nhất là gì?

Bài tiếp theo: Specialized GIN/BRIN/partial/expression index

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

GIN/BRIN/partial/expression — chọn index theo data type và workload