Skip to content

PostgreSQL — Partial & Expression Index

Hai kỹ thuật giúp index nhỏ hơn và chính xác hơn so với index thông thường.


1. Partial Index

Index thông thường index toàn bộ row. Partial index chỉ index những row thỏa mãn điều kiện WHERE.

sql
-- Index thông thường: index cả triệu row
CREATE INDEX idx_users_email ON users (email);

-- Partial index: chỉ index ~10k user đang active
CREATE INDEX idx_active_users_email ON users (email)
WHERE is_active = true;

Tại sao hữu ích

Bảng users: 1 triệu row
  is_active = true:  10,000 row  (1%)
  is_active = false: 990,000 row (99%)

Query thực tế hầu hết chỉ tìm active users:
  WHERE email = 'foo@bar.com' AND is_active = true

Index thông thường: 1 triệu entries → lớn, tốn RAM
Partial index:      10,000 entries → nhỏ hơn 100x, fit vào cache tốt hơn

Planner tự động dùng partial index khi query condition khớp với WHERE của index.

Các pattern hay dùng

sql
-- Chỉ index orders chưa xử lý
CREATE INDEX idx_pending_orders ON orders (created_at)
WHERE status = 'pending';

-- Chỉ index row chưa bị xóa mềm — [generic pattern]
-- Schema này không có deleted_at; thay bằng column soft-delete tương ứng
CREATE INDEX idx_active_records ON your_table (user_id, created_at)
WHERE deleted_at IS NULL;  -- [generic pattern]

-- Unique constraint chỉ áp dụng cho row active
-- (cho phép nhiều row inactive có cùng email, nhưng active thì phải unique)
CREATE UNIQUE INDEX idx_unique_active_email ON users (email)
WHERE is_active = true;

2. Expression Index

Index thông thường index giá trị column. Expression index index kết quả của một expression (function, phép tính, concatenation...).

sql
-- Index thông thường — không giúp được query dưới
CREATE INDEX idx_email ON users (email);

-- Query này KHÔNG dùng index vì phải tính lower() trên mỗi row
WHERE lower(email) = 'user@example.com'

-- Fix: expression index — index lưu kết quả lower(email)
CREATE INDEX idx_email_lower ON users (lower(email));

-- Giờ query này dùng index
WHERE lower(email) = 'user@example.com'

Quan trọng: Query phải dùng đúng expression như trong index. Nếu index là lower(email) mà query dùng email = '...' → không match.

Các pattern hay dùng

sql
-- Case-insensitive search (users.full_name)
CREATE INDEX idx_full_name_lower ON users (lower(full_name));
WHERE lower(full_name) = 'nguyen van a'

-- Tính toán từ column
CREATE INDEX idx_order_year ON orders (EXTRACT(year FROM created_at));
WHERE EXTRACT(year FROM created_at) = 2024

-- Lấy field từ jsonb — index giá trị bên trong JSON (events.payload)
CREATE INDEX idx_event_page ON events ((payload->>'page'));
WHERE payload->>'page' = '/checkout'

-- Concat nhiều column — [generic pattern]
-- Schema này dùng full_name thay vì split first/last name
-- Nếu project có split name: thay users bằng bảng tương ứng
CREATE INDEX idx_split_name ON your_table ((first_name || ' ' || last_name));  -- [generic pattern]
WHERE (first_name || ' ' || last_name) = 'John Doe'

Expression index và generated column (PG 12+)

Cách khác để giải quyết cùng vấn đề — thêm column tính sẵn vào bảng:

sql
-- Generated column: PostgreSQL tự tính và lưu giá trị
ALTER TABLE users ADD COLUMN email_lower text
  GENERATED ALWAYS AS (lower(email)) STORED;

CREATE INDEX idx_email_lower ON users (email_lower);
WHERE email_lower = 'user@example.com'

Khác với expression index ở chỗ: generated column lưu giá trị thật vào bảng → SELECT email_lower được, expression index không.


3. Covering Index — INCLUDE

Khi index scan tìm được row, nó có ctid rồi phải quay lại đọc heap để lấy thêm column → thêm 1 lần I/O.

INCLUDE nhét thêm column vào index để tránh bước đó:

sql
CREATE INDEX idx_orders_covering ON orders (user_id)
INCLUDE (status, total_amount, created_at);

-- Query này dùng Index-Only Scan — không cần đọc heap
SELECT user_id, status, total_amount, created_at
FROM orders
WHERE user_id = 123;

Key column vs INCLUDE column

Key column (user_id):
  → dùng để tìm kiếm và sort
  → tham gia vào B-tree structure

INCLUDE column (status, total_amount, created_at):
  → chỉ lưu ở leaf node, không tham gia B-tree
  → không thể dùng trong WHERE, ORDER BY, JOIN condition
  → chỉ dùng để tránh heap lookup
sql
-- ĐÚNG — user_id là key column, có thể filter
WHERE user_id = 123

-- SAI — status là INCLUDE column, không thể filter bằng index này
WHERE user_id = 123 AND status = 'pending'

-- Muốn filter cả status → phải đưa status vào key
CREATE INDEX idx_orders ON orders (user_id, status)
INCLUDE (total_amount, created_at);

Khi nào nên dùng INCLUDE

  • Query thường xuyên select thêm một vài column ngoài các filter column
  • Các column đó lớn (text, jsonb) → tránh random I/O vào heap tốn kém
  • Visibility map đã all-visible (autovacuum chạy tốt) → Index-Only Scan hoạt động được

4. Kết hợp các kỹ thuật

sql
-- Partial + Expression + Covering
-- Chỉ index active users, index lower(email), bao gồm thêm full_name + tier để tránh heap lookup
CREATE INDEX idx_active_users_search ON users (lower(email))
INCLUDE (full_name, tier)
WHERE is_active = true;

-- Query này dùng Index-Only Scan trên partial index
SELECT full_name, tier
FROM users
WHERE lower(email) = 'user@example.com'
  AND is_active = true;

Personal notes by thanhlt