CREATE INDEX trong PostgreSQL
Xem thửTình huống mở đầu
Màn hình "tra đơn theo khách" của Cà Phê Chú Tư chạy ổn khi quán có vài trăm đơn. Sau hai năm, bảng orders có 50.000 đơn của 2.000 khách, mỗi khách khoảng 25 đơn. Mỗi lần tra một khách, PostgreSQL đọc cả 50.000 dòng, giữ lại 25 dòng và bỏ đi 49.975 dòng. Dev thêm một chỉ mục vào cột customer_id và con số "dòng bị bỏ" biến mất khỏi kế hoạch.
Rồi dev làm tiếp: đánh chỉ mục cho cột status để màn hình "đơn đã trả tiền" cũng nhanh. Lần này kế hoạch vẫn cứ đọc cả bảng, như thể chỉ mục không tồn tại.
Hãy chọn một đáp án trước khi đọc tiếp: bảng có chỉ mục trên status, và truy vấn WHERE status = 'paid' khớp 30.004 trên 50.000 dòng. PostgreSQL dùng chỉ mục hay đọc cả bảng? Cuối bài bạn sẽ thấy câu trả lời và lý do.
Ôn nhanh từ bài trước: ENABLE TRIGGER có ghi bù những thay đổi đã bỏ lỡ lúc trigger tắt không?
Đáp án: không, ENABLE không hồi tố. Bài này chuyển từ chuyện ghi sang chuyện đọc: làm cho truy vấn nhanh mà không đụng tới dữ liệu.
Mục tiêu bài học
Sau bài này bạn có thể:
- Tạo chỉ mục bằng
CREATE INDEX, đọc tên chỉ mục tự sinh và xử lý lỗi trùng tên vớiIF NOT EXISTS. - Đọc kế hoạch của
EXPLAIN (ANALYZE)để biết truy vấn dùngSeq Scan,Index Scan,Bitmap ScanhayIndex Only Scan. - Chọn dạng chỉ mục phù hợp: nhiều cột,
INCLUDE, một phần (partial), trên biểu thức, kèm thứ tựDESCvàNULLS. - Giải thích cái giá của chỉ mục và vì sao
CREATE INDEX CONCURRENTLYkhông chạy được trong transaction.
Bức tranh lớn
Xuyên suốt bài, bảng orders là cuốn sổ dày 50.000 trang và chỉ mục là mục lục ở cuối sách: tra "khách 42" ra danh sách số trang, rồi lật đúng 25 trang thay vì 50.000 trang. Dự án xuyên suốt là Cà Phê Chú Tư, database shop, bảng orders có 50.000 dòng sinh tự động và VACUUM ANALYZE để thống kê chính xác.
CREATE TABLE orders (
id int PRIMARY KEY,
customer_id int NOT NULL,
customer text NOT NULL,
status text NOT NULL,
total numeric(10,2) NOT NULL,
created_at date NOT NULL
);
INSERT INTO orders
SELECT g,
(g * 7919) % 2000 + 1,
'Cust' || lpad(((g * 7919) % 2000 + 1)::text, 4, '0'),
(ARRAY['pending', 'paid', 'paid', 'paid', 'cancelled'])[(g / 7 + g / 2000 * 3 + g / 13) % 5 + 1],
round(((g * 37) % 50000) / 100.0 + 5, 2),
DATE '2026-01-01' + (g / 11 + g / 2000 * 37) % 300 -- trộn số để trạng thái và ngày không phụ thuộc vào khách
FROM generate_series(1, 50000) AS g;
VACUUM ANALYZE orders;
Dữ liệu có hình dạng này [Đã chạy: PostgreSQL 16.15]:
SELECT count(*) AS rows_total,
count(DISTINCT customer_id) AS customers,
count(*) FILTER (WHERE customer_id = 42) AS orders_of_42,
count(*) FILTER (WHERE status = 'paid') AS paid,
count(*) FILTER (WHERE status = 'pending') AS pending,
count(*) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders;
rows_total | customers | orders_of_42 | paid | pending | cancelled
------------+-----------+--------------+-------+---------+-----------
50000 | 2000 | 25 | 30004 | 9996 | 10000
(1 row)
Hình dưới trả lời một câu hỏi: khi nào PostgreSQL chọn dùng chỉ mục?
Đang tải sơ đồ…
Cách đọc hình: đọc từ ô xanh dương ở trên xuống ô cam đầu tiên, hỏi có chỉ mục dùng được không. Không có thì ô tím trái: Seq Scan, đọc từng dòng. Có thì ô cam thứ hai hỏi liệu chỉ có ít dòng khớp không: ít thì dùng chỉ mục (ô tím giữa), nhiều thì vẫn đọc cả bảng vì tra chỉ mục từng dòng còn tốn hơn (ô tím phải). Nhánh dùng chỉ mục dẫn tới ô đỏ: cái giá của chỉ mục là mọi lệnh ghi phải cập nhật nó. Màu chỉ là tín hiệu phụ, chữ trong ô mới là nội dung chính.
Chỗ ẩn dụ không còn đúng: mục lục sách in sẵn và không đổi, còn chỉ mục luôn được cập nhật cùng bảng. Mục lục tra theo một từ, còn chỉ mục có thể ghép nhiều cột và có thứ tự.
Khái niệm cốt lõi
Bài này có bốn khái niệm.
1. Lệnh CREATE INDEX và loại mặc định
CREATE INDEX [IF NOT EXISTS] [tên] ON bảng [USING phương_pháp] (cột ...). Mặc định là B-tree: một cây cân bằng giữ giá trị đã sắp xếp, tìm theo =, <, >, BETWEEN và trả dữ liệu theo thứ tự. Không ghi tên thì PostgreSQL tự sinh theo dạng bảng_cột_idx. Tên trùng là lỗi 42P07.
2. Bộ lập kế hoạch quyết định, không phải bạn
Có chỉ mục không có nghĩa nó được dùng. PostgreSQL ước lượng số dòng khớp rồi chọn Seq Scan, Index Scan, Bitmap Heap Scan (gom vị trí rồi đọc theo khối) hoặc Index Only Scan (chỉ đọc chỉ mục). Truy vấn khớp quá nhiều dòng thì đọc cả bảng rẻ hơn. Xác nhận bằng EXPLAIN, không đoán.
3. Hình dạng của chỉ mục
Chỉ mục nhiều cột (a, b) dùng tốt khi truy vấn lọc theo cột đầu a, không dùng tốt khi chỉ lọc theo b. INCLUDE (cột) thêm cột chỉ để đọc, giúp Index Only Scan. Chỉ mục một phần (WHERE ...) chỉ chứa dòng thỏa điều kiện và nhỏ hơn. Chỉ mục trên biểu thức như lower(cột) phục vụ truy vấn dùng đúng biểu thức đó. DESC và NULLS FIRST/LAST phải khớp với ORDER BY.
4. Cái giá và các loại khác
Mỗi chỉ mục chiếm dung lượng và phải được cập nhật trong mọi INSERT, UPDATE, DELETE ảnh hưởng tới cột của nó. CREATE INDEX thường khóa bảng không cho ghi; CREATE INDEX CONCURRENTLY không chặn ghi nhưng chậm hơn và không chạy trong transaction. Ngoài B-tree còn có hash, gin, gist, spgist và brin cho các trường hợp riêng.
Ví dụ đầu tiên
Bắt đầu không có chỉ mục nào ngoài khóa chính. Tra một khách, EXPLAIN (ANALYZE) cho biết máy đã làm gì thật sự [Đã chạy: PostgreSQL 16.15, kế hoạch gắn nhãn Có thể khác nhau tùy thống kê và phiên bản]:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42;
Dự đoán trước khi xem kết quả: máy đọc bao nhiêu dòng và bỏ đi bao nhiêu?
QUERY PLAN
---------------------------------------------
Seq Scan on orders (actual rows=25 loops=1)
Filter: (customer_id = 42)
Rows Removed by Filter: 49975
(3 rows)
Kế hoạch Seq Scan: đọc cả 50.000 dòng, giữ 25 (actual rows=25), bỏ 49.975 (Rows Removed by Filter). Khóa chính thì khác. Cột id đã có chỉ mục từ lúc tạo bảng [Đã chạy: PostgreSQL 16.15]:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE id = 42;
QUERY PLAN
----------------------------------------------------------------
Index Scan using orders_pkey on orders (actual rows=1 loops=1)
Index Cond: (id = 42)
(2 rows)
Giờ tạo chỉ mục trên customer_id rồi chạy lại đúng truy vấn đó [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_customer ON orders (customer_id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42;
CREATE INDEX
QUERY PLAN
-------------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=25 loops=1)
Recheck Cond: (customer_id = 42)
Heap Blocks: exact=25
-> Bitmap Index Scan on idx_orders_customer (actual rows=25 loops=1)
Index Cond: (customer_id = 42)
(5 rows)
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
CREATE INDEX idx_orders_customer ON orders (customer_id); | Quét bảng một lần, dựng cây B-tree theo customer_id | Tên có tiền tố idx_ cho dễ nhận ra |
Bitmap Index Scan on idx_orders_customer | Tra chỉ mục, lấy ra vị trí của 25 dòng | actual rows=25 cho thấy chỉ mục trả đúng số dòng khớp |
Bitmap Heap Scan on orders | Gom vị trí rồi đọc đúng các khối cần thiết | Heap Blocks: exact=25 là 25 khối thay vì cả bảng |
Recheck Cond | Kiểm tra lại điều kiện sau khi đọc dòng | Bitmap có thể khớp rộng hơn nên cần kiểm tra lại |
Animation dưới đây chạy lại hai kịch bản: không có chỉ mục và có chỉ mục.
Xem từng bước
Hãy trả lời câu dự đoán trước khi bấm bước đầu tiên. Công tắc ở dưới animation đổi giữa hai kịch bản.
Đang tải Animation…
Bảng trace bằng chữ [Đã chạy: PostgreSQL 16.15, theo hai kế hoạch ở trên]:
| Chỉ số | Không có chỉ mục | Có chỉ mục |
|---|---|---|
| Cách quét | Seq Scan | Bitmap Index Scan rồi Bitmap Heap Scan |
| Dòng bảng phải đọc | 50.000 | 25 |
| Dòng bị loại sau khi đọc | 49.975 | 0 |
| Dòng trả về | 25 | 25 |
Quay lại câu hỏi mở đầu. Tạo chỉ mục trên status rồi thử hai truy vấn: một khớp 30.004 dòng, một khớp 5 dòng [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_status ON orders (status);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE status = 'paid';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE status = 'pending' AND customer_id = 42;
CREATE INDEX
QUERY PLAN
------------------------------------------------
Seq Scan on orders (actual rows=30004 loops=1)
Filter: (status = 'paid'::text)
Rows Removed by Filter: 19996
(3 rows)
QUERY PLAN
-------------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=5 loops=1)
Recheck Cond: (status = 'pending'::text)
Filter: (customer_id = 42)
Rows Removed by Filter: 9991
Heap Blocks: exact=417
-> Bitmap Index Scan on idx_orders_status (actual rows=9996 loops=1)
Index Cond: (status = 'pending'::text)
(7 rows)
Truy vấn status = 'paid' vẫn là Seq Scan dù có chỉ mục, vì 60 phần trăm dòng khớp. Chỉ mục chỉ giúp khi lọc ra phần nhỏ của bảng. Đáp án của câu hỏi mở đầu là đọc cả bảng. Truy vấn thứ hai dùng chỉ mục trên status nhưng vẫn phải đọc 9.996 dòng pending để lọc tiếp theo customer_id. Đây là dấu hiệu cần chỉ mục nhiều cột.
Bạn vừa làm được: đọc EXPLAIN (ANALYZE) để biết máy đã đọc bao nhiêu dòng, và giải thích vì sao một chỉ mục có thể bị bỏ qua.
Concept Check
Bảng orders có 50.000 dòng và các chỉ mục ở trên.
Câu 1. Truy vấn WHERE customer_id = 42 khớp 25 dòng và cột có chỉ mục. Kế hoạch có thể dùng gì?
- A.
Seq Scan, vì chỉ mục chỉ được dùng cho khóa chính. - B.
Bitmap Index ScanhoặcIndex Scan, vì ít dòng khớp. - C.
Index Only Scan, vì chỉ mục luôn chứa mọi cột cần thiết. - D. Lỗi, vì không thể dùng chỉ mục với điều kiện
=.
Câu 2. Truy vấn WHERE status = 'paid' khớp 60 phần trăm dòng và cột status có chỉ mục. Kế hoạch có thể dùng gì?
- A. Luôn là
Index Scan, vì đã có chỉ mục phù hợp. - B.
Seq Scan, vì đọc cả bảng rẻ hơn tra chỉ mục cho 30.004 dòng. - C. Lỗi
42P07, vì chỉ mục trùng với khóa chính. - D.
Index Only Scan, vìstatuscó ít giá trị khác nhau.
Câu 3. Chỉ mục (customer_id, created_at). Truy vấn chỉ lọc WHERE created_at = DATE '2026-09-01' có dùng tốt chỉ mục này không?
- A. Có, vì chỉ mục chứa cột
created_at. - B. Có, vì thứ tự cột trong chỉ mục không quan trọng.
- C. Không, vì cột đầu
customer_idkhông có trong điều kiện. - D. Không, vì chỉ mục đa cột chỉ phục vụ
ORDER BY.
Đáp án và giải thích:
- Câu 1: B. Ít dòng khớp thì tra chỉ mục có lợi. A và D bịa ra giới hạn, C sai vì
Index Only Scancần chỉ mục chứa mọi cột truy vấn lấy. - Câu 2: B. Lọc ra phần lớn bảng thì
Seq Scanrẻ hơn. A mô tả mong đợi sai thường gặp. C và D bịa ra nguyên nhân. - Câu 3: C. Chỉ mục đa cột hiệu quả nhất khi lọc theo cột đầu. Kế hoạch trong bài cho truy vấn này là
Seq Scan.
Làm cùng mình
Chú Tư muốn màn hình "đơn gần đây của một khách" nhanh: lấy 3 đơn mới nhất của khách 42.
Bước 1. Vẽ ra giấy
Viết ra truy vấn cần tối ưu và cột mà nó lọc, cột mà nó sắp xếp. Dự đoán cần chỉ mục trên những cột nào, theo thứ tự nào.
Bước 2. Điền chỗ trống
-- TODO 1: tạo chỉ mục idx_orders_cust_date trên (customer_id, created_at)
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 3;
-- TODO 2: đọc kế hoạch, tìm chữ Backward trong dòng quét chỉ mục
Gợi ý: cột lọc bằng = đứng trước, cột sắp xếp đứng sau. Chỉ mục B-tree đọc ngược được nên không cần khai báo DESC nếu truy vấn chỉ sắp xếp theo một chiều.
Bước 3. Chạy và so sánh
Dự đoán trước: kế hoạch có bước Sort riêng không? Đối chiếu ở mục Lời giải và đáp án.
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 3;
CREATE INDEX
QUERY PLAN
----------------------------------------------------------------------------------------
Limit (actual rows=3 loops=1)
-> Index Scan Backward using idx_orders_cust_date on orders (actual rows=3 loops=1)
Index Cond: (customer_id = 42)
(3 rows)
Bước 4. Vẽ lại ra giấy
Vẽ lại đường đi của truy vấn: chỉ mục, rồi đọc 3 dòng, rồi dừng nhờ LIMIT.
Thử phá nó đi
Các lỗi người mới hay gặp với CREATE INDEX [Đã chạy: PostgreSQL 16.15]:
| Tình huống | Điều xảy ra | Nguyên nhân | Cách sửa |
|---|---|---|---|
| Tạo hai chỉ mục cùng tên | relation "idx_orders_customer" already exists, mã 42P07 | Tên chỉ mục là tên của một relation | Thêm IF NOT EXISTS hoặc đổi tên |
| Tạo hai chỉ mục cùng cột, tên khác | Thành công, tốn gấp đôi dung lượng | PostgreSQL không cản chỉ mục trùng lặp | Kiểm tra trước, xóa bản thừa |
CREATE INDEX CONCURRENTLY trong BEGIN | CREATE INDEX CONCURRENTLY cannot run inside a transaction block, mã 25001 | Cần nhiều giao dịch riêng | Chạy ngoài khối transaction |
ORDER BY total DESC với chỉ mục (total DESC NULLS LAST) | Kế hoạch có Sort, không dùng chỉ mục | Thứ tự NULLS khác mặc định | Khớp đúng NULLS FIRST hay NULLS LAST |
Hai lỗi đầu cho thấy tên tự sinh và cách xử lý trùng. Dòng NOTICE và ERROR nằm cuối output vì đi qua luồng lỗi [Đã chạy: PostgreSQL 16.15]:
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_customer ON orders (customer_id);
\echo SQLSTATE: :LAST_ERROR_SQLSTATE
CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_customer_again ON orders (customer_id);
SELECT indexname FROM pg_indexes WHERE tablename = 'orders' ORDER BY indexname;
CREATE INDEX
SQLSTATE: 42P07
CREATE INDEX
CREATE INDEX
indexname
---------------------------
idx_orders_customer
idx_orders_customer_again
orders_pkey
(3 rows)
ERROR: relation "idx_orders_customer" already exists
NOTICE: relation "idx_orders_customer" already exists, skipping
Tên tự sinh có quy luật bảng_cột_idx, và thêm số 1 nếu trùng [Đã chạy: PostgreSQL 16.15]:
CREATE INDEX ON orders (customer_id);
CREATE INDEX ON orders (customer_id, status);
CREATE INDEX ON orders (lower(customer));
CREATE INDEX ON orders (customer_id) WHERE status = 'pending';
SELECT indexname FROM pg_indexes WHERE tablename = 'orders' ORDER BY indexname;
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
indexname
-------------------------------
orders_customer_id_idx
orders_customer_id_idx1
orders_customer_id_status_idx
orders_lower_idx
orders_pkey
(5 rows)
Lỗi CONCURRENTLY trong transaction và kết quả khi chạy ngoài transaction [Đã chạy: PostgreSQL 16.15]:
BEGIN;
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id);
\echo SQLSTATE: :LAST_ERROR_SQLSTATE
ROLLBACK;
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id);
SELECT indexrelid::regclass AS index_name, indisvalid FROM pg_index WHERE indrelid = 'orders'::regclass AND NOT indisprimary;
BEGIN
SQLSTATE: 25001
ROLLBACK
CREATE INDEX
index_name | indisvalid
---------------------+------------
idx_orders_customer | t
(1 row)
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
Dòng cuối của bảng lỗi cần chạy tận mắt. Thứ tự NULLS phải khớp, ngay cả khi cột không có NULL [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_total_desc_last ON orders (total DESC NULLS LAST);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, total FROM orders ORDER BY total DESC LIMIT 3;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, total FROM orders ORDER BY total DESC NULLS LAST LIMIT 3;
CREATE INDEX
QUERY PLAN
------------------------------------------------------------
Limit (actual rows=3 loops=1)
-> Sort (actual rows=3 loops=1)
Sort Key: total DESC
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on orders (actual rows=50000 loops=1)
(5 rows)
QUERY PLAN
-------------------------------------------------------------------------------------
Limit (actual rows=3 loops=1)
-> Index Scan using idx_orders_total_desc_last on orders (actual rows=3 loops=1)
(2 rows)
Truy vấn đầu ORDER BY total DESC mặc định là NULLS FIRST nên không khớp chỉ mục NULLS LAST: kế hoạch phải Sort. Truy vấn thứ hai khai báo NULLS LAST rõ ràng nên khớp và dùng Index Scan. Hiểu lầm quan trọng nhất của bài là tin rằng nhiều chỉ mục thì luôn nhanh hơn. Hình dưới đối chiếu mô hình sai với mô hình đúng.
Đang tải sơ đồ…
Cách đọc hình: hai chuỗi độc lập. Chuỗi đỏ là niềm tin sai, dẫn đến chỉ mục bị bỏ qua và lệnh ghi chậm đi. Chuỗi xanh là thực tế: kiểm tra kế hoạch trước và sau, giữ lại những chỉ mục thật sự được dùng.
Tự tay làm
Dùng chung bảng orders 50.000 dòng của bài. Mỗi bài nêu đầu vào, kết quả mong đợi và tiêu chí đạt.
Cơ bản
Bài 1. Dự đoán kế hoạch. Đầu vào: chỉ mục trên customer_id và ba truy vấn: WHERE customer_id = 42, WHERE id = 42, WHERE status = 'paid' (khớp 60 phần trăm). Ghi mỗi truy vấn dùng chỉ mục nào hoặc Seq Scan. Tiêu chí đạt: đúng cả ba (chỉ mục customer_id, khóa chính, Seq Scan). Gợi ý: nghĩ xem mỗi truy vấn khớp bao nhiêu dòng.
Vận dụng
Bài 2. Chỉ mục nhiều cột. Đầu vào: truy vấn WHERE status = 'pending' AND created_at = DATE '2026-09-01'. Tạo chỉ mục (status, created_at) và đọc kế hoạch. Tiêu chí đạt: kế hoạch có Bitmap Index Scan trên chỉ mục mới với cả hai điều kiện trong Index Cond. Gợi ý: cả hai cột đều lọc bằng dấu bằng.
Thử thách (tùy chọn)
Bài 3. Thứ tự chỉ mục. Đầu vào: chỉ mục (customer_id, created_at DESC). Thử ORDER BY created_at DESC LIMIT 3 và ORDER BY created_at ASC LIMIT 3 cho khách 42. Tiêu chí đạt: giải thích vì sao cả hai đều dùng chỉ mục mà không cần Sort, và Backward xuất hiện ở truy vấn nào. Gợi ý: chỉ mục B-tree đọc được cả hai chiều.
Tự kiểm tra
Trả lời bằng lời của bạn, không nhìn lại bài. Mỗi câu ghi mục tiêu được kiểm tra.
- (Mục tiêu 1, nhớ lại) Nếu bỏ qua tên khi
CREATE INDEX, PostgreSQL đặt tên theo dạng nào? - (Mục tiêu 2, dự đoán) Truy vấn khớp 60 phần trăm số dòng có dùng chỉ mục không?
- (Mục tiêu 3, dự đoán) Chỉ mục
(customer_id, created_at)có giúp truy vấn chỉ lọc theocreated_atkhông? - (Mục tiêu 4, giải thích cho bạn chưa học) Đồng nghiệp hỏi: "Sao không đánh chỉ mục mọi cột?" Hãy trả lời trong hai câu.
- (Mục tiêu 4, tìm lỗi)
BEGIN; CREATE INDEX CONCURRENTLY ...báo25001. Sửa thế nào?
Điều cần nhớ
CREATE INDEXmặc định B-tree, tên tự sinh dạngbảng_cột_idx, tên trùng là lỗi42P07.- Bộ lập kế hoạch chọn dùng chỉ mục hay đọc cả bảng theo số dòng ước tính khớp; kiểm tra bằng
EXPLAIN (ANALYZE). - Chỉ mục nhiều cột dùng tốt cho cột đầu;
INCLUDE, một phần, biểu thức và thứ tựDESC/NULLSphải khớp với truy vấn. - Chỉ mục tốn dung lượng và làm chậm lệnh ghi;
CONCURRENTLYkhông chặn ghi nhưng không chạy trong transaction.
Cheat sheet:
| Muốn | Viết | Ghi chú |
|---|---|---|
| Chỉ mục cơ bản | CREATE INDEX idx ON t (cột); | B-tree |
| Không lỗi nếu có rồi | CREATE INDEX IF NOT EXISTS idx ON t (cột); | Chỉ báo NOTICE |
| Nhiều cột | CREATE INDEX idx ON t (a, b); | Cột đầu quan trọng nhất |
| Chỉ đọc chỉ mục | ... ON t (a) INCLUDE (b); | Giúp Index Only Scan |
| Một phần | ... ON t (a) WHERE status = 'pending'; | Nhỏ hơn, chỉ cho truy vấn khớp |
| Biểu thức | ... ON t (lower(a)); | Truy vấn phải dùng đúng biểu thức |
| Không chặn ghi | CREATE INDEX CONCURRENTLY ... | Ngoài transaction |
| Xem kế hoạch | EXPLAIN (ANALYZE) SELECT ... | Đọc actual rows |
Hình tóm tắt của bài là sơ đồ ở mục Bức tranh lớn.
Câu hỏi để dành cho ngày mai: sau vài tháng, bảng có thể có hàng chục chỉ mục do nhiều người thêm vào. Làm sao liệt kê chúng, biết cái nào được dùng, cái nào trùng lặp và cái nào chỉ tốn chỗ?
Đào sâu
Phần này dành cho bạn tò mò.
- Cơ chế bên trong. Chỉ chỉ mục chứa
customer_idthì không đủ cho truy vấn lấy thêmtotal: máy còn phải đọc bảng. ThêmINCLUDE (total)để chỉ mục tự trả lời và kế hoạch chuyển sangIndex Only Scan. Bảng đã đượcVACUUMnênHeap Fetchesbằng 0 [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_customer ON orders (customer_id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT customer_id, total FROM orders WHERE customer_id = 42;
CREATE INDEX
QUERY PLAN
-------------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=25 loops=1)
Recheck Cond: (customer_id = 42)
Heap Blocks: exact=25
-> Bitmap Index Scan on idx_orders_customer (actual rows=25 loops=1)
Index Cond: (customer_id = 42)
(5 rows)
CREATE INDEX idx_orders_customer_total ON orders (customer_id) INCLUDE (total);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT customer_id, total FROM orders WHERE customer_id = 42;
CREATE INDEX
QUERY PLAN
------------------------------------------------------------------------------------
Index Only Scan using idx_orders_customer_total on orders (actual rows=25 loops=1)
Index Cond: (customer_id = 42)
Heap Fetches: 0
(3 rows)
- Chỉ mục một phần và biểu thức. Chỉ mục một phần chỉ chứa đơn
pending, nhỏ hơn nhiều và phục vụ đúng truy vấn có điều kiện đó; truy vấnpaidbỏ qua nó [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_pending ON orders (customer_id) WHERE status = 'pending';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
CREATE INDEX
QUERY PLAN
-----------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=5 loops=1)
Recheck Cond: ((customer_id = 42) AND (status = 'pending'::text))
Heap Blocks: exact=5
-> Bitmap Index Scan on idx_orders_pending (actual rows=5 loops=1)
Index Cond: (customer_id = 42)
(5 rows)
QUERY PLAN
------------------------------------------------------------
Seq Scan on orders (actual rows=14 loops=1)
Filter: ((customer_id = 42) AND (status = 'paid'::text))
Rows Removed by Filter: 49986
(3 rows)
Chỉ mục trên biểu thức chỉ phục vụ truy vấn dùng đúng biểu thức. lower(customer) dùng được, còn customer = 'Cust0042' thì không [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_lower_customer ON orders (lower(customer));
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE lower(customer) = 'cust0042';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer = 'Cust0042';
CREATE INDEX
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=25 loops=1)
Recheck Cond: (lower(customer) = 'cust0042'::text)
Heap Blocks: exact=25
-> Bitmap Index Scan on idx_orders_lower_customer (actual rows=25 loops=1)
Index Cond: (lower(customer) = 'cust0042'::text)
(5 rows)
QUERY PLAN
---------------------------------------------
Seq Scan on orders (actual rows=25 loops=1)
Filter: (customer = 'Cust0042'::text)
Rows Removed by Filter: 49975
(3 rows)
- Chỉ mục nhiều cột. Truy vấn có đủ cột đầu dùng được chỉ mục; truy vấn chỉ lọc cột sau thì không [Đã chạy: PostgreSQL 16.15, kế hoạch Có thể khác nhau]:
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42 AND created_at >= DATE '2026-09-01';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders WHERE created_at = DATE '2026-09-01';
CREATE INDEX
QUERY PLAN
---------------------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=5 loops=1)
Recheck Cond: ((customer_id = 42) AND (created_at >= '2026-09-01'::date))
Heap Blocks: exact=5
-> Bitmap Index Scan on idx_orders_cust_date (actual rows=5 loops=1)
Index Cond: ((customer_id = 42) AND (created_at >= '2026-09-01'::date))
(5 rows)
QUERY PLAN
----------------------------------------------
Seq Scan on orders (actual rows=165 loops=1)
Filter: (created_at = '2026-09-01'::date)
Rows Removed by Filter: 49835
(3 rows)
- Các loại khác. Ngoài B-tree, PostgreSQL 16 có sáu phương pháp chỉ mục [Đã chạy: PostgreSQL 16.15]:
SELECT amname FROM pg_am WHERE amtype = 'i' ORDER BY amname;
amname
--------
brin
btree
gin
gist
hash
spgist
(6 rows)
hash chỉ phục vụ so sánh bằng, gin cho mảng, JSONB và tìm toàn văn, gist và spgist cho dữ liệu hình học hay không gian, brin cho bảng rất lớn mà giá trị sắp theo thứ tự lưu. Cùng cột nhưng kích thước khác hẳn [Đã chạy: PostgreSQL 16.15, kích thước Có thể khác nhau]:
CREATE INDEX idx_orders_btree ON orders USING btree (customer_id);
CREATE INDEX idx_orders_hash ON orders USING hash (customer_id);
CREATE INDEX idx_orders_brin ON orders USING brin (id);
SELECT relname AS index_name, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class
WHERE relname IN ('orders', 'orders_pkey', 'idx_orders_btree', 'idx_orders_hash', 'idx_orders_brin')
ORDER BY pg_relation_size(oid) DESC;
CREATE INDEX
CREATE INDEX
CREATE INDEX
index_name | size
------------------+---------
orders | 3336 kB
idx_orders_hash | 2080 kB
orders_pkey | 1112 kB
idx_orders_btree | 392 kB
idx_orders_brin | 24 kB
(5 rows)
- Cái giá. Dung lượng chỉ mục tăng cùng số chỉ mục. Thêm bốn chỉ mục làm tổng dung lượng chỉ mục từ 1112 kB lên 3520 kB, gấp ba lần, và mỗi lệnh ghi phải cập nhật cả bốn [Đã chạy: PostgreSQL 16.15, kích thước Có thể khác nhau]:
SELECT pg_size_pretty(pg_table_size('orders')) AS table_size,
pg_size_pretty(pg_indexes_size('orders')) AS indexes_size;
CREATE INDEX idx_a ON orders (customer_id);
CREATE INDEX idx_b ON orders (created_at);
CREATE INDEX idx_c ON orders (total);
CREATE INDEX idx_d ON orders (status, customer_id);
SELECT pg_size_pretty(pg_table_size('orders')) AS table_size,
pg_size_pretty(pg_indexes_size('orders')) AS indexes_size;
table_size | indexes_size
------------+--------------
3376 kB | 1112 kB
(1 row)
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
table_size | indexes_size
------------+--------------
3376 kB | 3520 kB
(1 row)
- Giới hạn của bài. Bài này chưa nói cách liệt kê và đo mức dùng của chỉ mục (bài sau), chưa nói chỉ mục unique (bài cuối), chưa nói
REINDEX, và chưa đo thời gian chạy vì thời gian thay đổi giữa các máy. - Đọc thêm. Tài liệu PostgreSQL 16, chương "Indexes": https://www.postgresql.org/docs/16/indexes.html và trang
CREATE INDEX: https://www.postgresql.org/docs/16/sql-createindex.html
Bài tiếp theo
Bài sau là Liệt kê Index (danh sách chỉ mục): dùng pg_indexes, \d, \di+ và pg_stat_user_indexes để thấy bảng đang có chỉ mục nào, nặng bao nhiêu, được dùng bao nhiêu lần, và cái nào là bản sao thừa. Mini-dự án nối tiếp: kiểm kê chỉ mục của orders rồi quyết định giữ cái nào.
Lời giải và đáp án
Làm cùng mình, Bước 2. Chỗ trống thứ nhất:
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at);
Làm cùng mình, Bước 3. Kế hoạch là Index Scan Backward using idx_orders_cust_date, không có bước Sort. Cột lọc đứng đầu, cột sắp xếp đứng sau, nên các dòng của khách 42 đã nằm theo thứ tự created_at trong chỉ mục. Đọc ngược từ cuối rồi dừng sau 3 dòng nhờ LIMIT.
Bài 1. customer_id = 42 dùng chỉ mục customer_id (khớp 25 dòng). id = 42 dùng khóa chính (kế hoạch Index Scan using orders_pkey). status = 'paid' dùng Seq Scan vì khớp 60 phần trăm.
Bài 2.
CREATE INDEX idx_orders_status_date ON orders (status, created_at);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id FROM orders WHERE status = 'pending' AND created_at = DATE '2026-09-01';
CREATE INDEX
QUERY PLAN
----------------------------------------------------------------------------------------
Bitmap Heap Scan on orders (actual rows=31 loops=1)
Recheck Cond: ((status = 'pending'::text) AND (created_at = '2026-09-01'::date))
Heap Blocks: exact=10
-> Bitmap Index Scan on idx_orders_status_date (actual rows=31 loops=1)
Index Cond: ((status = 'pending'::text) AND (created_at = '2026-09-01'::date))
(5 rows)
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
CREATE INDEX idx_orders_status_date ON orders (status, created_at); | Dựng cây theo cặp cột | Cả hai điều kiện lọc bằng dấu bằng nên cột nào đứng trước cũng được |
Index Cond: ((status = ...) AND (created_at = ...)) | Cả hai điều kiện được kiểm tra ngay trong chỉ mục | Không phải đọc 9.996 dòng pending như khi chỉ có chỉ mục status |
actual rows=31 | Có 31 đơn pending ngày đó | Con số này đối chiếu được bằng truy vấn đếm riêng |
Bài 3.
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at DESC);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 3;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders WHERE customer_id = 42 ORDER BY created_at ASC LIMIT 3;
CREATE INDEX
QUERY PLAN
-------------------------------------------------------------------------------
Limit (actual rows=3 loops=1)
-> Index Scan using idx_orders_cust_date on orders (actual rows=3 loops=1)
Index Cond: (customer_id = 42)
(3 rows)
QUERY PLAN
----------------------------------------------------------------------------------------
Limit (actual rows=3 loops=1)
-> Index Scan Backward using idx_orders_cust_date on orders (actual rows=3 loops=1)
Index Cond: (customer_id = 42)
(3 rows)
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
ORDER BY created_at DESC | Index Scan xuôi | Thứ tự chỉ mục created_at DESC khớp truy vấn |
ORDER BY created_at ASC | Index Scan Backward | Cây B-tree đọc ngược được, nên chiều ngược lại cũng tránh được Sort |
LIMIT 3 | Dừng sau 3 dòng | Không cần đọc hết 25 đơn của khách |
Tự kiểm tra.
- Dạng
bảng_cột_idx, ví dụorders_customer_id_idx, thêm số nếu tên đã dùng. - Thường là không. Khớp quá nhiều dòng thì
Seq Scanrẻ hơn. - Thường không, vì cột đầu
customer_idkhông có trong điều kiện. - Mỗi chỉ mục chiếm dung lượng và mọi lệnh ghi phải cập nhật nó, nên nhiều chỉ mục làm ghi chậm. Chỉ mục thừa còn không được dùng, nên chỉ có hại.
- Chạy
CREATE INDEX CONCURRENTLYngoài khốiBEGIN, vì lệnh này tự quản lý nhiều transaction.