← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202625 phút đọc21 min read

Query Planning & Performance TuningQuery Planning & Performance Tuning

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Mỗi câu lệnh SQL bạn gửi cho PostgreSQL chỉ mô tả cái bạn muốn, chứ không nói làm thế nào để lấy được nó. Giữa văn bản SQL và các dòng dữ liệu trả về là query planner (còn gọi là optimizer): thành phần trong backend liệt kê các cách thực thi khả dĩ — dùng index nào (nếu có), join các bảng theo thứ tự nào, dùng thuật toán join nào cho mỗi cặp bảng, sắp xếp trước hay sau khi join — rồi chọn phương án mà nó ước tính là rẻ nhất. “Rẻ nhất” được đo bằng một đơn vị chi phí trừu tượng, tính từ statistics: kích thước bảng, phân bố giá trị của các cột, mức độ tương quan giữa thứ tự vật lý của dòng dữ liệu và giá trị cột, cùng các chi phí tương đối (có thể cấu hình) của sequential I/O, random I/O và CPU. Điều quan trọng là planner không bao giờ nhìn vào dữ liệu thật để ra quyết định — nó nhìn vào một bản tóm tắt của dữ liệu được chụp lại lần cuối ANALYZE chạy. Sự khác biệt này chính là nguồn gốc của gần như mọi sự cố kiểu “query này trước nhanh giờ chậm” mà một DBA phải điều tra.

Bài này nói về vòng lặp thực hành mà mọi PostgreSQL DBA lặp lại liên tục: đọc một plan, hiểu vì sao planner chọn nó, xác minh ước tính so với thực tế, rồi sửa nguyên nhân gốc rễ — có thể là thiếu index, statistics cũ, một tham số bộ nhớ cấu hình sai kích thước, hoặc một dạng query khiến planner “bó tay”. EXPLAINEXPLAIN ANALYZE là hai lệnh giúp nhìn thấy plan; pg_stat_statements là công cụ cho biết query nào đáng để dành sự chú ý này ngay từ đầu. Kết hợp lại, chúng biến việc tuning hiệu năng từ đoán mò thành một quy trình chẩn đoán có thể lặp lại.

Kiến thức nền tảng

Planner thực sự làm gì

Với một query cho trước, planner của PostgreSQL:

  1. Parse và viết lại query (mở rộng view, áp dụng rule) thành một cây query.
  2. Sinh ra một tập các plan ứng viên — các tổ hợp của phương thức scan (sequential scan, index scan, index-only scan, bitmap scan), phương thức join (nested loop, hash join, merge join), và thứ tự join.
  3. Ước tính cost của từng ứng viên bằng hai con số: startup cost (công việc phải làm trước khi trả về dòng đầu tiên) và total cost (công việc để trả về toàn bộ dòng), cả hai đều tính theo đơn vị tùy ý, được hiệu chỉnh gần đúng theo “một lần đọc trang tuần tự.”
  4. Chọn plan có total cost thấp nhất (ở mức query cấp cao nhất) và thực thi nó.

Với các query có nhiều hơn một vài bảng, việc thử hết mọi thứ tự join là tốn kém theo cấp số tổ hợp, nên vượt quá join_collapse_limit (mặc định 8) bảng, planner chuyển sang Genetic Query Optimizer (GEQO), tìm kiếm theo kiểu heuristic thay vì duyệt cạn. Điều này đáng biết khi plan của một query join rất rộng dường như thay đổi giữa các lần chạy — GEQO có yếu tố ngẫu nhiên.

Đầu vào của mô hình ước tính chi phí

Mô hình cost dựa trên:

Mọi thứ về sau — chọn index-vs-seqscan, thuật toán join, thứ tự join — đều là phép tính số học trên các đầu vào này. Khi một plan có vẻ sai, cách sửa hầu như luôn là chỉnh lại một trong các đầu vào này (làm mới statistics, thêm index, tăng work_mem) thay vì cố “đấu” với planner bằng hint, vì PostgreSQL cố tình không có cú pháp query hint.

EXPLAIN vs EXPLAIN ANALYZE

EXPLAIN hiển thị plan được chọn cùng chi phí ước tính mà KHÔNG chạy query:

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
                              QUERY PLAN
------------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..104166.00 rows=25 width=97)
   Filter: (customer_id = 42)

EXPLAIN ANALYZE thực sự thực thi câu lệnh (được bọc lại sao cho tác dụng phụ của INSERT/UPDATE/DELETE vẫn xảy ra — hãy bọc trong transaction rồi rollback nếu không muốn vậy) và báo cáo thời gian, số dòng thực tế bên cạnh cùng các ước tính chi phí:

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
                                              QUERY PLAN
--------------------------------------------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=25 width=97)
                                                     (actual time=0.031..0.048 rows=23 loops=1)
   Index Cond: (customer_id = 42)
 Planning Time: 0.112 ms
 Execution Time: 0.071 ms

(Khi có index, planner đã chuyển từ Seq Scan sang Index Scan — xem phần “Đọc một plan thực tế” bên dưới để hiểu vì sao.)

Đọc dòng cost (cost=0.43..8.45 rows=25 width=97):

TrườngÝ nghĩa
0.43 (startup cost)Chi phí ước tính trước khi dòng đầu tiên được trả về — với index scan, chủ yếu là chi phí đi xuống B-tree
8.45 (total cost)Chi phí ước tính để trả về toàn bộ số dòng mà node này sinh ra
rows=25Số dòng ước tính node này sẽ trả ra
width=97Độ rộng trung bình ước tính của một dòng, tính bằng byte

Và trong dòng actual (actual time=0.031..0.048 rows=23 loops=1):

TrườngÝ nghĩa
0.031Số mili-giây thực tế cho tới khi dòng đầu tiên sẵn sàng
0.048Số mili-giây thực tế cho tới khi toàn bộ dòng được trả về
rows=23Số dòng thực tế được sinh ra
loops=1Số lần node này được thực thi (>1 với vế trong của một nested loop)

Thói quen quan trọng nhất khi đọc output EXPLAIN ANALYZE là so sánh rows ước tính với rows thực tế trên cùng một node. rows=25 ước tính so với rows=23 thực tế (ở trên) là không đáng lo — đủ gần để cho thấy lựa chọn của planner là hợp lý. Một node hiện rows=50 ước tính nhưng rows=125000 thực tế thì không phải sai số làm tròn; nó nghĩa là planner đã có một bức tranh sai lệch cơ bản về dữ liệu khi chọn plan này, và đây là dấu hiệu phổ biến nhất của vấn đề statistics — cũ, quá thô, hoặc (với các cột tương quan nhau) đơn giản là không thể mô hình hóa được nếu thiếu extended statistics. Hãy truy tìm sự lệch pha này trước khi động vào bất kỳ tham số nào khác.

Khái niệm chính

Đọc một plan thực tế: các loại node scan

Xét một bảng orders với 5.000.000 dòng, có status text, customer_id int, created_at timestamptz, total_cents int, và có index trên customer_idstatus.

Seq Scan — đọc mọi trang của bảng/heap và lọc trong bộ nhớ. Đúng đắn khi phần lớn dòng đều thỏa điều kiện, hoặc bảng đủ nhỏ để chi phí random I/O của index không đáng:

EXPLAIN ANALYZE SELECT * FROM orders WHERE total_cents > 0;
 Seq Scan on orders  (cost=0.00..104166.00 rows=4998211 width=97) (actual time=0.011..612.334 rows=5000000 loops=1)
   Filter: (total_cents > 0)
 Planning Time: 0.065 ms
 Execution Time: 845.221 ms

Index Scan — đi xuống B-tree để tìm các TID khớp, rồi lấy từng dòng khớp từ heap. Tốt nhất khi ít dòng thỏa điều kiện:

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=25 width=97) (actual time=0.031..0.048 rows=23 loops=1)
   Index Cond: (customer_id = 42)

Index Only Scan — giống Index Scan, nhưng mọi cột được yêu cầu đều đã có sẵn trong chính index, nên không cần truy cập heap chút nào miễn là visibility map đánh dấu các trang đó “all visible” (các phiên bản dòng ở đó được đảm bảo nhìn thấy được với mọi transaction, tức không bị update/delete gần đây):

EXPLAIN ANALYZE SELECT customer_id FROM orders WHERE customer_id = 42;
 Index Only Scan using idx_orders_customer_id on orders  (cost=0.43..4.45 rows=25 width=4) (actual time=0.020..0.028 rows=23 loops=1)
   Index Cond: (customer_id = 42)
   Heap Fetches: 0
 Planning Time: 0.078 ms
 Execution Time: 0.045 ms

Heap Fetches: 0 nghĩa là visibility map đã làm tốt nhiệm vụ. Nếu Heap Fetches cao trên một bảng có nhiều thao tác ghi gần đây, thường là do autovacuum chưa kịp bắt kịp — chạy VACUUM để xây lại visibility map.

Bitmap Heap Scan / Bitmap Index Scan — một giải pháp trung gian cho độ chọn lọc vừa phải: xây một bitmap trong bộ nhớ đánh dấu các trang heap khớp từ index (Bitmap Index Scan), rồi truy cập các trang đó một lần theo thứ tự vật lý (Bitmap Heap Scan), tránh cả full table scan lẫn random I/O theo từng dòng:

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'shipped';
 Bitmap Heap Scan on orders  (cost=4523.28..98219.10 rows=249876 width=97) (actual time=18.223..612.884 rows=248112 loops=1)
   Recheck Cond: (status = 'shipped'::text)
   Heap Blocks: exact=45213
   ->  Bitmap Index Scan on idx_orders_status  (cost=0.00..4460.79 rows=249876 width=0) (actual time=17.902..17.902 rows=248112 loops=1)
         Index Cond: (status = 'shipped'::text)
 Planning Time: 0.089 ms
 Execution Time: 625.451 ms

Nếu bitmap cần theo dõi nhiều trang hơn mức work_mem cho phép, PostgreSQL sẽ giảm cấp một cách “mềm” sang bitmap lossy (ở mức trang thay vì mức dòng), buộc phải Recheck Cond với mọi dòng trên trang đó — thể hiện qua Heap Blocks: exact=N lossy=M với M > 0, một triệu chứng khác cũng bắt nguồn từ work_mem.

Thuật toán join

Nested Loop — với mỗi dòng của vế ngoài, quét lại (hoặc dò lại index trên) vế trong. Rẻ khi vế ngoài nhỏ, vì chi phí mỗi dòng của vế trong chỉ phải trả một số ít lần:

EXPLAIN ANALYZE
SELECT c.name, o.total_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.id = 42;
 Nested Loop  (cost=0.86..12.94 rows=25 width=40) (actual time=0.045..0.089 rows=23 loops=1)
   ->  Index Scan using customers_pkey on customers c  (cost=0.43..2.45 rows=1 width=20) (actual time=0.020..0.021 rows=1 loops=1)
         Index Cond: (id = 42)
   ->  Index Scan using idx_orders_customer_id on orders o  (cost=0.43..8.45 rows=25 width=24) (actual time=0.021..0.056 rows=23 loops=1)
         Index Cond: (customer_id = 42)
 Planning Time: 0.145 ms
 Execution Time: 0.118 ms

Nested loop trở nên thảm họa khi ước tính số dòng của vế ngoài sai lệch nhiều bậc độ lớn — một ước tính loops=1 mà thực tế thành loops=48000 nghĩa là vế trong (nhìn có vẻ rẻ) giờ phải chạy tới 48.000 lần.

Hash Join — xây một hash table trong bộ nhớ trên vế (thường nhỏ hơn), rồi dò một lần cho mỗi dòng của vế còn lại. Tốt cho các equi-join lớn khi không có sẵn thứ tự sắp xếp nào hữu ích:

EXPLAIN ANALYZE
SELECT c.region, sum(o.total_cents)
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.region;
 HashAggregate  (cost=195432.50..195432.62 rows=8 width=40) (actual time=1820.442..1820.448 rows=8 loops=1)
   Group Key: c.region
   Batches: 1  Memory Usage: 24kB
   ->  Hash Join  (cost=3211.00..170432.50 rows=5000000 width=12) (actual time=32.110..1654.223 rows=5000000 loops=1)
         Hash Cond: (o.customer_id = c.id)
         ->  Seq Scan on orders o  (cost=0.00..104166.00 rows=5000000 width=8) (actual time=0.010..421.556 rows=5000000 loops=1)
         ->  Hash  (cost=1929.00..1929.00 rows=100000 width=12) (actual time=31.980..31.981 rows=100000 loops=1)
               Buckets: 131072  Batches: 1  Memory Usage: 5905kB
               ->  Seq Scan on customers c  (cost=0.00..1929.00 rows=100000 width=12) (actual time=0.008..14.223 rows=100000 loops=1)
 Planning Time: 0.210 ms
 Execution Time: 1820.601 ms

Chú ý Batches trong một node Hash: Batches: 1 nghĩa là hash table vừa hoàn toàn trong work_mem. Batches: 4 (hoặc nhiều hơn) nghĩa là không vừa và PostgreSQL đã chia hash thành các partition tràn ra đĩa — chậm hơn, và là một tín hiệu mạnh để tăng work_mem cho workload này.

Merge Join — cả hai vế đã (hoặc được làm cho) sắp xếp theo khóa join, và phép join duyệt song song hai bên như khóa kéo. Tốt khi cả hai vế đã sẵn sàng sắp xếp (ví dụ nhờ một index cung cấp thứ tự đó miễn phí), tránh phải thêm bước sort riêng:

EXPLAIN ANALYZE
SELECT c.name, o.total_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id
ORDER BY c.id;
 Merge Join  (cost=1.13..215432.88 rows=5000000 width=40) (actual time=0.055..1980.221 rows=5000000 loops=1)
   Merge Cond: (c.id = o.customer_id)
   ->  Index Scan using customers_pkey on customers c  (cost=0.29..3859.29 rows=100000 width=20) (actual time=0.020..18.556 rows=100000 loops=1)
   ->  Index Scan using idx_orders_customer_id on orders o  (cost=0.43..185432.50 rows=5000000 width=24) (actual time=0.018..1502.331 rows=5000000 loops=1)
 Planning Time: 0.180 ms
 Execution Time: 2105.442 ms

Nếu không vế nào tự nhiên đã sắp xếp, planner sẽ chèn thêm node Sort tường minh bên dưới Merge Join — lúc đó nó đang cạnh tranh về chi phí với Hash Join, và planner chọn phương án nào thực sự rẻ hơn với khối lượng dữ liệu đó.

Trực quan hóa plan

Output văn bản thô của EXPLAIN ANALYZE rất khó đọc khi plan có hơn năm sáu node lồng nhau nhiều tầng. Hai công cụ cộng đồng giúp việc này dễ dàng hơn hẳn, và việc dán output vào một trong hai công cụ này nên là phản xạ trong bất kỳ buổi tuning thực tế nào:

Luôn thu thập plan bằng EXPLAIN (ANALYZE, BUFFERS) (thay vì EXPLAIN ANALYZE trần) trước khi dán vào một trong hai công cụ trên — tùy chọn BUFFERS thêm Buffers: shared hit=… read=… cho mỗi node, cho biết một node chậm là do công việc CPU, do đọc đĩa (read), hay thực ra chạy nhanh và được phục vụ hoàn toàn từ shared_buffers (hit). Đây thường là ranh giới giữa “node này chậm vì cần index” và “node này chậm vì đang đọc dữ liệu nguội từ đĩa,” hai tình huống đòi hỏi cách sửa hoàn toàn khác nhau.

Statistics và ANALYZE

Mọi quyết định lập kế hoạch ở trên cuối cùng đều dựa vào pg_stats:

SELECT attname, n_distinct, most_common_vals, most_common_freqs, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
 attname |      n_distinct       |              most_common_vals               |         most_common_freqs          | correlation
---------+------------------------+----------------------------------------------+-------------------------------------+-------------
 status  |                      6 | {shipped,delivered,pending,cancelled,refunded,returned} | {0.42,0.31,0.14,0.08,0.03,0.02} |        0.11

Những con số này chỉ là một ảnh chụp, được làm mới bởi ANALYZE (một lượt quét nhẹ, dựa trên lấy mẫu — rẻ hơn nhiều so với VACUUM) hoặc bởi tính năng auto-analyze tự động của autovacuum khi đủ số dòng đã thay đổi (điều khiển bởi autovacuum_analyze_scale_factor/autovacuum_analyze_threshold). default_statistics_target (mặc định 100, có thể đặt toàn server hoặc theo từng cột qua ALTER TABLE ... ALTER COLUMN ... SET STATISTICS n) quyết định ANALYZE lấy mẫu bao nhiêu dòng và danh sách most-common-values/histogram lớn tới đâu — tăng giá trị này (ví dụ lên 500 hoặc 1000) trên một vài cột được biết là có phân bố lệch là một cách tuning phổ biến, ít rủi ro; tăng giá trị này toàn cục khiến mọi ANALYZE chậm hơn và mọi plan tốn thêm chút chi phí để tạo ra.

Kịch bản thất bại kinh điển: một đợt bulk load hoặc một UPDATE/DELETE lớn làm thay đổi hình dạng dữ liệu (một giá trị status mới giờ chiếm áp đảo, hoặc một bảng từ 10 nghìn dòng nhảy lên 10 triệu dòng) nhưng statistics vẫn phản ánh hình dạng cũ cho tới khi ngưỡng auto-analyze bị vượt qua — trên một bảng khổng lồ, điều này có thể xảy ra rất lâu sau khi đợt load kết thúc. Trong lúc đó, planner vẫn đang chọn plan cho dữ liệu không còn tồn tại nữa:

-- Ngay sau khi load 4.9 triệu đơn 'refunded', trước khi ANALYZE chạy
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'refunded';
 Seq Scan on orders  (cost=0.00..104166.00 rows=50 width=97) (actual time=0.015..523.223 rows=125000 loops=1)
   Filter: (status = 'refunded'::text)
   Rows Removed by Filter: 4875000
 Planning Time: 0.095 ms
 Execution Time: 545.667 ms

Ước tính rows=50, thực tế rows=125000 — sai lệch 2.500 lần, đúng là kiểu lệch pha ước-tính-so-với-thực-tế được nhắc ở trên như dấu hiệu đặc trưng của vấn đề statistics. Cách sửa mang tính quy trình, không phải cấu trúc: chạy ANALYZE orders; (hoặc ANALYZE; cho cả database) ngay sau bất kỳ đợt bulk load, update/delete hàng loạt, hay attach/detach partition nào, thay vì chờ autovacuum tự nhận ra. Việc này chỉ tốn vài giây tới vài phút và có thể biến một query mất nhiều giây trở lại thành query dưới một mili-giây, không cần thay đổi schema.

pg_stat_statements: tìm ra cái gì cần tuning

Đọc từng plan riêng lẻ chỉ hữu ích khi bạn đã biết query nào cần nhìn. pg_stat_statements theo dõi số liệu thực thi cho mọi query khác biệt đã được chuẩn hóa (hằng số được thay bằng placeholder như $1) trên toàn bộ server, và đây là nơi đầu tiên một DBA có kinh nghiệm nhìn vào — không phải phương án cuối cùng sau khi đoán mò.

Bật extension này (cần khởi động lại server vì nó cần shared memory):

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION pg_stat_statements;

Tìm các query tiêu tốn tổng thời gian nhiều nhất trên server — thường là điểm bắt đầu có đòn bẩy cao nhất, vì nó tính đến cả chi phí mỗi lần gọi lẫn tần suất gọi:

SELECT
  query,
  calls,
  round(total_exec_time::numeric, 2) AS total_ms,
  round(mean_exec_time::numeric, 2)  AS mean_ms,
  round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
                          query                              |  calls  | total_ms  | mean_ms | pct_total
---------------------------------------------------------------+---------+-----------+---------+-----------
 SELECT * FROM orders WHERE customer_id = $1                   |  812340 | 214532.10 |    0.26 |     18.20
 UPDATE orders SET status = $1 WHERE id = $2                    |   45210 | 189213.55 |    4.19 |     16.04
 SELECT c.*, o.* FROM customers c JOIN orders o ON ...          |    3211 | 165442.80 |   51.53 |     14.02
 SELECT * FROM orders WHERE status = $1                         |    1876 |  98221.44 |   52.35 |      8.33
 DELETE FROM sessions WHERE expires_at < $1                     |     512 |  87023.11 |  170.00 |      7.38

Hai query rất khác nhau có thể cùng chiếm ưu thế trong total_ms vì hai lý do trái ngược: dòng đầu tiên ở đây được gọi 812.340 lần với trung bình chỉ 0,26 ms — bản thân nó hoàn toàn ổn, nhưng đáng để xác nhận nó thực sự đang dùng idx_orders_customer_id. Dòng cuối chỉ được gọi 512 lần nhưng trung bình 170 ms — dòng đó đáng được EXPLAIN ANALYZE ngay lập tức. Sắp xếp theo mean_exec_time DESC thay vì total_exec_time sẽ đưa lên các query tệ nhất theo mỗi lần gọi, hữu ích để bắt được một query báo cáo cực chậm nhưng không được gọi đủ nhiều để chiếm ưu thế trong bảng xếp hạng tổng thời gian. pg_stat_statements_reset() xóa các bộ đếm, hữu ích trước một bài load test hoặc sau khi sửa lỗi để đo “trước/sau” một cách sạch sẽ.

Anti-pattern phổ biến và cách sửa

Anti-patternTriệu chứng trong EXPLAINCách sửa
Thiếu indexSeq Scan với Filter có độ chọn lọc cao trên bảng lớnThêm index B-tree (hoặc loại phù hợp) — xem Indexing Strategies
SELECT *width= lớn dù chỉ 2 cột được dùng ở tầng sau; chặn Index Only ScanChỉ select các cột cần thiết; cho phép index hẹp hơn “cover” được query
Ép kiểu ngầm định (implicit cast)Filter hiện (customer_phone)::text = '12345'::text hoặc cast bọc quanh cột thay vì literalKhớp kiểu dữ liệu giữa cột và literal một cách tường minh (WHERE customer_phone = '12345', không so sánh text với integer) để cast không làm mất tác dụng của index
Hàm áp lên cột có indexFilter: (lower(email) = 'foo@bar.com'::text) cùng Seq Scan dù có index trên emailTạo expression index: CREATE INDEX ON customers (lower(email));
Mẫu N+1 từ code ứng dụngpg_stat_statements cho thấy một query đơn dòng tham số hóa (WHERE id = $1) với calls cực lớnGom nhóm bằng WHERE id = ANY($1::int[]), một JOIN, hoặc eager-loading trong ORM

Anti-pattern ép kiểu ngầm định dễ bị bỏ qua vì query vẫn “chạy được” — nó chỉ âm thầm ngừng dùng index đáng lẽ phải dùng. So sánh:

EXPLAIN SELECT * FROM orders WHERE customer_id = '42';   -- literal text, cột int
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=25 width=97)
   Index Cond: (customer_id = 42)

PostgreSQL thường đủ thông minh để gấp (fold) cast của literal ngay tại thời điểm lập plan (như trên), nhưng điều đó không được đảm bảo với cast áp lên chính cột, hay với so sánh khác kiểu mà planner không thể rút gọn thành hằng số — những trường hợp đó thường xuất hiện dưới dạng điều kiện Filter: (được đánh giá từng dòng sau khi scan) thay vì Index Cond: (dùng để dẫn dắt scan), đây chính là dấu hiệu cần chú ý khi đọc output EXPLAIN.

Cấu hình bộ nhớ và cache

Ba tham số ảnh hưởng đến việc một plan có thể thực thi hiệu quả hay không, kể cả khi bản thân lựa chọn plan đã đúng:

EXPLAIN ANALYZE SELECT * FROM orders ORDER BY total_cents;
 Sort  (cost=712345.12..724845.12 rows=5000000 width=97) (actual time=4521.223..5890.114 rows=5000000 loops=1)
   Sort Key: total_cents
   Sort Method: external merge  Disk: 512000kB
   ->  Seq Scan on orders  (cost=0.00..104166.00 rows=5000000 width=97) (actual time=0.012..430.221 rows=5000000 loops=1)
 Planning Time: 0.088 ms
 Execution Time: 6102.556 ms

Sort Method: external merge Disk: 512000kB chính là bằng chứng rõ ràng — thao tác sort không vừa trong work_mem và đã tràn khoảng 500 MB ra file tạm trên đĩa. Tăng work_mem cho session (hoặc toàn cục, một cách thận trọng) sẽ khắc phục:

SET work_mem = '256MB';
 Sort  (cost=712345.12..724845.12 rows=5000000 width=97) (actual time=2210.556..2890.221 rows=5000000 loops=1)
   Sort Key: total_cents
   Sort Method: quicksort  Memory: 665432kB
   ->  Seq Scan on orders  (cost=0.00..104166.00 rows=5000000 width=97) (actual time=0.011..415.223 rows=5000000 loops=1)
 Planning Time: 0.075 ms
 Execution Time: 3102.556 ms

Ý tưởng tương tự áp dụng cho Batches: N (N > 1) trên một node Hash đã thấy ở trên — đó là hash table tràn ra đĩa vì cùng nguyên nhân gốc rễ.

Best Practices

Luôn dùng pg_stat_statements trước khi mở riêng lẻ một query — nó cho biết một cách khách quan query nào đáng để dành thời gian, thay vì tuning bất kỳ query nào bạn tình cờ đang nhìn vào. Khi đã có ứng viên, chạy EXPLAIN (ANALYZE, BUFFERS) — không phải EXPLAIN trần — vì chi phí ước tính đơn thuần không cho biết plan có thực sự chậm hay không, hay chậm vì disk I/O, CPU, hay vì hình dạng plan sai; dán output vào explain.dalibo.com hoặc explain.depesz.com ngay khi plan có nhiều hơn vài node, thay vì cố nhìn thụt lề bằng mắt. So sánh số dòng ước tính với thực tế trên mọi node trước khi kết luận bất cứ điều gì về thứ tự join hay lựa chọn scan — một plan “nhìn có vẻ sai” thường lại là một plan đúng đắn với statistics mà planner có trong tay, và cách sửa thực sự là ANALYZE, không phải viết lại query.

Đưa ANALYZE vào runbook vận hành, không phải một việc làm thêm khi rảnh: chạy tường minh sau các đợt bulk load, các batch UPDATE/DELETE lớn, khôi phục một bản dump, và attach/detach partition — đừng dựa vào lịch trình của autovacuum cho các sự kiện đơn lẻ này trên bảng lớn, vì độ trễ trước khi nó kích hoạt có thể khiến planner làm việc với statistics cũ hàng giờ liền. Coi việc tuning work_mem là chuyện đặc thù theo workload và thận trọng trên các server OLTP có độ đồng thời cao (tham số này nhân lên theo mỗi sort/hash đồng thời, và một lần tăng với thiện chí có thể đẩy server vào tình trạng swap); thường an toàn hơn khi tăng theo session hoặc theo role cho các query báo cáo nặng đã biết trước (ALTER ROLE reporting_user SET work_mem = '256MB';) thay vì tăng toàn cục. Đừng bao giờ cố “ép” hình dạng plan bằng query hint — PostgreSQL cố tình không hỗ trợ chúng, chính xác để giữ áp lực vào việc sửa đầu vào thực sự (statistics, index, schema) thay vì che giấu triệu chứng. Cuối cùng, coi các anti-pattern như ép kiểu ngầm định và hàm áp lên cột có index là hạng mục code review, không chỉ là việc dọn dẹp của DBA: bắt được WHERE lower(email) = ... chạy trên một index thường, hay một so sánh khác kiểu text/int, trước khi lên production rẻ hơn rất nhiều so với chẩn đoán nó nhiều tháng sau trong production.

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

Every SQL statement you send to PostgreSQL is a description of what you want, not how to get it. Between the SQL text and the rows that come back sits the query planner (also called the optimizer): a piece of the backend that enumerates plausible ways to execute the query — which index to use, if any; which order to join tables in; which join algorithm to use for each pair; whether to sort before or after a join — and picks the one it estimates will be cheapest. “Cheapest” is measured in an abstract cost unit computed from statistics: table sizes, column value distributions, correlation between physical row order and column values, and the (configurable) relative costs of sequential I/O, random I/O, and CPU work. Crucially, the planner never looks at your actual data to make this decision — it looks at a summary of your data captured the last time ANALYZE ran. That distinction is the source of almost every “the query used to be fast and now it’s slow” incident a DBA will investigate.

This note is about the practical loop every PostgreSQL DBA repeats constantly: read a plan, understand why the planner chose it, verify the estimate against reality, and fix the underlying cause — a missing index, stale statistics, a poorly-sized memory setting, or a query shape that defeats the planner outright. EXPLAIN and EXPLAIN ANALYZE are the two commands that make plans visible; pg_stat_statements is the tool that tells you which queries deserve this attention in the first place. Together they turn performance tuning from guesswork into a repeatable diagnostic process.

Fundamentals

What the planner actually does

For a given query, PostgreSQL’s planner:

  1. Parses and rewrites the query (view expansion, rule application) into a query tree.
  2. Generates a set of candidate plans — combinations of scan methods (sequential scan, index scan, index-only scan, bitmap scan), join methods (nested loop, hash join, merge join), and join orders.
  3. Estimates the cost of each candidate using two numbers: a startup cost (work done before the first row can be returned) and a total cost (work done to return all rows), both in arbitrary units roughly calibrated to “one sequential page fetch.”
  4. Chooses the plan with the lowest total cost (for the top-level query) and executes it.

For queries with more than a handful of tables, exhaustively trying every join order is combinatorially expensive, so beyond join_collapse_limit (default 8) tables the planner switches to a Genetic Query Optimizer (GEQO) that heuristically searches the space instead of exhausting it. This is worth knowing when a very wide join query’s plan seems to change between runs — GEQO involves randomness.

Cost estimation inputs

The cost model draws on:

Everything downstream — index-vs-seqscan choices, join algorithm, join order — is arithmetic over these inputs. When a plan looks wrong, the fix is almost always to correct one of these inputs (refresh statistics, add an index, raise work_mem) rather than to fight the planner with hints, since PostgreSQL deliberately has no query hint syntax.

EXPLAIN vs EXPLAIN ANALYZE

EXPLAIN shows the chosen plan and its estimated cost without running the query at all:

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
                              QUERY PLAN
------------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..104166.00 rows=25 width=97)
   Filter: (customer_id = 42)

EXPLAIN ANALYZE actually executes the statement (wrapped so side effects of INSERT/UPDATE/DELETE still happen — wrap in a transaction and roll back if you don’t want that) and reports real timings and row counts alongside the same cost estimates:

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
                                              QUERY PLAN
--------------------------------------------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=25 width=97)
                                                     (actual time=0.031..0.048 rows=23 loops=1)
   Index Cond: (customer_id = 42)
 Planning Time: 0.112 ms
 Execution Time: 0.071 ms

(Once an index existed, the planner switched from Seq Scan to Index Scan — see “Reading a real plan” below for why.)

Reading the cost line (cost=0.43..8.45 rows=25 width=97):

FieldMeaning
0.43 (startup cost)Estimated cost before the first row is returned — for an index scan, mostly the cost of descending the B-tree
8.45 (total cost)Estimated cost to return all rows this node produces
rows=25Estimated number of rows this node will emit
width=97Estimated average row width in bytes

And in the actual line (actual time=0.031..0.048 rows=23 loops=1):

FieldMeaning
0.031Real milliseconds until the first row was ready
0.048Real milliseconds until all rows were returned
rows=23Real number of rows produced
loops=1Number of times this node executed (>1 for the inner side of a nested loop)

The single most important habit when reading EXPLAIN ANALYZE output is comparing estimated rows against actual rows on the same node. rows=25 estimated vs. rows=23 actual (above) is a non-issue — close enough that the planner’s choice was sound. A node showing rows=50 estimated against rows=125000 actual is not a rounding error; it means the planner had a fundamentally wrong picture of the data when it chose this plan, and it is the single most common sign of a statistics problem — stale, too coarse, or (for correlated columns) simply un-modelable without extended statistics. Chase that divergence before touching any other knob.

Key Concepts

Reading a real plan: scan node types

Take a 5,000,000-row orders table with status text, customer_id int, created_at timestamptz, total_cents int, and indexes on customer_id and status.

Seq Scan — reads every page of the table/heap and filters in memory. Correct when a large fraction of rows qualify, or the table is small enough that random I/O overhead from an index isn’t worth it:

EXPLAIN ANALYZE SELECT * FROM orders WHERE total_cents > 0;
 Seq Scan on orders  (cost=0.00..104166.00 rows=4998211 width=97) (actual time=0.011..612.334 rows=5000000 loops=1)
   Filter: (total_cents > 0)
 Planning Time: 0.065 ms
 Execution Time: 845.221 ms

Index Scan — descends a B-tree to find matching TIDs, then fetches each matching row from the heap individually. Best when few rows qualify:

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=25 width=97) (actual time=0.031..0.048 rows=23 loops=1)
   Index Cond: (customer_id = 42)

Index Only Scan — like an Index Scan, but every requested column exists in the index itself, so the heap doesn’t need to be visited at all provided the visibility map marks those pages “all visible” (the row versions there are guaranteed visible to every transaction, i.e. not recently updated/deleted):

EXPLAIN ANALYZE SELECT customer_id FROM orders WHERE customer_id = 42;
 Index Only Scan using idx_orders_customer_id on orders  (cost=0.43..4.45 rows=25 width=4) (actual time=0.020..0.028 rows=23 loops=1)
   Index Cond: (customer_id = 42)
   Heap Fetches: 0
 Planning Time: 0.078 ms
 Execution Time: 0.045 ms

Heap Fetches: 0 means the visibility map did its job. A high Heap Fetches count on a table with many recent writes usually means autovacuum hasn’t caught up — run VACUUM to rebuild the visibility map.

Bitmap Heap Scan / Bitmap Index Scan — a middle ground for medium selectivity: build an in-memory bitmap of matching heap pages from the index (Bitmap Index Scan), then visit those pages once each in physical order (Bitmap Heap Scan), avoiding both a full table scan and per-row random I/O:

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'shipped';
 Bitmap Heap Scan on orders  (cost=4523.28..98219.10 rows=249876 width=97) (actual time=18.223..612.884 rows=248112 loops=1)
   Recheck Cond: (status = 'shipped'::text)
   Heap Blocks: exact=45213
   ->  Bitmap Index Scan on idx_orders_status  (cost=0.00..4460.79 rows=249876 width=0) (actual time=17.902..17.902 rows=248112 loops=1)
         Index Cond: (status = 'shipped'::text)
 Planning Time: 0.089 ms
 Execution Time: 625.451 ms

If the bitmap would need to track more pages than work_mem allows, PostgreSQL degrades gracefully to a lossy bitmap (page-level rather than row-level), which forces a Recheck Cond against every row on that page — visible as Heap Blocks: exact=N lossy=M with M > 0, another symptom that traces back to work_mem.

Join algorithms

Nested Loop — for every row of the outer input, re-scan (or re-probe an index on) the inner input. Cheap when the outer side is small, because the per-row cost of the inner side is paid few times:

EXPLAIN ANALYZE
SELECT c.name, o.total_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.id = 42;
 Nested Loop  (cost=0.86..12.94 rows=25 width=40) (actual time=0.045..0.089 rows=23 loops=1)
   ->  Index Scan using customers_pkey on customers c  (cost=0.43..2.45 rows=1 width=20) (actual time=0.020..0.021 rows=1 loops=1)
         Index Cond: (id = 42)
   ->  Index Scan using idx_orders_customer_id on orders o  (cost=0.43..8.45 rows=25 width=24) (actual time=0.021..0.056 rows=23 loops=1)
         Index Cond: (customer_id = 42)
 Planning Time: 0.145 ms
 Execution Time: 0.118 ms

Nested loops turn catastrophic when the planner’s row estimate for the outer side is wrong by orders of magnitude — a loops=1 estimate that turns into loops=48000 in reality means the (cheap-looking) inner scan is now happening 48,000 times.

Hash Join — build an in-memory hash table on the (usually smaller) side, then probe it once per row of the other side. Good for large equi-joins with no useful sort order already available:

EXPLAIN ANALYZE
SELECT c.region, sum(o.total_cents)
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.region;
 HashAggregate  (cost=195432.50..195432.62 rows=8 width=40) (actual time=1820.442..1820.448 rows=8 loops=1)
   Group Key: c.region
   Batches: 1  Memory Usage: 24kB
   ->  Hash Join  (cost=3211.00..170432.50 rows=5000000 width=12) (actual time=32.110..1654.223 rows=5000000 loops=1)
         Hash Cond: (o.customer_id = c.id)
         ->  Seq Scan on orders o  (cost=0.00..104166.00 rows=5000000 width=8) (actual time=0.010..421.556 rows=5000000 loops=1)
         ->  Hash  (cost=1929.00..1929.00 rows=100000 width=12) (actual time=31.980..31.981 rows=100000 loops=1)
               Buckets: 131072  Batches: 1  Memory Usage: 5905kB
               ->  Seq Scan on customers c  (cost=0.00..1929.00 rows=100000 width=12) (actual time=0.008..14.223 rows=100000 loops=1)
 Planning Time: 0.210 ms
 Execution Time: 1820.601 ms

Watch Batches in a Hash node: Batches: 1 means the hash table fit entirely in work_mem. Batches: 4 (or more) means it didn’t and PostgreSQL split the hash into partitions spilled to disk — slower, and a strong signal to raise work_mem for this workload.

Merge Join — both inputs are (or are made) sorted on the join key, and the join walks them in lockstep like a zipper. Good when both sides are already sorted (e.g., via an index that provides the order for free), avoiding a separate sort step:

EXPLAIN ANALYZE
SELECT c.name, o.total_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id
ORDER BY c.id;
 Merge Join  (cost=1.13..215432.88 rows=5000000 width=40) (actual time=0.055..1980.221 rows=5000000 loops=1)
   Merge Cond: (c.id = o.customer_id)
   ->  Index Scan using customers_pkey on customers c  (cost=0.29..3859.29 rows=100000 width=20) (actual time=0.020..18.556 rows=100000 loops=1)
   ->  Index Scan using idx_orders_customer_id on orders o  (cost=0.43..185432.50 rows=5000000 width=24) (actual time=0.018..1502.331 rows=5000000 loops=1)
 Planning Time: 0.180 ms
 Execution Time: 2105.442 ms

If neither input is naturally sorted, the planner will insert explicit Sort nodes below a Merge Join — at which point it’s competing against a Hash Join’s cost, and the planner picks whichever is actually cheaper for that data volume.

Visualizing plans

Raw EXPLAIN ANALYZE text is hard to scan once a plan has more than five or six nodes nested several levels deep. Two community tools make this dramatically easier, and pasting output into one of them should be a reflex during any real tuning session:

Always capture plans with EXPLAIN (ANALYZE, BUFFERS) (rather than bare EXPLAIN ANALYZE) before pasting into either tool — the BUFFERS option adds Buffers: shared hit=… read=… per node, which tells you whether a slow node was slow due to CPU work, disk reads (read), or was actually fast and served entirely from shared_buffers (hit). This is frequently the difference between “this node is slow because it needs an index” and “this node is slow because it’s reading cold data from disk,” which call for very different fixes.

Statistics and ANALYZE

Every planning decision above ultimately rests on pg_stats:

SELECT attname, n_distinct, most_common_vals, most_common_freqs, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
 attname |      n_distinct       |              most_common_vals               |         most_common_freqs          | correlation
---------+------------------------+----------------------------------------------+-------------------------------------+-------------
 status  |                      6 | {shipped,delivered,pending,cancelled,refunded,returned} | {0.42,0.31,0.14,0.08,0.03,0.02} |        0.11

These numbers are only a snapshot, refreshed by ANALYZE (a lightweight, sampling-based scan — much cheaper than VACUUM) or by autovacuum’s automatic analyze once enough rows have changed (governed by autovacuum_analyze_scale_factor/autovacuum_analyze_threshold). default_statistics_target (default 100, settable server-wide or per-column via ALTER TABLE ... ALTER COLUMN ... SET STATISTICS n) controls how many rows ANALYZE samples and how large the most-common-values list and histogram get — raising it (e.g., to 500 or 1000) on a handful of columns known to have skewed distributions is a common, low-risk tuning move; raising it globally makes every ANALYZE slower and every plan slightly more expensive to produce.

The classic failure mode: a bulk load or a big UPDATE/DELETE changes the shape of the data (a new status value now dominates, or a table went from 10k to 10M rows) but statistics still reflect the old shape until autovacuum’s analyze threshold is crossed — which, on a huge table, can be a long time after the load finished. In the meantime, the planner is choosing plans for data that no longer exists:

-- Right after loading 4.9M 'refunded' orders, before ANALYZE runs
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'refunded';
 Seq Scan on orders  (cost=0.00..104166.00 rows=50 width=97) (actual time=0.015..523.223 rows=125000 loops=1)
   Filter: (status = 'refunded'::text)
   Rows Removed by Filter: 4875000
 Planning Time: 0.095 ms
 Execution Time: 545.667 ms

Estimated rows=50, actual rows=125000 — a 2,500x miss, and exactly the estimate-vs-actual divergence flagged earlier as the signature of a statistics problem. The fix is procedural, not structural: run ANALYZE orders; (or ANALYZE; for the whole database) immediately after any bulk load, mass update/delete, or partition attach/detach, rather than waiting for autovacuum to notice. This costs seconds to minutes and can turn a multi-second query back into a sub-millisecond one, with no schema change required.

pg_stat_statements: finding what to tune

Reading individual plans only helps once you know which query to look at. pg_stat_statements tracks execution statistics for every distinct, normalized query (constants replaced by placeholders like $1) across the entire server, and is the first place a competent DBA looks — not the last resort after guessing.

Enable it (requires a server restart because it needs shared memory):

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION pg_stat_statements;

Find the queries consuming the most total time on the server — usually the highest-leverage place to start, since it factors in both cost-per-call and call frequency:

SELECT
  query,
  calls,
  round(total_exec_time::numeric, 2) AS total_ms,
  round(mean_exec_time::numeric, 2)  AS mean_ms,
  round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
                          query                              |  calls  | total_ms  | mean_ms | pct_total
---------------------------------------------------------------+---------+-----------+---------+-----------
 SELECT * FROM orders WHERE customer_id = $1                   |  812340 | 214532.10 |    0.26 |     18.20
 UPDATE orders SET status = $1 WHERE id = $2                    |   45210 | 189213.55 |    4.19 |     16.04
 SELECT c.*, o.* FROM customers c JOIN orders o ON ...          |    3211 | 165442.80 |   51.53 |     14.02
 SELECT * FROM orders WHERE status = $1                         |    1876 |  98221.44 |   52.35 |      8.33
 DELETE FROM sessions WHERE expires_at < $1                     |     512 |  87023.11 |  170.00 |      7.38

Two very different queries can dominate total_ms for opposite reasons: the first row here is called 812,340 times at a cheap 0.26 ms average — completely fine on its own, but worth confirming it’s really using idx_orders_customer_id. The last row is called only 512 times but averages 170 ms — that one warrants an EXPLAIN ANALYZE immediately. Sorting instead by mean_exec_time DESC surfaces the worst per-call offenders, useful for catching a single catastrophically slow report query that isn’t called often enough to dominate the total-time ranking. pg_stat_statements_reset() clears the counters, useful before a load test or after a fix to measure the “before/after” cleanly.

Common anti-patterns and their fixes

Anti-patternSymptom in EXPLAINFix
Missing indexSeq Scan with a selective Filter on a large tableAdd a B-tree (or appropriate) index — see Indexing Strategies
SELECT *Wide width= even when only 2 columns are used downstream; blocks Index Only ScanSelect only needed columns; lets narrower indexes cover the query
Implicit type castFilter shows (customer_phone)::text = '12345'::text or similar cast wrapping the column instead of the literalMatch column and literal types explicitly (WHERE customer_phone = '12345', not comparing text to integer) so the cast doesn’t defeat the index
Function on indexed columnFilter: (lower(email) = 'foo@bar.com'::text) with a Seq Scan despite an index on emailCreate an expression index: CREATE INDEX ON customers (lower(email));
N+1 queries from app codepg_stat_statements shows one parameterized single-row query (WHERE id = $1) with an enormous calls countBatch with WHERE id = ANY($1::int[]), a JOIN, or eager-loading in the ORM

The type-cast anti-pattern is easy to miss because the query still “works” — it just silently stops using the index it should. Compare:

EXPLAIN SELECT * FROM orders WHERE customer_id = '42';   -- text literal, int column
 Index Scan using idx_orders_customer_id on orders  (cost=0.43..8.45 rows=25 width=97)
   Index Cond: (customer_id = 42)

PostgreSQL is often smart enough to fold a literal cast at plan time (as above), but the same is not guaranteed for casts applied to the column itself, or for cross-type comparisons the planner can’t reduce to a constant — those routinely show up as Filter: conditions (evaluated row-by-row after the scan) instead of Index Cond: (used to drive the scan), which is the tell-tale sign to look for in EXPLAIN output.

Memory and cache configuration

Three settings shape whether a plan can execute efficiently even when the plan choice itself is correct:

EXPLAIN ANALYZE SELECT * FROM orders ORDER BY total_cents;
 Sort  (cost=712345.12..724845.12 rows=5000000 width=97) (actual time=4521.223..5890.114 rows=5000000 loops=1)
   Sort Key: total_cents
   Sort Method: external merge  Disk: 512000kB
   ->  Seq Scan on orders  (cost=0.00..104166.00 rows=5000000 width=97) (actual time=0.012..430.221 rows=5000000 loops=1)
 Planning Time: 0.088 ms
 Execution Time: 6102.556 ms

Sort Method: external merge Disk: 512000kB is the smoking gun — the sort didn’t fit in work_mem and spilled ~500 MB to temp files on disk. Raising work_mem for the session (or globally, with care) fixes it:

SET work_mem = '256MB';
 Sort  (cost=712345.12..724845.12 rows=5000000 width=97) (actual time=2210.556..2890.221 rows=5000000 loops=1)
   Sort Key: total_cents
   Sort Method: quicksort  Memory: 665432kB
   ->  Seq Scan on orders  (cost=0.00..104166.00 rows=5000000 width=97) (actual time=0.011..415.223 rows=5000000 loops=1)
 Planning Time: 0.075 ms
 Execution Time: 3102.556 ms

Same idea applies to Batches: N (N > 1) on a Hash node, seen earlier — that’s a hash table spilling for the same underlying reason.

Best Practices

Always reach for pg_stat_statements before opening a single query in isolation — it tells you objectively which query is worth your time, rather than tuning whichever one you happen to be staring at. Once you have a candidate, run EXPLAIN (ANALYZE, BUFFERS) — not bare EXPLAIN — since estimated costs alone can’t tell you whether the plan is actually slow or whether it’s slow because of disk I/O versus CPU versus a bad plan shape; paste the output into explain.dalibo.com or explain.depesz.com the moment a plan has more than a few nodes, rather than trying to eyeball indentation. Compare estimated vs actual row counts on every node before concluding anything about join order or scan choice — a plan that “looks wrong” is frequently a plan that is correct for the statistics the planner had, and the real fix is ANALYZE, not a rewritten query.

Make ANALYZE part of your operational runbook, not an afterthought: run it explicitly after bulk loads, large UPDATE/DELETE batches, restoring a dump, and attaching/detaching partitions — don’t rely on autovacuum’s schedule for these one-off events on large tables, since the delay before it triggers can leave the planner working from badly stale statistics for hours. Treat work_mem tuning as workload-specific and be conservative on high-concurrency OLTP servers (the setting multiplies across every concurrent sort/hash, and a well-intentioned increase can push a server into swapping); it’s often safer to raise it per-session or per-role for known-heavy reporting queries (ALTER ROLE reporting_user SET work_mem = '256MB';) than globally. Never chase a plan shape with query hints — PostgreSQL doesn’t support them by design, precisely to keep pressure on fixing the actual input (statistics, indexes, schema) rather than papering over a symptom. Finally, treat anti-patterns like implicit casts and functions on indexed columns as code-review items, not just DBA cleanup: catching WHERE lower(email) = ... against a plain index, or a text/int cross-type comparison, before it ships is far cheaper than diagnosing it in production months later.

References