SQL & Database — Thực chiến PostgreSQL/Composite index ordering — leftmost prefix rule + INCLUDE covering
~22 phútIndexing internals 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 không có index skip scan native. Hệ quả: query bỏ qua column 1 luôn dẫn đến Seq Scan hoặc cần index riêng.

-- Index (a, b)
-- Query WHERE b = X (skip a) -> Seq Scan theo default

-- MySQL va Oracle co "index skip scan" native:
-- DB tu dong enumerate distinct gia tri cua a, chay scan cho moi gia tri
-- PG chua implement (roadmap, khong co ETA chinh thuc tinh den 2026)

-- Workaround PG: 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
Q1
Vì sao composite (a, b, c) không help WHERE c = X? Giải thích theo cơ chế B-tree navigation.

B-tree composite lưu entry theo thứ tự từ điển: sort a trước, rồi b trong group cùng a, rồi c trong group cùng (a, b). Column c không có thứ tự toàn cục — mỗi subtree (a_i, b_j) có range c riêng, phân tán khắp tree.

Khi query WHERE c = X, B-tree không biết navigate đến subtree nào vì không có điểm bắt đầu (không biết ab). Planner phải đọc toàn index để tìm mọi entry có c = X — tốn kém tương đương Seq Scan, nên planner bỏ index và chọn Seq Scan thẳng.

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.

Equality trước range (đúng): query WHERE status = 'done' AND due_at > '2026-05-01' — index (status, due_at). B-tree narrow đến subtree status='done' (equality, chính xác), rồi range scan due_at trong subtree đó. Filter hiệu quả nhất vì equality loại bỏ phần lớn entry trước khi range scan.

Range trước equality (sai): index (due_at, status) cho cùng query. B-tree range scan due_at > '2026-05-01' — có thể trả về lượng lớn row — rồi filter status='done' như Filter node (không dùng index). Đọc nhiều row hơn cần thiết, kém hiệu quả.

Quy tắc: equality column narrow subtree chính xác trước, range column scan phần còn lại. Đổi thứ tự thì range column không còn selective vì equality column sau range không dùng được index.

Q3
INCLUDE vs thêm column vào sort key — khác biệt gì? Khi nào dùng cái nào?

Sort key column (trong danh sách chính): tham gia tree structure, dùng được trong WHERE để filter và navigate. Có thể làm thu hẹp subtree. Nhưng mọi giá trị của column đó ảnh hưởng đến thứ tự entry — tree phức tạp hơn khi cardinality cao.

INCLUDE column: chỉ stored at leaf, không tham gia sort. Không dùng được trong WHERE. Chỉ phục vụ SELECT — khi query cần column đó nhưng không dùng để filter, INCLUDE giúp Index Only Scan mà không làm phức tạp tree structure.

Dùng sort key khi column xuất hiện trong WHERE (filter hoặc range). Dùng INCLUDE khi column chỉ xuất hiện trong SELECT — ví dụ INCLUDE (id, title) để cover output mà không ảnh hưởng index ordering.

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?

Option 1 — đổi thứ tự thành (b, a): Q2 dùng được (prefix b), Q1 cũng dùng được (b rồi a). Chỉ cần 1 index — write cost thấp hơn. Tradeoff: Q1 narrow theo b trước, rồi filter a trong subtree — nếu b ít selective (cardinality thấp), subtree vẫn lớn, Q1 không tối ưu bằng index (a, b).

Option 2 — 2 index riêng: (a, b) cho Q1, (b) cho Q2. Mỗi query có index tối ưu nhất. Tradeoff: thêm 1 index → thêm write overhead trên mọi INSERT/UPDATE/DELETE. Nếu table write-heavy, cost đáng kể.

Quyết định dựa vào workload: nếu Q1 xuất hiện 10x nhiều hơn Q2, ưu tiên option 1 và chấp nhận Q2 hơi chậm hơn. Nếu cả hai đều critical, tách 2 index. Module 11 của khoá này đo pg_stat_user_indexes.idx_scan để verify index nào thực sự được dùng.

Q5
ORDER BY due_at + composite index (assignee_id, status, due_at) — planner skip sort. Cơ chế gì cho phép điều đó?

B-tree composite (assignee_id, status, due_at) lưu entry đã sorted theo cả 3 column. Khi query có WHERE assignee_id = 5 AND status IN ('todo', 'doing'), planner narrow đến subtree (assignee_id=5, status IN ...). Trong subtree đó, entry đã được sort theo due_at tăng dần — đây chính là thứ tự mà ORDER BY due_at yêu cầu.

Planner nhận ra rằng index scan trả về row theo đúng thứ tự ORDER BY cần, nên loại bỏ Sort node khỏi execution plan. Điều này chỉ xảy ra khi column ORDER BY trùng với column range (hoặc column cuối) trong index sau khi equality column đã được fix. Nếu ORDER BY đi ngược chiều (ORDER BY due_at DESC), B-tree vẫn hỗ trợ — planner scan index theo chiều ngược.

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ì?

Workaround thực tế nhất: tạo index riêng CREATE INDEX ON tasks(status) — index đơn column phục vụ WHERE status = 'done' với Index Scan hiệu quả. Cost: thêm 1 B-tree phải update trên mỗi write. Nhưng đây là giải pháp đơn giản, predictable, và planner tự chọn đúng index cho đúng query.

Option thứ 2: đổi thứ tự composite thành (status, assignee_id)WHERE status = 'done' dùng được (prefix col 1), và WHERE assignee_id = X AND status = 'done' vẫn dùng được (planner có thể dùng cả 2 column). Nhưng query chỉ có WHERE assignee_id = X (không có status) sẽ không dùng được index này nữa.

Recursive CTE workaround (loose index scan) phức tạp và thường chậm hơn index riêng trong production — không khuyến nghị trừ khi cardinality của column 1 quá cao và tạo index thứ 2 không khả thi về storage.

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

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