EXPLAIN là công cụ quan trọng nhất khi tối ưu truy vấn, và nó in ra một khối chữ mà phần lớn người dùng chỉ nhìn dòng đầu. Bài này mổ từng con số, trên bảng 1.000.000 đơn hàng và 100.000 khách hàng.
Một dòng, hai thế giới
Seq Scan on dh (cost=0.00..17906.00 rows=199181 width=16)
(actual time=0.113..32.068 rows=199800 loops=1)
Cụm ngoặc đầu là bộ tối ưu đoán trước khi chạy. Cụm sau là điều đã xảy ra. EXPLAIN trần chỉ cho cụm đầu; muốn cụm sau phải dùng EXPLAIN ANALYZE — và lưu ý nó thật sự chạy truy vấn, nên với UPDATE hay DELETE hãy bọc trong giao dịch rồi ROLLBACK.
| Phần | Nghĩa |
|---|---|
cost=0.00..17906.00 |
chi phí ước lượng: trước dòng đầu tiên .. tới dòng cuối |
rows=199181 |
số dòng dự đoán |
width=16 |
bề rộng trung bình một dòng, tính bằng byte |
actual time=0.113..32.068 |
thời gian thật, mili giây, mỗi vòng lặp |
rows=199800 |
số dòng thật |
loops=1 |
nút này chạy bao nhiêu lần |
Ở đây dự đoán 199.181 so với thực tế 199.800 — lệch 0,3%, thống kê đang tốt.
Đơn vị của cost không phải mili giây. Nó là một con số quy ước, mặc định lấy "đọc tuần tự một trang" làm 1,0. Nó chỉ dùng để so sánh giữa các kế hoạch, không dùng để đoán thời gian.
Ba con số hay bị đọc sai
loops nhân lên mọi thứ
Nested Loop (actual time=0.021..0.194 rows=490 loops=1)
-> Index Scan on kh k (actual time=0.011..0.013 rows=49 loops=1)
-> Index Scan on dh d (actual time=0.001..0.003 rows=10 loops=49)
Nút trong cùng ghi rows=10 loops=49. Đó không phải 10 dòng — đó là 10 dòng mỗi lần, và nó chạy 49 lần. Tổng thật là 490, khớp với rows=490 của nút cha.
Thời gian cũng vậy: 0.003 ms là mỗi vòng, tổng khoảng 0.003 × 49 ≈ 0.15 ms.
Đây là chỗ người ta hay kết luận nhầm "nút này chỉ tốn 0,003 ms nên không phải vấn đề", trong khi loops có thể là hàng chục nghìn.
cost=X..Y — số đầu là chi phí trước dòng đầu tiên
EXPLAIN SELECT * FROM dh ORDER BY tong;
-- Sort (cost=132154.34..134654.34 rows=1000000)
Chi phí khởi động 132.154 gần bằng tổng chi phí, vì Sort phải xếp xong toàn bộ mới trả được dòng nào.
Thêm LIMIT 10 và bộ tối ưu đổi hẳn kế hoạch:
Limit (cost=19576.71..19577.88 rows=10)
-> Gather Merge (cost=19576.71..116805.80 rows=833334)
Chi phí khởi động rơi từ 132.154 xuống 19.576. Đây là lý do một truy vấn "nhanh khi có LIMIT, chậm khi bỏ LIMIT" — không phải vì nó trả ít dòng hơn, mà vì kế hoạch khác hẳn.
Rows Removed by Filter là công sức bỏ đi
Parallel Seq Scan on dh (actual rows=67 loops=3)
Rows Removed by Filter: 333267
Để lấy 67 dòng, PostgreSQL đọc lên và vứt đi 333.267 dòng mỗi worker. Đây là dấu hiệu rõ nhất của việc thiếu chỉ mục: tỉ lệ giữ lại trên đọc vào càng nhỏ, chỉ mục càng đáng thêm.
Đọc cây theo thứ tự nào
Kế hoạch in ra dạng cây, và đọc từ trong ra ngoài, từ dưới lên:
Limit
-> Sort
Sort Key: (count(*)) DESC
Sort Method: top-N heapsort Memory: 25kB
-> Finalize GroupAggregate
-> Gather Merge
Workers Planned: 2
Workers Launched: 2
-> Sort
-> Partial HashAggregate
Nút sâu nhất chạy trước. Dữ liệu chảy từ dưới lên, mỗi tầng biến đổi rồi đưa lên tầng trên.
Vài dòng phụ đáng đọc:
Sort Method: top-N heapsort Memory: 25kB— sắp xếp trong bộ nhớ. Nếu thấyexternal merge Disk: ...thìwork_memkhông đủ và nó đang tràn ra đĩa.Workers Planned: 2 / Workers Launched: 2— hai con số này lệch nhau nghĩa là hệ thống hết worker rảnh, và truy vấn chạy chậm hơn kế hoạch.
BUFFERS cho biết dữ liệu đến từ đâu
EXPLAIN (ANALYZE, BUFFERS) SELECT ...
Buffers: shared hit=1 read=19
Buffers: shared hit=99
hit là trang lấy từ bộ nhớ đệm, read là trang phải đọc từ hệ thống tệp. Câu thứ hai chạy toàn bộ trong bộ nhớ đệm.
Đây là con số đáng tin hơn thời gian khi so hai truy vấn: thời gian phụ thuộc bộ đệm đang nóng hay lạnh, còn số trang phải đụng tới thì ổn định. Tôi khuyên luôn bật BUFFERS — từ PostgreSQL 18 nó bật sẵn cùng ANALYZE.
Khi ước lượng sai, mọi thứ sai theo
Một bảng mà hai cột luôn bằng nhau:
CREATE TABLE lech AS SELECT i, (i%100) a, (i%100) b FROM generate_series(1,500000) i;
SELECT count(*) FROM lech WHERE a = 7 AND b = 7;
| Dự đoán | Thực tế | Lệch | |
|---|---|---|---|
| Mặc định | 30 dòng | 2.500 | 83 lần |
Bộ tối ưu tính xác suất a=7 là 1/100, b=7 là 1/100, rồi nhân chúng thành 1/10.000 — vì nó mặc định coi các cột độc lập. Ở đây chúng không độc lập chút nào.
Ước lượng lệch 83 lần dẫn tới chọn sai kế hoạch: nó tưởng chỉ có 30 dòng nên có thể chọn Nested Loop, trong khi 2.500 dòng thì Hash Join hợp hơn.
Cách sửa là khai cho PostgreSQL biết hai cột có liên quan:
CREATE STATISTICS st_lech (dependencies, ndistinct) ON a, b FROM lech;
ANALYZE lech;
| Dự đoán | Thực tế | Lệch | |
|---|---|---|---|
Sau CREATE STATISTICS |
2.882 dòng | 2.500 | 15% |
Từ 83 lần xuống 15%. Đây là công cụ ít được dùng nhưng rất hiệu quả cho các cột có quan hệ tự nhiên — tỉnh và mã vùng, danh mục và danh mục con, quốc gia và tiền tệ.
Quy trình đọc một kế hoạch chậm
- So
rowsdự đoán vớirowsthật. Lệch trên một bậc là vấn đề thống kê, không phải vấn đề chỉ mục. - Tìm nút có
actual timelớn nhất, nhớ nhân vớiloops. - Xem
Rows Removed by Filter. Đọc nhiều bỏ nhiều là thiếu chỉ mục. - Xem
Sort Method. Có chữDisklà thiếuwork_mem. - Xem
Buffers.readcao nghĩa là dữ liệu không nằm trong bộ đệm.
Thử ba mươi giây
Lấy kế hoạch đầy đủ của truy vấn chậm nhất trong hệ thống bạn:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) <truy van cua ban>;
SETTINGS in ra những tham số đã bị đổi khác mặc định — rất hữu ích khi kế hoạch trên máy chủ khác trên máy bạn.
Và tìm nhanh nút có ước lượng tệ nhất:
EXPLAIN (ANALYZE, FORMAT JSON) <truy van>;
Rồi so Plan Rows với Actual Rows ở từng nút. Nút đầu tiên lệch trên mười lần thường là nguyên nhân của mọi thứ bên trên nó.
Phần sau đo EXPLAIN ANALYZE sâu hơn: chỗ ước lượng sai kéo theo chọn nhầm thuật toán join.