SQL & Database — Thực chiến PostgreSQL/Pattern matching — vì sao LIKE '%x' kill index
~16 phútTruy vấn cơ bản lượt xem

Pattern matching — vì sao LIKE '%x' kill index

4 cấp pattern matching: LIKE, ILIKE, regex, FTS. B-tree prefix scan vs leading wildcard. pg_trgm cho fuzzy. Decision tree theo use case.

Dashboard TaskFlow có ô tìm kiếm task theo tiêu đề. Bạn thêm index B-tree trên cột title rồi chạy thử:

  • WHERE title LIKE 'deploy%' — 5ms, plan: Index Scan.
  • WHERE title LIKE '%deploy%' — 800ms, plan: Seq Scan.

Cùng một table, cùng một index, chỉ thêm dấu % phía trước — index biến mất hoàn toàn. Vì sao leading wildcard vô hiệu hoá B-tree index? Và làm sao pg_trgm khôi phục tốc độ về 8ms?

Bài này map 4 cấp pattern matching từ nhẹ đến nặng, giải thích cơ chế B-tree prefix scan, và đưa ra decision tree khi nào dùng cái nào.

1. Analogy — Tìm tên trong sổ điện thoại

Sổ điện thoại in theo thứ tự alphabet — tên được sort theo tiền tố. Khi tìm tất cả tên bắt đầu bằng "Nguyen", bạn mở thẳng đến trang N, đọc liên tiếp đến khi hết họ Nguyễn — rất nhanh. Nhưng khi tìm tất cả tên có chứa "nguyen" ở bất kỳ vị trí nào, bạn buộc phải đọc từng trang một từ đầu đến cuối — không có cách nào bỏ qua.

Sổ điện thoạiSQL B-tree index
Sort theo tiền tố alphabetSort theo giá trị column từ nhỏ đến lớn
Tìm "Nguyen%" — mở trang N, đọc liên tiếpLIKE 'Nguyen%' — range scan từ "Nguyen" đến "Nguyeo"
Tìm "%nguyen%" — đọc cả sổLIKE '%nguyen%' — không có tiền tố, phải scan toàn index
Không tìm được theo đuôi tênLIKE '%nguyen' — B-tree không hỗ trợ
Sổ chỉ hữu ích khi biết chữ đầuIndex chỉ hữu ích khi pattern có prefix cố định
💡 Cách nhớ

B-tree index = sổ điện thoại. Tìm theo prefix thì tra sổ được; tìm theo infix/suffix thì phải đọc cả sổ. Leading wildcard % xoá prefix → index vô dụng.

2. Cơ chế B-tree prefix scan

B-tree index lưu giá trị column đã được sort theo thứ tự từ điển. Khi bạn viết LIKE 'deploy%', PostgreSQL biết rằng kết quả nằm trong khoảng từ 'deploy' đến 'deployz' (hay chính xác hơn, 'deploy' đến tiền tố kế tiếp 'deploz'). Đây là một range scan — database seek đến điểm bắt đầu rồi đọc liên tiếp cho đến khi ra khỏi range. Rất nhanh.

Với LIKE '%deploy%', không có tiền tố cố định nào — pattern có thể match "abc_deploy_xyz", "x_deploy", hay "deploy" ngay đầu. Database không thể xác định range bắt đầu trong B-tree, buộc phải scan toàn bộ.

-- Co B-tree index tren title
CREATE INDEX idx_tasks_title ON tasks(title);

-- FAST: prefix scan, range [deploy, deploz)
EXPLAIN ANALYZE SELECT * FROM tasks WHERE title LIKE 'deploy%';
-- Plan: Index Scan using idx_tasks_title
-- actual time: 5ms

-- SLOW: leading wildcard, khong co prefix
EXPLAIN ANALYZE SELECT * FROM tasks WHERE title LIKE '%deploy%';
-- Plan: Seq Scan on tasks
-- actual time: 800ms

Forward link: Module 5 của khoá này sẽ phân tích cấu trúc nội tại của B-tree index — leaf node, page split, và cách planner chọn index scan vs seq scan.

3. 4 cấp pattern matching

3.1 LIKE — basic, case-sensitive

% khớp với chuỗi bất kỳ (kể cả rỗng), _ khớp đúng một ký tự. PostgreSQL mặc định case-sensitive.

SELECT * FROM tasks WHERE title LIKE 'deploy%';    -- prefix - fast, dung index
SELECT * FROM tasks WHERE title LIKE '%deploy';    -- suffix - slow, seq scan
SELECT * FROM tasks WHERE title LIKE '%deploy%';   -- contains - slow, seq scan
SELECT * FROM tasks WHERE title LIKE 'deploy___';  -- prefix 6 ky tu + 3 bat ky

Pattern 'deploy%' dùng được B-tree index. Ba pattern còn lại không có prefix cố định — đều là seq scan.

3.2 ILIKE — case-insensitive, PostgreSQL-specific

-- Match deploy, Deploy, DEPLOY, dEpLoY
SELECT * FROM tasks WHERE title ILIKE 'Deploy%';

Nội bộ PostgreSQL chuyển ILIKE 'Deploy%' thành LOWER(title) LIKE LOWER('Deploy%'). B-tree index trên title không dùng được vì index lưu giá trị gốc, không lưu lowercase. Cần expression index:

-- Expression index tren LOWER(title)
CREATE INDEX idx_tasks_title_lower ON tasks(LOWER(title));

-- Bay gio query nay dung duoc index
SELECT * FROM tasks WHERE LOWER(title) LIKE 'deploy%';
-- Plan: Index Scan using idx_tasks_title_lower

3.3 POSIX regex — ~~*

Mạnh hơn LIKE về biểu đạt nhưng không có tối ưu prefix. Dùng khi LIKE không đủ khả năng diễn đạt pattern.

-- ~ case-sensitive, ~* case-insensitive
SELECT * FROM tasks WHERE title ~ '^(deploy|release)';   -- bat dau bang deploy hoac release
SELECT * FROM tasks WHERE title ~* '^deploy';            -- case-insensitive anchor
SELECT * FROM tasks WHERE title ~ '\d{4}-\d{2}-\d{2}';  -- co dang ngay YYYY-MM-DD

Cả ~~* đều không tận dụng được B-tree index — luôn là seq scan hoặc index scan full range.

3.4 Full-text search — tsvector/tsquery

FTS tokenize và stem văn bản, lưu vào tsvector, index bằng GIN. Phù hợp cho semantic search — tìm theo nghĩa, không phải substring.

SELECT * FROM tasks
WHERE to_tsvector('english', title) @@ to_tsquery('deploy & production');
-- Match: "Deploying to production env", "Production deployment checklist"
-- Khong match: "deploying-to-production" (tuy theo tokenizer)

FTS index bằng GIN, không phải B-tree. Forward link: Module 9 của khoá này đi sâu vào FTS, GIN index internals, và cấu hình tiếng Việt với unaccent.

4. Decision tree theo use case

Use caseRecommendVì sao
Prefix autocomplete (biết chữ đầu)LIKE 'x%'B-tree prefix scan, nhanh
Prefix case-insensitiveLOWER(col) LIKE 'x%' + expression indexSame mechanism, thêm index
Substring chứa chuỗipg_trgm GINLIKE '%x%' slow, trgm nhanh
Pattern phức tạp (regex)~ hoặc ~*Khi LIKE không đủ biểu đạt
Semantic search (tokenize, stem)FTS tsvector + GINPhân biệt nghĩa, không chỉ substring
Fuzzy, chịu lỗi đánh máypg_trgm + similarity()Tìm "depoly" ra "deploy"

5. pg_trgm — giải bài toán substring và fuzzy

Extension pg_trgm chia văn bản thành các trigram (chuỗi 3 ký tự liên tiếp) rồi index bằng GIN. Khi bạn tìm '%deploy%', PG phân tích pattern thành trigrams rồi dùng GIN index để tìm candidates — không cần scan toàn table.

-- Bat dau bang enable extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Tao GIN index tren title voi operator class cua trgm
CREATE INDEX idx_tasks_title_trgm ON tasks USING GIN (title gin_trgm_ops);

-- Bay gio LIKE '%deploy%' dung duoc index
EXPLAIN ANALYZE SELECT * FROM tasks WHERE title LIKE '%deploy%';
-- Plan: Bitmap Heap Scan -> Bitmap Index Scan on idx_tasks_title_trgm
-- actual time: 8ms (truoc khi co index: 800ms)

Bonus: pg_trgm cũng hỗ trợ fuzzy similarity search qua operator % và hàm similarity():

-- Tim task co title gan giong 'depoly' (loi danh may)
SELECT title, similarity(title, 'depoly') AS sim
FROM tasks
WHERE title % 'depoly'   -- % la similarity threshold operator, default >= 0.3
ORDER BY sim DESC
LIMIT 10;
-- Tra ve: "deploy prod", "deploy staging", ...

Tradeoff GIN index: write chậm hơn B-tree khoảng 5-10 lần, storage lớn hơn khoảng 2-3 lần. Đáng đầu tư khi table read-heavy và substring/fuzzy search là tính năng cốt lõi.

6. Pitfall — function trên indexed column

Pitfall — function trên indexed column kill index

Khi bạn đặt function lên column trong WHERE clause, PostgreSQL không thể dùng index thông thường trên column đó — planner không biết function có reversible hay không, nên chọn seq scan cho an toàn.

-- WRONG: LOWER(email) khong dung index tren email
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- Plan: Seq Scan (index tren email khong duoc dung)

-- Fix 1: ILIKE (PostgreSQL tu xu ly, transparent voi app)
SELECT * FROM users WHERE email ILIKE '[email protected]';

-- Fix 2: expression index (ro rang nhat, portable nhat)
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- Plan: Index Scan using idx_users_email_lower

Rule chung: nếu WHERE clause có f(column) = value, cần expression index trên f(column), không phải index trên column.

Ba scenario tìm kiếm phổ biến, mỗi cái cần SQL và index khác nhau:

-- Autocomplete theo prefix (user go "dep" -> goi y "deploy...", "deployment...")
SELECT id, title
FROM tasks
WHERE title ILIKE $1 || '%'
LIMIT 10;
-- Index can: CREATE INDEX ON tasks(LOWER(title));
-- Voi input 'dep' -> LOWER(title) LIKE 'dep%' -> index scan, ~4ms

-- Substring search (user go "dep" -> tim bat ky task chua "dep")
SELECT id, title
FROM tasks
WHERE title ILIKE '%' || $1 || '%'
LIMIT 50;
-- Khong co pg_trgm: seq scan, ~800ms tren 1M tasks
-- Co pg_trgm GIN: bitmap index scan, ~8ms

-- Fuzzy search chiu loi danh may (user go "depoly" -> tra ve "deploy...")
SELECT id, title, similarity(title, $1) AS sim
FROM tasks
WHERE title % $1
ORDER BY sim DESC
LIMIT 10;
-- Can: CREATE INDEX ON tasks USING GIN (title gin_trgm_ops);

Prefix autocomplete và substring search có UX khác nhau rõ rệt: autocomplete LIKE 'x%' chỉ gợi ý task bắt đầu bằng từ đó (giống Google omnibox); substring LIKE '%x%' trả về mọi task có chứa chuỗi đó ở bất kỳ vị trí nào — kết quả nhiều hơn nhưng index cần pg_trgm.

8. Deep Dive — Pattern matching

📚 Deep Dive — Pattern matching

Ghi chú: PG docs cho rules chính xác và behavior đảm bảo. Use The Index Luke cho intuition trực quan. Đọc PG docs trước để biết cú pháp đúng, rồi Use The Index Luke để hiểu cơ chế planner.

9. Tóm tắt

  • LIKE 'x%' dùng được B-tree index vì có prefix cố định; LIKE '%x%'LIKE '%x' buộc planner phải seq scan.
  • ILIKE là case-insensitive LIKE của PostgreSQL — cần expression index trên LOWER(column) để tận dụng index.
  • POSIX regex ~ / ~* cho pattern phức tạp hơn LIKE nhưng không có prefix optimisation, luôn là full scan.
  • pg_trgm GIN giải bài toán substring (LIKE '%x%') và fuzzy (similarity()) — tradeoff write chậm hơn và storage lớn hơn B-tree.
  • Function trên indexed column trong WHERE clause kill index — dùng expression index hoặc rewrite query để tránh.
  • Forward links: Module 5 của khoá này (B-tree internals, composite index) + Module 9 (FTS deep dive + cấu hình tiếng Việt).

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao `LIKE '%deploy%'` không dùng được B-tree index? Giải thích cơ chế prefix scan của B-tree.

B-tree lưu giá trị column đã sort theo thứ tự từ điển. LIKE 'deploy%' xác định được range cụ thể trong B-tree: từ 'deploy' đến tiền tố kế tiếp — database seek thẳng đến điểm bắt đầu rồi đọc liên tiếp. Đây là range scan, rất nhanh.

Với LIKE '%deploy%', không có tiền tố cố định — pattern có thể match bất kỳ vị trí nào trong chuỗi. Planner không xác định được điểm bắt đầu trong B-tree, buộc phải scan toàn bộ index (hoặc table) từ đầu đến cuối. Cost tăng tuyến tính với số rows — trên 1M rows sẽ chậm hơn rất nhiều so với prefix scan.

Q2
Phân biệt khi nào dùng LIKE, ILIKE, regex, và FTS. Cho 2 ví dụ cụ thể cho mỗi loại.

LIKE: pattern đơn giản, case-sensitive, có thể dùng B-tree với prefix.

  • Autocomplete tên task bắt đầu bằng "deploy": title LIKE 'deploy%'
  • Tìm file có đuôi .sql: filename LIKE '%.sql'

ILIKE: giống LIKE nhưng case-insensitive, PostgreSQL-specific.

  • Search email không phân biệt hoa thường: email ILIKE 'foo@%'
  • Autocomplete tên người dùng bất kể cách viết hoa: username ILIKE 'admin%'

Regex ~ / ~*: khi LIKE không đủ biểu đạt pattern.

  • Task có tiêu đề bắt đầu bằng "deploy" hoặc "release": title ~ '^(deploy|release)'
  • Task có chứa mã ngày dạng YYYY-MM-DD: title ~ '\d{4}-\d{2}-\d{2}'

FTS tsvector: khi cần semantic search, stemming, và xử lý ngôn ngữ tự nhiên.

  • Tìm task liên quan đến "deployment" và "production" (bao gồm các dạng biến thể từ): to_tsvector('english', title) @@ to_tsquery('deploy & prod')
  • Search bài viết blog theo từ khóa với stopword removal: không match "the", "a" thừa.
Q3
Bạn có B-tree index trên cột `email`. Query `WHERE LOWER(email) = '[email protected]'` chạy seq scan. Có 2 cách fix — liệt kê và so sánh tradeoff.

B-tree index lưu giá trị gốc của email, không lưu LOWER(email). Khi WHERE clause có LOWER(email), planner không biết cách ánh xạ ngược lại, nên bỏ qua index và chọn seq scan.

Fix 1 — Dùng ILIKE:

SELECT * FROM users WHERE email ILIKE '[email protected]';

PostgreSQL tự xử lý case-insensitive internally. Không cần sửa schema. Nhưng ILIKE là PostgreSQL-specific, không portable sang MySQL/SQLite. Và nếu không có expression index thì vẫn seq scan.

Fix 2 — Expression index:

CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

Explicit và portable hơn — query rõ ràng dùng index nào. Nhưng cần thêm index (tốn storage, write chậm hơn một chút). Đây là pattern chuẩn cho mọi expression trong WHERE clause.

Q4
`pg_trgm` GIN index giúp `LIKE '%x%'` nhanh hơn nhưng write chậm hơn B-tree 5-10 lần và storage lớn hơn 2-3 lần. Khi nào tradeoff này đáng đầu tư?

Đáng đầu tư khi:

  • Read-heavy table: tỷ lệ SELECT cao hơn nhiều so với INSERT/UPDATE. Search box user-facing thường read-heavy.
  • Substring search là core feature: không thể rewrite về prefix-only — ví dụ search box tìm task "có chứa bất kỳ từ nào".
  • Dataset đủ lớn để seq scan gây vấn đề: trên 100k-1M rows, 800ms vs 8ms là sự khác biệt UX rõ rệt.
  • Fuzzy tolerance cần thiết: người dùng thường đánh sai — "depoly" thay vì "deploy".

Không đáng khi table nhỏ dưới 10k rows (seq scan vẫn nhanh), hoặc khi write throughput rất cao (ETL pipeline, event stream) mà GIN write penalty gây bottleneck thực sự.

Q5
Ô search box của TaskFlow: input 'dep'. Autocomplete prefix và substring search có UX khác nhau thế nào? Mỗi loại dùng SQL pattern nào?

Autocomplete prefix (LIKE 'dep%' hoặc ILIKE 'dep%'): chỉ trả về task có tiêu đề bắt đầu bằng "dep" — "deploy staging", "deployment checklist". Task như "run deploy script" không xuất hiện vì "deploy" không ở đầu. UX giống Google omnibox — gợi ý nhanh, ít kết quả, phù hợp khi user biết đầu tên task. Dùng được B-tree index hoặc expression index trên LOWER(title).

Substring search (LIKE '%dep%' với pg_trgm): trả về mọi task có chứa "dep" ở bất kỳ vị trí — "run deploy script", "deep copy task", "deploy-to-prod". Kết quả nhiều hơn, phù hợp khi user không nhớ tên đầy đủ. Cần pg_trgm GIN index để không seq scan.

Trong thực tế nhiều app dùng prefix autocomplete khi user đang gõ (low latency, dùng index) và chuyển sang substring hoặc FTS khi user nhấn Enter để tìm chính thức (chấp nhận latency cao hơn một chút).

Bài tiếp theo: CASE + COALESCE + NULLIF — fallback và conditional

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