EXPLAIN & EXPLAIN ANALYZE — đọc query plan của PostgreSQL
EXPLAIN show plan + estimated cost (không chạy query). EXPLAIN ANALYZE chạy thật + actual time + actual rows. Đọc plan tree bottom-up: cost, rows, width, loops. BUFFERS đếm I/O page. Base cho mọi optimization.
Query chạy 2 giây. PM hỏi "sao chậm?" Bạn đoán: thiếu index? JOIN sai? Data nhiều quá? Đoán xong thêm index thử — vẫn 2 giây. Thêm cái nữa — 1.8 giây. Đoán tiếp.
Đây là cách optimize của đa số developer. Và đây cũng là lý do phần lớn optimization không hiệu quả: đoán mà không nhìn vào bằng chứng.
EXPLAIN là công cụ PostgreSQL dùng để cho bạn xem bên trong — query planner đang nghĩ gì, chọn chiến lược gì, ước tính bao nhiêu row, tốn bao nhiêu cost. Trước khi đụng vào bất kỳ index nào, đọc EXPLAIN trước. Bài này dạy bạn đọc plan output từ đầu đến cuối.
1. Analogy — Google Maps route preview vs lái thực tế
Google Maps cho bạn xem route trước khi lái: ước tính 45 phút, 32 km, qua cao tốc. Bạn có thể chọn route khác trước khi nổ máy. EXPLAIN hoạt động theo đúng cách đó — cho bạn xem "plan" của PostgreSQL trước khi query thực sự chạy.
| Google Maps | PostgreSQL EXPLAIN |
|---|---|
| Xem route preview — chưa lái | EXPLAIN — xem plan, không chạy query |
| Lái thực + đo time thật | EXPLAIN ANALYZE — chạy thật + đo time |
| Thời gian thực tế vs ước tính | actual time vs cost trong plan |
| Đếm số km thực tế đi | actual rows — số row thực tế qua node |
| Đếm số trạm xăng đi qua | BUFFERS — số page I/O đọc/write |
| Export route data sang JSON | FORMAT JSON — output plan dạng JSON |
EXPLAIN = preview route. EXPLAIN ANALYZE = lái thật + bấm đồng hồ. BUFFERS = đếm trạm xăng (I/O page). Bạn không thể optimize nếu chưa biết route planner chọn đường nào.
2. EXPLAIN vs EXPLAIN ANALYZE — khi nào dùng cái nào
-- Chi xem plan, khong chay query
-- An toan voi SELECT, INSERT, UPDATE, DELETE
EXPLAIN SELECT * FROM tasks WHERE assignee_id = 5;
-- Chay query thuc + do thoi gian thuc te
-- CANH BAO: INSERT/UPDATE/DELETE se thuc su thay doi data!
EXPLAIN ANALYZE SELECT * FROM tasks WHERE assignee_id = 5;
-- Chay thuc + dem I/O page (shared hit/read)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM tasks WHERE assignee_id = 5;
-- Output JSON de paste vao dalibo visualizer
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM tasks WHERE id = 1;
EXPLAIN thuần chỉ hiển thị plan — planner chạy nhưng query không thực thi. An toàn hoàn toàn dù với mutation query.
EXPLAIN ANALYZE chạy query thật để đo actual time và actual rows. Với SELECT: không vấn đề. Với INSERT, UPDATE, DELETE: mutation thật sự xảy ra. Để test plan của mutation query mà không thay đổi data:
-- Wrap EXPLAIN ANALYZE mutation trong transaction roi ROLLBACK
BEGIN;
EXPLAIN ANALYZE UPDATE tasks SET status = 'done' WHERE id = 1;
ROLLBACK;
-- Mutation da chay (EXPLAIN ANALYZE do duoc), nhung transaction rollback
-- Data khong bi thay doi
3. Đọc plan tree bottom-up
PostgreSQL in plan dưới dạng cây thụt đầu dòng. Mỗi cấp indent = 1 node con. Nguyên tắc đọc: từ trong ra ngoài, từ dưới lên trên — leaf node chạy trước, kết quả chuyển lên node cha.
Limit (cost=8.45..8.46 rows=1 width=64)
-> Sort (cost=8.45..8.46 rows=2 width=64)
Sort Key: due_at
-> Index Scan using idx_tasks_assignee on tasks (cost=0.42..8.44 rows=2 width=64)
Index Cond: (assignee_id = 5)
Thứ tự thực thi:
- Index Scan (leaf, trong cùng) — scan index
idx_tasks_assignee, lọcassignee_id = 5, trả row về Sort. - Sort — nhận row từ Index Scan, sort theo
due_at. - Limit (root, ngoài cùng) — nhận row từ Sort, trả đúng số row cần.
Mỗi -> là một node con. Node không có -> là leaf — đây là điểm tiếp xúc thực tế với data (Seq Scan, Index Scan, v.v.). Đọc plan bắt đầu từ leaf node trong cùng, đi ngược ra ngoài.
4. Cost format — startup, total, rows, width
Mỗi node in ra dòng cost:
Index Scan using idx_tasks_assignee on tasks (cost=0.42..8.44 rows=2 width=64)
Bốn con số có nghĩa:
| Field | Giá trị | Ý nghĩa |
|---|---|---|
cost=0.42 | startup cost | Chi phí trước khi trả row đầu tiên |
cost=..8.44 | total cost | Chi phí trả toàn bộ result set |
rows=2 | estimated rows | Planner ước tính bao nhiêu row qua node này |
width=64 | bytes per row | Trung bình số byte mỗi row |
Cost là đơn vị abstract — không phải millisecond. PostgreSQL định nghĩa: 1.0 cost ≈ chi phí đọc 1 sequential page (8 KB). random_page_cost = 4.0 mặc định nghĩa là đọc 1 random page tốn 4x chi phí sequential. Cost chỉ có ý nghĩa so sánh tương đối giữa các node và các plan với nhau — không convert sang ms được.
Startup cost thấp quan trọng với query có LIMIT: planner ưu tiên plan có startup thấp khi có LIMIT 1 vì không cần trả full result. Index Scan thường có startup cost thấp hơn Seq Scan, giúp LIMIT query nhanh hơn đáng kể.
rows ước tính là nơi hay sai nhất. Planner dùng statistics để ước tính — nếu statistics stale hoặc có correlation phức tạp, ước tính có thể sai xa so với thực tế. Phần sau của bài 4 và bài 5 trong module này giải thích cách đọc và fix vấn đề này.
5. EXPLAIN ANALYZE — actual time và loops
Thêm ANALYZE bổ sung thêm dòng actual time và rows thực tế sau mỗi node:
Index Scan using idx_tasks_assignee on tasks
(cost=0.42..8.44 rows=2 width=64)
(actual time=0.012..0.045 rows=3 loops=1)
| Field | Giá trị | Ý nghĩa |
|---|---|---|
actual time=0.012 | startup actual (ms) | Thời gian đến row đầu tiên thực tế |
actual time=..0.045 | total actual (ms) | Thời gian trả full result thực tế |
rows=3 | actual rows | Số row thực tế qua node này |
loops=1 | lần chạy | Node này được thực thi bao nhiêu lần |
Ví dụ trên: estimated 2 row, actual 3 row — sai 1.5x, chấp nhận được. Nếu estimated 1,000,000 mà actual 3 — đó là dấu hiệu planner đang chọn plan sai do statistics stale.
loops > 1 xuất hiện ở Nested Loop — node inner chạy nhiều lần, 1 lần per row của outer:
Nested Loop (cost=0.42..45.32 rows=50 width=72)
(actual time=0.030..2.500 rows=50 loops=1)
-> Index Scan on users (cost=0.28..8.30 rows=10 width=40)
(actual time=0.015..0.080 rows=10 loops=1)
-> Index Scan on tasks (cost=0.42..3.50 rows=5 width=32)
(actual time=0.018..0.024 rows=5 loops=10)
Node tasks chạy 10 lần (loops=10) — 1 lần per row từ users. actual time=0.018..0.024 là time của 1 lần chạy. Tổng time thực tế của node này = 10 × 0.024 ≈ 0.24 ms. Khi đọc actual time của node có loops > 1, nhớ nhân với số loops để có total time thực tế.
6. BUFFERS — đếm I/O page
Option BUFFERS thêm vào report số lần đọc/ghi page vào/từ buffer cache và disk:
Buffers: shared hit=124 read=8 dirtied=0 written=0
| Field | Ý nghĩa | Performance signal |
|---|---|---|
shared hit | Page đọc từ buffer cache (RAM) | Tốt — cache warm |
shared read | Page đọc từ disk (cache miss) | Cảnh báo — disk I/O chậm ~10x |
dirtied | Page đã modify trong buffer | Bình thường cho DML |
written | Page actually written đến disk | Thường 0 cho SELECT |
shared hit cao, shared read thấp = query đang dùng data đã cache trong RAM. shared read cao = query phải đọc nhiều từ disk — nguyên nhân thường gặp của query chậm dù đã có index đúng. Fix: tăng shared_buffers, hoặc query đang access cold dataset lớn hơn RAM.
Ví dụ đọc BUFFERS kết hợp với plan:
Index Scan using idx_tasks_assignee on tasks
(cost=0.42..15.44 rows=200 width=12)
(actual time=0.025..0.180 rows=180 loops=1)
Index Cond: (assignee_id = 5)
Buffers: shared hit=22 read=2
22 page từ cache, 2 page từ disk. Tỷ lệ hit 92% — khá tốt. Nếu thấy read=150 hit=2 với cùng query — dataset lạnh hoặc shared_buffers quá nhỏ để giữ index trong RAM.
BUFFERS chỉ có ý nghĩa kết hợp với ANALYZE — cần chạy query thật mới đo được I/O thực tế.
7. FORMAT JSON và visualizer
PostgreSQL hỗ trợ output plan dưới dạng JSON, YAML, hoặc XML — hữu ích để paste vào tool visualizer:
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT count(*) FROM tasks;
Output JSON paste vào explain.dalibo.com — visualizer hiển thị plan dưới dạng cây có màu, đánh dấu node chậm nhất, và highlight row estimate mismatch. Đặc biệt hữu ích với plan nhiều node phức tạp — dễ đọc hơn TEXT format nhiều.
Format mặc định là TEXT — đủ cho hầu hết trường hợp debug và là format in trong bài này. JSON dùng khi muốn dùng visualizer hoặc parse programmatically.
Workflow nhanh với dalibo:
- Chạy
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <query>;trong psql. - Copy toàn bộ JSON output (bắt đầu từ
[đến]). - Paste vào explain.dalibo.com — visualizer tô màu node chậm nhất, hiển thị actual vs estimated rows side-by-side, và đánh dấu row estimate mismatch bằng màu đỏ.
Dalibo đặc biệt hữu ích với query có 5+ node — TEXT format khó scan mắt; visualizer collapse/expand từng nhánh của plan tree.
8. Pitfall — cost không phải ms, và estimated rows mismatch
Pitfall 1 — cost không phải millisecond: cost=50000 không có nghĩa là 50,000 ms. Cost là đơn vị abstract tương đối trong PostgreSQL (1.0 ≈ 1 sequential page read). Hai plan có cost khác nhau 10x không nhất thiết khác nhau 10x về wall-clock time. Để biết thời gian thực, dùng EXPLAIN ANALYZE và đọc actual time.
Pitfall 2 — estimated rows sai xa là root cause: Khi thấy rows=1000000 actual rows=3, planner đã chọn plan dựa trên ước tính sai hoàn toàn. Seq Scan được chọn thay vì Index Scan vì planner nghĩ có 1 triệu row cần trả — nên Seq Scan có vẻ efficient hơn. Fix không phải force plan — fix là làm cho statistics chính xác (ANALYZE). Bài 4 và bài 5 trong module này đi sâu vào vấn đề này.
Pitfall 3 — EXPLAIN ANALYZE trên prepared statement: Prepared statement với $1 placeholder có thể được plan theo "generic plan" (dùng cho mọi giá trị) thay vì "custom plan" (optimize cho giá trị cụ thể). EXPLAIN output có thể show generic plan với $1 thay vì giá trị literal — plan này có thể không representative với giá trị thực tế. Dùng literal value khi debug để chắc chắn xem custom plan.
-- Vi du estimated vs actual mismatch nghiem trong:
Seq Scan on tasks (cost=0.00..50000.00 rows=1000000 width=64)
(actual time=0.012..2.500 rows=3 loops=1)
-- Planner nghi co 1 trieu row -> chon Seq Scan
-- Thuc te chi co 3 row -> Index Scan se nhanh hon 1000x
-- Root cause: statistics cu (can ANALYZE) hoac cross-column correlation
9. Applied — EXPLAIN ANALYZE BUFFERS trên TaskFlow dashboard query
Query dashboard từ bài 7 Module 2 của khoá này (tasks active của user 5, group by status). Chạy EXPLAIN ANALYZE BUFFERS để đọc toàn bộ plan:
-- Dashboard query (bai 7 Module 2 cua khoa nay)
-- Truoc khi chay, dam bao index ton tai:
-- CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT
status,
count(*) FILTER (WHERE due_at < now()) AS overdue_count,
count(*) FILTER (WHERE due_at >= now() AND due_at < now() + interval '7 days') AS this_week,
count(*) AS total
FROM tasks
WHERE assignee_id = 5
GROUP BY status;
Output mẫu (realistic với dataset nhỏ, index trên assignee_id):
HashAggregate (cost=20.45..20.50 rows=4 width=24)
(actual time=0.350..0.355 rows=3 loops=1)
Group Key: status
Batches: 1 Memory Usage: 24kB
-> Index Scan using idx_tasks_assignee on tasks
(cost=0.42..15.44 rows=200 width=12)
(actual time=0.025..0.180 rows=180 loops=1)
Index Cond: (assignee_id = 5)
Buffers: shared hit=24
Planning Time: 0.150 ms
Execution Time: 0.420 ms
Đọc plan từ trong ra ngoài:
- Index Scan (leaf) — dùng
idx_tasks_assignee, filterassignee_id = 5. Estimated 200 row, actual 180 row — sai ~10%, hoàn toàn chấp nhận được. Thực thi trong 0.025–0.180 ms. - HashAggregate (root) — nhận 180 row từ Index Scan, group by
status, tính cáccount(*) FILTER. Trả về 3 group (status:todo,doing,done). 0.350–0.355 ms. - Buffers: shared hit=24 — toàn bộ 24 page đọc từ buffer cache (RAM),
read=0. Dataset đã warm trong cache — không có disk I/O. - Execution Time: 0.420 ms — tổng thời gian thực tế. Hoàn toàn đạt sub-millisecond.
Plan này tốt: Index Scan đúng, estimated rows gần actual, không có disk I/O, thời gian dưới 1 ms. Nếu thấy Seq Scan thay vì Index Scan ở bước 1 — đó là signal cần điều tra (index chưa có? statistics stale? data volume quá nhỏ để index có lợi?).
Planning Time: 0.150 ms là thời gian planner dành để tạo ra plan — thường nhỏ, chỉ đáng chú ý nếu vượt quá vài chục ms (ví dụ query có nhiều JOIN phức tạp với hàng trăm plan alternative). Execution Time: 0.420 ms là tổng thời gian thực thi — con số này nên dùng để so sánh trước/sau khi thêm index hoặc thay đổi query.
10. Deep Dive
- PostgreSQL Documentation Ch.14.1 "Using EXPLAIN" — official reference đầy đủ: tất cả option (ANALYZE, BUFFERS, SETTINGS, WAL, FORMAT), cách đọc từng loại node, và giải thích cost model. Đọc một lần để nắm hết option; quay lại tra cứu khi gặp node lạ.
- explain.dalibo.com — paste JSON output từ
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)để visualize plan tree, xem time breakdown per node, và highlight slowest node. Miễn phí, không cần đăng ký, hoạt động hoàn toàn client-side (plan không gửi lên server). - PostgreSQL 14 Internals — Egor Rogov, Part IV "Query Execution" — free PDF. Part IV (~80 trang) đi sâu vào executor node types, cost model parameter (
seq_page_cost,random_page_cost,cpu_tuple_cost,cpu_operator_cost), và cách planner chọn giữa các plan alternative. Đọc sau khi đã quen với EXPLAIN output cơ bản.
11. Tóm tắt
EXPLAIN <query>hiển thị plan + estimated cost — không chạy query, an toàn với mọi loại query.EXPLAIN ANALYZE <query>chạy query thật, đoactual timevàactual rowsper node. Với mutation query: wrap trongBEGIN+ROLLBACKnếu chỉ muốn test plan.EXPLAIN (ANALYZE, BUFFERS)thêm I/O accounting:shared hit(từ RAM),shared read(từ disk). Tỷ lệ hit cao = cache healthy; read cao = disk I/O bottleneck.- Plan tree đọc bottom-up: leaf node chạy trước (Seq Scan, Index Scan), kết quả chuyển lên parent (Sort, HashAggregate, Limit).
cost=startup..totallà đơn vị abstract (1.0 ≈ 1 sequential page read) — không phải millisecond. Thời gian thực đọc từactual timecủaEXPLAIN ANALYZE.rows=X(estimated) vsactual rows=Y: sai lệch lớn là root cause của plan sai — planner chọn Seq Scan thay vì Index Scan vì ước tính rows quá cao. Fix quaANALYZEhoặc statistics tuning (bài 4 và bài 5 trong module này).loops=N > 1ở Nested Loop inner node:actual timelà time per 1 lần chạy, nhân vớiloopsđể có total time thực tế.FORMAT JSON+ explain.dalibo.com = visualizer nhanh nhất để đọc plan phức tạp nhiều node.
12. Tự kiểm tra
Q1Sự khác biệt cốt lõi giữa `EXPLAIN` và `EXPLAIN ANALYZE` là gì? Khi nào chỉ nên dùng `EXPLAIN` thuần, và khi nào cần `EXPLAIN ANALYZE`?▸
EXPLAIN chỉ chạy planner — hiển thị plan planner dự định dùng với estimated cost và estimated rows. Query không được thực thi, không có actual time, không có actual rows. An toàn tuyệt đối với mọi loại query kể cả mutation.
EXPLAIN ANALYZE chạy query thật để đo actual time và actual rows per node. Cho phép so sánh estimated vs actual — đây là thông tin quan trọng nhất để biết planner đang ước tính có chính xác không.
Dùng EXPLAIN thuần khi: muốn xem plan nhanh mà không cần chạy query (tiết kiệm thời gian với query chậm), hoặc khi không muốn side effect của mutation. Dùng EXPLAIN ANALYZE khi cần biết actual time và actual rows để chẩn đoán vấn đề thực sự. Với mutation: wrap trong BEGIN ... ROLLBACK.
Q2Plan sau có vấn đề gì? `Seq Scan on tasks (cost=0.00..45000.00 rows=900000 width=64) (actual time=0.010..3.200 rows=5 loops=1)` — và hướng điều tra tiếp theo là gì?▸
Vấn đề rõ ràng: estimated rows sai nghiêm trọng. Planner ước tính 900,000 row nhưng actual chỉ 5 row — sai 180,000x. Vì nghĩ có 900,000 row, planner chọn Seq Scan thay vì Index Scan (Seq Scan efficient hơn khi cần lấy phần lớn bảng). Thực tế chỉ cần 5 row — Index Scan sẽ nhanh hơn rất nhiều.
Hướng điều tra: (1) Chạy ANALYZE tasks; để cập nhật statistics rồi thử lại — nếu statistics stale, planner sẽ ước tính lại chính xác hơn. (2) Kiểm tra có index phù hợp trên column filter không. (3) Nếu sau ANALYZE vẫn sai — có thể có cross-column correlation hoặc data distribution bất thường — cần extended statistics (bài 4 và bài 5 trong module này).
Q3Một node trong EXPLAIN ANALYZE output: `(actual time=0.018..0.024 rows=5 loops=10)`. Total actual time của node này là bao nhiêu? Vì sao loops > 1 xảy ra?▸
Total actual time = 10 × 0.024 ms = 0.24 ms. Giá trị actual time trong EXPLAIN output là time của 1 lần chạy duy nhất (1 loop). Khi loops=10, node chạy 10 lần tổng cộng — total time phải nhân thêm số loops.
loops > 1 xảy ra với node inner của Nested Loop Join: với mỗi row từ outer table, PostgreSQL chạy lại node inner để tìm matching rows. Nếu outer có 10 row, inner node chạy 10 lần (loops=10). Đây là lý do Nested Loop có thể chậm khi outer result set lớn — inner node chạy N lần thay vì 1 lần.
Q4EXPLAIN ANALYZE output có `Buffers: shared hit=5 read=200`. Điều này nói lên điều gì về performance? Có những hướng cải thiện nào?▸
shared hit=5, read=200 nghĩa là 200 trong 205 page phải đọc từ disk — tỷ lệ cache hit chỉ ~2.4%, rất thấp. Query đang gặp disk I/O bottleneck: mỗi page read từ disk chậm hơn từ RAM khoảng 10–100x tùy storage. Đây thường là nguyên nhân query chậm dù đã có index đúng.
Hướng cải thiện: (1) Tăng shared_buffers — nếu dataset fit vào RAM nhiều hơn, lần sau data đã được cache. Mặc định PG thường thấp (128 MB); production thường set 25–40% total RAM. (2) Query đang access cold data — lần đầu chạy sau restart server sẽ có read cao; sau vài lần chạy, data warm trong cache và hit tăng. (3) Dataset quá lớn so với RAM — không thể cache toàn bộ; cân nhắc partition hoặc giới hạn range query.
Q5Vì sao `cost=50000` không có nghĩa là query chạy 50,000 millisecond? Cost trong PostgreSQL là đơn vị gì, và đọc thời gian thực như thế nào?▸
Cost trong PostgreSQL là đơn vị abstract tương đối, không phải millisecond hay bất kỳ đơn vị thời gian nào. Định nghĩa gốc: 1.0 cost ≈ chi phí đọc 1 sequential page (8 KB). Các tham số như random_page_cost=4.0, cpu_tuple_cost=0.01 là multiplier để ước tính relative cost giữa các operation khác nhau.
Cost 50000 có thể tương đương 50 ms hoặc 500 ms tùy hardware (SSD vs HDD, RAM available, server load). Planner dùng cost chỉ để so sánh và chọn giữa các plan alternative — plan cost thấp hơn được chọn, không quan tâm cost translate sang thời gian thực bao nhiêu.
Để biết thời gian thực: chạy EXPLAIN ANALYZE và đọc actual time=X..Y (millisecond) hoặc Execution Time: Z ms ở cuối output. Đây là wall-clock time đo được thực tế.
Q6Tại sao `EXPLAIN ANALYZE UPDATE tasks SET status = 'done' WHERE id = 1` lại nguy hiểm nếu chạy trực tiếp không wrap transaction? Viết lại cách chạy an toàn.▸
EXPLAIN ANALYZE chạy query thật để đo actual time — với UPDATE, điều này có nghĩa là mutation thực sự xảy ra và được commit nếu không có gì ngăn lại. Row id=1 sẽ bị update status thành 'done' vĩnh viễn. Nếu đây là production data hoặc test với data cụ thể, side effect này là vấn đề.
Cách chạy an toàn — wrap trong transaction rồi rollback:
BEGIN;
EXPLAIN ANALYZE UPDATE tasks SET status = 'done' WHERE id = 1;
ROLLBACK;
-- EXPLAIN ANALYZE da chay va do duoc actual time/rows
-- Nhung ROLLBACK huy bo mutation: row id=1 khong bi thay doiSau ROLLBACK, data trở về trạng thái trước transaction. Bạn vẫn nhận được đầy đủ EXPLAIN ANALYZE output với actual time và actual rows — chỉ là mutation bị hoàn tác. Đây là pattern chuẩn khi cần profile DML query mà không muốn side effect.
Bài tiếp theo: Scan strategies — Seq Scan, Index Scan, Index Only Scan, Bitmap Scan
Bài này có giúp bạn hiểu bản chất không?