Animated Architecture Patterns

Query Execution Plans

อ่าน EXPLAIN แบบที่ planner คิด: scan, join, จำนวน row ที่ประมาณไว้เทียบกับของจริง และ statistics ที่อยู่เบื้องหลังแต่ละการตัดสินใจ

หรือที่เรียกกันว่า EXPLAIN, EXPLAIN ANALYZE, Query plans

report.sql PostgreSQL 18.6 SELECT c.country, count(*) AS orders, sum(o.total_thb) AS revenue FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.created_at >= '2026-09-03' GROUP BY c.country ORDER BY revenue DESC; country orders revenue THB TH12,713320.2 M SG3,46385.1 M MY2,77669.4 M VN1,93347.5 M ID1,38334.5 M PH94523.7 M Execution Time106 ms Execution Time88 ms PostgreSQL 18.6 plans the query, then runs the plan ? Which plan? How many rows? Where did the time go? Don't guess: run EXPLAIN (ANALYZE, BUFFERS) EXPLAIN (ANALYZE, BUFFERS)one run · parallel query off Sortest 6 · actual 6 · 105.6 ms · buffers 74,704 GroupAggregateest 6 · actual 6 · 105.6 ms Sortest 48 · actual 23,213 · 101.3 ms Nested Loopest 48 · actual 23,213 · 92.8 ms outer inner, per row Seq Scan on orders oest 48 · actual 23,213removed by filter 476,7870.010..43.964 msbuffers 5,059 Index Scan · customers_pkeyest 1 · actual 1 per looploops 23,2130.002 ms × 23,213 ≈ 46 msbuffers 69,639 ×484 93% Sortest 6 · actual 6 · 87.4 ms · buffers 6,198 HashAggregateest 6 · actual 6 · 87.4 ms Hash Joinest 22,956 · actual 23,213 · 82.0 ms probe build, once Seq Scan on orders oest 22,956 · actual 23,213removed by filter 476,7870.011..37.430 msbuffers 5,059 Hash100,000 rows · 32.3 ms Seq Scan on customers cest = actual = 100,00013.6 ms · buffers 1,136 Most expensive: 23,213 index lookups, 93% of all buffersThey happen because the scan was estimated at 48 rows: for 48,a Nested Loop looked cheapest (cost 11,409 vs 14,408 hash join).cost: planner units, not ms; a node includes its children Hash Join: 6,198 buffers instead of 74,704 · 106 → 88 msWith 22,956 rows expected, one pass over customers into a hashis cheapest: cost 14,928 vs 22,701 for a nested loop.warm cache here: every page was already in memory Generic plan: PREPARE … WHERE o.created_at >= $1Seq Scan on orders o (rows=166,667): a third of the table,because a generic plan cannot see the value of $1.PostgreSQL kept custom plans here: the generic one cost more The team's guesses?Add an index on every column? ?Give the server more memory? ?Move to a bigger instance? None of them looks at what thedatabase actually did. Reading one plan node Index Scan using customers_pkey operation, then the index it reads cost=0.29..8.06 rows=1 estimate: startup..total cost, rows actual time=0.002..0.002 ms to first row..last row, per loop rows=1.00 loops=23213 × 23,213 loops: 23,213 rows, ≈ 46 ms Buffers: shared hit=69639 8 kB pages; hit = already in memory Read from the leaves up: compare rows with actual rows, then follow the time and the buffers. What the planner knew pg_stats · orders.created_at 100 buckets of 1% each; Jul–Oct shown Jul Aug Sep Oct ANALYZE orders;rebuilds statistics ANALYZE orders;done: new histogram pg_class · orders Auto-ANALYZE waits for 47,729 changes (50 + 10% of 476,787); 23,213 arrived pg_stats · customers.country TH .5522 · SG .1485 · MY .1189 VN .0815 · ID .0584 · PH .0405 n_distinct 6: the aggregates expect 6 rows created_at >= '2026-09-03' ? Past the last bound, 2026-09-02 23:58: floor 0.01% × 476,881 rows = 48 rows 476,787 rows in 5,058 pages (now 5,059) created_at >= '2026-09-03' The range covers 4.59 of 100 buckets: 4.59% × 500,000 rows = 22,956 rows 500,000 rows in 5,059 pages EXPLAIN alone only estimatesEXPLAIN ANALYZE runs the statement,so wrap a write in a transaction:BEGIN;EXPLAIN ANALYZE UPDATE orders …;ROLLBACK; Plans change with parametersA prepared statement gets 5 customplans, then may switch to a genericone that cannot see $1.plan_cache_mode = autoforce_custom_plan when values skew Watch plans in productionauto_explain.log_min_durationlogs the plan of each slow statementpg_stat_statementsranks statements by total timeThen EXPLAIN the top ones by hand.

ขั้นที่ 1 · report ช้า report ของ Acme Shop รวมยอด order 30 วันล่าสุดแยกตามประเทศของ customer: order 23,213 ตัวกลายเป็น 6 row แล้วพอรันบน PostgreSQL 18.6 มันใช้เวลา 106 ms (รัน EXPLAIN ANALYZE หนึ่งครั้งบน laptop โดยปิด parallel query) แล้วทีมก็เริ่มเดา: ใส่ index ทุก column, เพิ่ม memory, ขยาย instance ให้ใหญ่ขึ้น แต่ไม่มีข้อไหนเลยที่ไปดูว่า database ทำอะไรจริง ๆ

ขั้นที่ 2 · อ่าน plan EXPLAIN (ANALYZE, BUFFERS) พิมพ์ plan tree ออกมา ให้อ่านจาก node ที่อยู่ในสุดขึ้นมา แล้วเทียบ rows ที่ประมาณไว้ของแต่ละ node กับจำนวน row จริง ตัว Seq Scan บน orders ประมาณไว้ 48 row แต่คืนมา 23,213 row ทำให้ Nested Loop ที่อยู่ข้างบนไป lookup customers_pkey หนึ่งครั้งต่อหนึ่ง row: loops=23213 ใช้ประมาณ 46 ms และ 69,639 จาก 74,704 buffer จุดที่ต้องดูคือ node ที่แพงที่สุด และช่องว่างใหญ่ช่องแรกระหว่างค่าประมาณกับของจริงมักจะอธิบายได้ว่าทำไม

ขั้นที่ 3 · ค่าประมาณเทียบกับของจริง planner ประมาณจำนวน row จาก pg_stats ตัว histogram ของ created_at ถูกสร้างไว้ก่อนที่ order ของ 30 วันล่าสุดจะเข้ามา และไปจบที่ 2026-09-02 23:58 ทำให้ created_at >= '2026-09-03' ตกเลย bucket สุดท้ายไป แล้ว planner ก็ใช้ค่าขั้นต่ำ 0.01%: 48 row แต่พอ ANALYZE orders แล้ว ช่วงนี้ครอบ 4.59 จาก 100 bucket ค่าประมาณกลายเป็น 22,956 แล้ว plan ก็เปลี่ยนไปใช้ Hash Join และ report อ่าน 6,198 buffer แทน 74,704 (88 ms แทน 106 ms ในการรันรอบนี้)

ขั้นที่ 4 · กับดักบน production EXPLAIN เปล่า ๆ แสดงแค่ค่าประมาณ ส่วน EXPLAIN ANALYZE รัน statement จริง ๆ เลยต้องครอบ UPDATE หรือ DELETE ด้วย BEGIN … ROLLBACK ไว้ ส่วน plan ก็เปลี่ยนไปตาม parameter ด้วย: หลังรันห้าครั้ง prepared statement อาจเปลี่ยนไปใช้ generic plan (plan_cache_mode = auto) ที่มองไม่เห็น $1 และในกรณีนี้สมมติไว้ 166,667 row คือหนึ่งในสามของ table ส่วนบน production ตัว auto_explain จะ log plan ของ statement ที่ช้า และ pg_stat_statements บอกว่า statement ไหนกินต้นทุนรวมมากที่สุด

ปัญหา

dashboard ของ Acme Shop รัน report ตัวหนึ่ง: ยอดขายแยกตามประเทศของ customer สำหรับ order ใน 30 วันล่าสุด พอรันบน PostgreSQL 18.6 มันก็ join orders (500,000 row) กับ customers (100,000 row) แล้ว group order ล่าสุด 23,213 ตัวให้เหลือ 6 row โดยใช้เวลาประมาณ 106 ms พอ query แบบนี้รู้สึกช้า ทีมก็มักจะเดา: ใส่ index ทุก column, ให้ server มี memory มากขึ้น, ย้ายไป instance ที่ใหญ่ขึ้น ทุกการเดามีราคาของมัน (ทุก index เพิ่มงานให้ insert และ update ส่วน memory และ instance ก็ต้องจ่ายเงิน) และไม่มีข้อไหนเลยที่ไปดูว่า database ทำอะไรจริง ๆ

database บอกเราได้เอง EXPLAIN พิมพ์ plan ที่ planner เลือกพร้อมค่าประมาณของมันออกมา ส่วน EXPLAIN (ANALYZE, BUFFERS) รัน statement จริงด้วย แล้วเพิ่มสิ่งที่เกิดขึ้นจริงในทุกขั้นเข้าไป: row, loop, เวลา และ page ที่แตะ การอ่าน output นี้พาเราจาก "มันช้า" ไปถึง "node นี้ ด้วยเหตุผลนี้"

ทำงานยัง⁠ไง

planner แปลง query เป็น tree ของ plan node สำหรับแต่ละ tree ที่เป็นตัวเลือก มันประมาณว่าทุก node จะให้ row ออกมากี่ row โดยใช้ statistics ที่ ANALYZE เก็บไว้ของแต่ละ table และ column แล้วตีราคางานด้วย cost constant ไม่กี่ตัว และเก็บ tree ที่ถูกที่สุดไว้ จากนั้น executor ก็ดึง row ขึ้นไปตาม tree: scan อยู่ที่ leaf, join, sort และ aggregate อยู่เหนือขึ้นไป และผลลัพธ์อยู่ที่ root

กายวิภาคของ node นี่คือ node หนึ่งตัวจากการรันใน diagram:

Index Scan using customers_pkey on customers c  (cost=0.29..8.06 rows=1 width=11) (actual time=0.002..0.002 rows=1.00 loops=23213)
  Index Cond: (id = o.customer_id)
  Index Searches: 23213
  Buffers: shared hit=69639
  • cost=0.29..8.06 คือ startup cost ที่ประมาณไว้ (ก่อนได้ row แรก) และ total cost ในหน่วยที่ไม่มีความหมายตายตัว โดยการอ่าน page หนึ่ง page แบบเรียงลำดับมีต้นทุน 1.0 (seq_page_cost) ค่า default ตัวอื่น ๆ คือ 4.0 สำหรับการอ่าน page แบบไม่เรียงลำดับ (random_page_cost), 0.01 ต่อ row (cpu_tuple_cost), 0.005 ต่อ index entry (cpu_index_tuple_cost) และ 0.0025 ต่อ operator (cpu_operator_cost) ส่วน cost ของ node หนึ่งก็รวม cost ของ node ลูกไว้ด้วย
  • rows=1 และ width=11 คือจำนวน row ที่ประมาณไว้ต่อการรันหนึ่งครั้ง และความกว้างเฉลี่ยของ row เป็น byte
  • actual time=0.002..0.002 คือเวลาที่วัดได้เป็น millisecond จนถึง row แรกและ row สุดท้าย ส่วน rows=1.00 คือจำนวน row ที่คืนมา ทั้งสองค่าเป็นค่าเฉลี่ยต่อการรันหนึ่งครั้ง และ loops=23213 คือจำนวนครั้งที่ node นี้รัน ให้คูณด้วย loops เพื่อได้ยอดรวม: ที่นี่คือ 23,213 row และประมาณ 46 ms
  • Buffers: shared hit=69639 นับ page ขนาด 8 kB: hit แปลว่าเจอใน shared buffer ส่วน read แปลว่าต้องดึงมาจาก operating system หรือ disk ตัว buffer ของ node หนึ่งรวมของ node ลูกไว้ด้วย
  • บรรทัดเพิ่มเติมอธิบายงานที่ทำ: Rows Removed by Filter, Sort Method, Batches และ Memory Usage ของ hash และ aggregate, Heap Blocks ของ bitmap scan ตัว PostgreSQL 18 เพิ่ม Buffers ให้อัตโนมัติทุกครั้งที่ใช้ ANALYZE พิมพ์ actual rows เป็นทศนิยมสองตำแหน่ง และรายงาน Index Searches ของ index scan

ลำดับการอ่าน เริ่มที่ node ในสุดแล้วไล่ขึ้นมา ที่แต่ละ node ให้เทียบจำนวน row ที่ประมาณไว้กับจำนวน row จริง (คูณ loops): ช่องว่างใหญ่ช่องแรกมักจะอธิบายทุกอย่างที่อยู่เหนือมันได้ เพราะทุกค่าประมาณถูกใช้ตัดสินใจเลือก node ที่อยู่ข้างบน แล้วค่อยตามเวลาและ buffer ไปหา node ที่แพงที่สุด นี่คือการรันที่ statistics เก่าแล้ว อ่านจากล่างขึ้นบน:

Node Row ที่ประมาณไว้ Row จริง × loops เวลาจริง (ms) Buffer
Seq Scan on orders o 48 23,213 × 1 0.010..43.964 5,059
Index Scan using customers_pkey 1 1.00 × 23,213 0.002 ต่อ loop 69,639
Nested Loop 48 23,213 × 1 0.018..92.780 74,698
Sort (ตาม country) 48 23,213 × 1 99.374..101.329 74,701
GroupAggregate 6 6 × 1 99.754..105.598 74,701
Sort (ตาม revenue) 6 6 × 1 105.619..105.627 74,704

scan คืน row มามากกว่าที่ประมาณไว้ 484 เท่า สำหรับ 48 row ตัว nested loop เป็น join ที่ถูกที่สุด (cost ที่ประมาณไว้ 11,409 เทียบกับ 14,408 ของ hash join) ทำให้ index scan ด้านในรัน 23,213 ครั้งและใช้ buffer ไป 93% ของทั้งหมด ส่วน nested loop ที่ด้านในรันเป็นพัน ๆ ครั้งก็คือ N+1 queries ในแบบของ database เอง: ให้เช็คค่าประมาณของด้าน outer ก่อน

ค่าประมาณมาจากไหน pg_class เก็บจำนวน row และจำนวน page ของแต่ละ table (reltuples, relpages) แล้ว planner ก็ปรับสเกลให้เข้ากับขนาดปัจจุบันของ table ส่วน pg_stats เก็บสิ่งที่ ANALYZE เจอใน sample แบบสุ่มไว้ต่อ column: สัดส่วนของ null, จำนวนค่าที่ไม่ซ้ำ (n_distinct), ค่าที่เจอบ่อยที่สุดพร้อมความถี่, histogram ของค่าที่เหลือแบ่งเป็น bucket ที่มีความถี่เท่ากัน (ได้ถึง 100 bucket ถ้าใช้ default_statistics_target ค่า default) และ correlation ระหว่างลำดับของ column กับลำดับทางกายภาพของ table สำหรับ range อย่าง created_at >= '2026-09-03' ตัว planner จะหา bucket ที่ค่านั้นตกอยู่ นับ bucket ที่อยู่เหนือขึ้นไป แล้ว interpolate แบบเส้นตรงภายใน bucket นั้น ถ้ามีหลายเงื่อนไข มันจะคูณ selectivity ของแต่ละตัวเข้าด้วยกันโดยสมมติว่าเป็นอิสระต่อกัน ยกเว้นมี extended statistics object (CREATE STATISTICS … (ndistinct, dependencies, mcv)) ที่บันทึกว่า column ของ table เดียวกันสัมพันธ์กันยัง⁠ไง ส่วน object แบบนี้จะถูกเติมข้อมูลตอน ANALYZE ครั้งถัดไป

อะไรผิดพลาดตรงนี้ statistics ของ orders ถูกเก็บไว้ก่อนที่ order ของ 30 วันล่าสุดจะเข้ามา ทำให้ histogram ของ created_at ไปจบที่ 2026-09-02 23:58 และทุก row ที่ report ต้องการก็อยู่เลย bucket สุดท้ายไปแล้ว planner ไม่เชื่อว่าปลายของ histogram จะเป็นค่าปัจจุบัน แทนที่จะใช้ศูนย์ มันเลยใช้ค่าขั้นต่ำคือหนึ่งในร้อยของหนึ่ง bucket (selfuncs.c): 0.01% ของ 476,881 row ที่มันสมมติไว้คือ 48 ส่วน autovacuum ก็ไม่ได้ refresh statistics เพราะมันจะ analyze table ใหม่ก็ต่อเมื่อมีการเปลี่ยนแปลงเกิน autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × จำนวน row โดย default ค่านี้คือ 50 + 10% ของ 476,787 = 47,729 แต่ order ใหม่ 23,213 ตัวยังน้อยกว่านั้น หลัง ANALYZE orders ตัว histogram ไปถึง 2026-10-02 17:16 ทำให้ช่วงที่ report ขอครอบ 4.59 จาก 100 bucket และค่าประมาณกลายเป็น 22,956 แล้ว planner ก็เลือก hash join ที่อ่าน customers ครั้งเดียวเข้า hash table แทนที่จะ lookup 23,213 ครั้ง (Join Algorithms เทียบทั้งสามวิธี): 6,198 buffer แทน 74,704 และ 88 ms แทน 106 ms

ค่าประมาณที่พลาดบ่อยแบบอื่น ๆ และวิธีแก้ แต่ละข้อวัดหนึ่งครั้งบนข้อมูลตัวอย่างชุดเดียวกัน:

  • statistics เก่ากว่าข้อมูล แบบข้างบน: รัน ANALYZE หลังเปลี่ยนข้อมูลก้อนใหญ่ และลด autovacuum_analyze_scale_factor ของ table ใหญ่ที่โตขึ้นเรื่อย ๆ
  • column ที่สัมพันธ์กัน ตรงนี้ customer ทุกคนส่งของไปประเทศของตัวเอง ทำให้ customer_id = 1 AND shipping_country = 'MY' ตรงกับ order 2,633 ตัว แต่การคูณ selectivity สองตัวให้ค่าประมาณ 331 หลัง CREATE STATISTICS orders_customer_country (dependencies) ON customer_id, shipping_country FROM orders และ ANALYZE อีกรอบ ค่าประมาณกลายเป็น 2,783 ส่วน extended statistics ครอบได้แค่ column ของ table เดียว: ไม่มีอะไรบอก planner ว่า orders.shipping_country ตรงกับ country ของ customer
  • function ที่ครอบ column อยู่ date_trunc('day', created_at) >= '2026-09-03' ไม่มี statistics ของตัวเอง planner เลยถอยไปใช้ค่า default คือหนึ่งในสาม: ประมาณไว้ 166,667 row แต่เจอจริง 23,213 row ทางแก้คือเทียบ column เปล่า ๆ กับ range แทน หรือเก็บ statistics ของ expression นั้น (CREATE STATISTICS … ON (date_trunc('day', created_at)) FROM orders ได้ 24,346) ส่วน index ต้องใช้ expression ที่ immutable และบน timestamptz รูปแบบนี้ขึ้นกับ TimeZone ของ session ทำให้ CREATE INDEX ไม่ยอมรับ: ให้ทำ index บน date_trunc('day', created_at, 'UTC') แล้วเขียน query แบบเดียวกัน
  • parameter ที่ planner มองไม่เห็น: generic plan ของ prepared statement อยู่ในหัวข้อ "ได้อะไร เสียอะไร" ข้างล่าง

ลองรันดู

ข้อมูลตัวอย่างมาจาก samples/acme-shop คือ script ที่สร้าง template database acme บน PostgreSQL 18 ในเวลาประมาณหนึ่งนาที

รันบนสำเนาใหม่ของข้อมูลตัวอย่าง Acme Shop (PostgreSQL 18) โดยต่อเข้าไปเป็น superuser: ขั้นย้อนเวลาตอนต้นจะปิดการเช็ค foreign key ไว้ระหว่างที่ดึง order ของ 30 วันล่าสุดออกแล้วใส่กลับเข้าไป ตัว comment แสดงบรรทัดสำคัญของการรันหนึ่งรอบ ส่วนในการรันของคุณ รอบแรกจะเห็น buffer บางส่วนเป็น read= แทน hit= และยอดรวมอาจต่างกันไม่กี่ catalog page

-- CREATE DATABASE query_execution_plans TEMPLATE acme STRATEGY FILE_COPY;  then connect to it
SET TimeZone = 'UTC';
SET max_parallel_workers_per_gather = 0;            -- one process: a smaller plan tree

-- Rewind the statistics to before the last 30 days of orders arrived
ALTER TABLE orders SET (autovacuum_enabled = off);  -- keep autovacuum out of the experiment
SET session_replication_role = replica;             -- no FK checks while the rows are out
CREATE TEMP TABLE recent AS SELECT * FROM orders WHERE created_at >= '2026-09-03';  -- SELECT 23213
DELETE FROM orders WHERE created_at >= '2026-09-03';
VACUUM ANALYZE orders;
INSERT INTO orders OVERRIDING SYSTEM VALUE SELECT * FROM recent;
RESET session_replication_role;

SELECT (histogram_bounds::text::timestamptz[])[101] AS last_bound
FROM pg_stats WHERE tablename = 'orders' AND attname = 'created_at';
-- 2026-09-02 23:58:49+00   (ANALYZE samples rows at random: yours differs a little)

EXPLAIN (ANALYZE, BUFFERS)
SELECT c.country, count(*) AS orders, sum(o.total_thb) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= '2026-09-03'
GROUP BY c.country
ORDER BY revenue DESC;
-- Sort  (cost=11408.87..11408.88 rows=6 width=43) (actual time=105.619..105.627 rows=6.00 loops=1)
--   Buffers: shared hit=74704
--   ->  GroupAggregate ... ->  Sort ...
--         ->  Nested Loop  (cost=0.29..11406.89 rows=48 width=11) (actual time=0.018..92.780 rows=23213.00 loops=1)
--               ->  Seq Scan on orders o  (cost=0.00..11020.01 rows=48 width=16) (actual time=0.010..43.964 rows=23213.00 loops=1)
--                     Rows Removed by Filter: 476787
--               ->  Index Scan using customers_pkey on customers c  (cost=0.29..8.06 rows=1 width=11) (actual time=0.002..0.002 rows=1.00 loops=23213)
--                     Buffers: shared hit=69639
-- Execution Time: 105.779 ms

ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.country, count(*) AS orders, sum(o.total_thb) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= '2026-09-03'
GROUP BY c.country
ORDER BY revenue DESC;
-- Sort  (cost=14927.58..14927.60 rows=6 width=43) (actual time=87.441..87.449 rows=6.00 loops=1)
--   Buffers: shared hit=6198
--   ->  HashAggregate ...
--         ->  Hash Join  (cost=3386.00..14755.26 rows=22956 width=11) (actual time=32.519..82.040 rows=23213.00 loops=1)
--               ->  Seq Scan on orders o  (cost=0.00..11309.00 rows=22956 width=16) (actual time=0.011..37.430 rows=23213.00 loops=1)
--               ->  Hash  (cost=2136.00..2136.00 rows=100000 width=11) (actual time=32.253..32.254 rows=100000.00 loops=1)
--                     ->  Seq Scan on customers c  (cost=0.00..2136.00 rows=100000 width=11) (actual time=0.005..13.591 rows=100000.00 loops=1)
-- Execution Time: 87.659 ms

ALTER TABLE orders RESET (autovacuum_enabled);

เวลามาจากการรันหนึ่งรอบบน laptop และเปลี่ยนไปในแต่ละรอบ สิ่งที่สำคัญคือ plan, จำนวน row และ buffer

ใช้ตอนไหนดี

  • ก่อนเปลี่ยนอะไรก็ตาม กับ statement ที่ช้า ให้รัน EXPLAIN (ANALYZE, BUFFERS) หา node ที่แพงที่สุดกับค่าประมาณที่พลาดมากตัวแรกที่อยู่ข้างใต้มัน แล้วแก้ตรงนั้น และให้รันสองครั้ง: รอบแรกอาจอ่าน page จาก disk ที่รอบสองเจอใน memory แล้ว
  • EXPLAIN ที่ไม่มี ANALYZE แค่วาง plan โดยไม่รัน statement เลยใช้กับ DELETE บน production ได้ แต่มันแสดงแค่ค่าประมาณ
  • option ที่ควรรู้: SETTINGS แสดง planner setting ที่ต่างจาก default, WAL แสดง WAL ที่การเขียนสร้างขึ้น, SERIALIZE (PostgreSQL 17 ขึ้นไป) วัดการแปลงผลลัพธ์เป็น text หรือ binary, MEMORY (17 ขึ้นไป) วัด memory ของ planner, GENERIC_PLAN (16 ขึ้นไป) วาง plan ให้ statement ที่มี parameter $1 โดยไม่ต้องมีค่า, TIMING OFF เก็บจำนวน row ไว้แต่ตัดเวลาของแต่ละ node ออก และ FORMAT JSON ใช้ป้อนให้ tool
  • tree ใหญ่ ๆ อ่านง่ายกว่าใน visualizer เช่น explain.depesz.com, explain.dalibo.com หรือ pgMustard
  • บน production ให้หา statement ที่คุ้มจะ explain ก่อน ตัว pg_stat_statements เก็บจำนวนครั้งที่เรียก, เวลารวมและเวลาเฉลี่ย, row และ buffer ของแต่ละ statement ที่ normalize แล้ว (ต้องใส่มันไว้ใน shared_preload_libraries) ส่วน auto_explain จะ log plan ของทุก statement ที่ช้ากว่า auto_explain.log_min_duration พร้อม actual rows และ buffer ถ้าเปิด log_analyze และ log_buffers ไว้

ได้อะไร เสียอะไร

  • EXPLAIN ANALYZE รัน statement จริง SELECT ไม่ส่งอะไรกลับมาให้ แต่ทำงานครบทั้งหมด ส่วน INSERT, UPDATE หรือ DELETE เปลี่ยนข้อมูลจริง ๆ เลยต้องครอบด้วย BEGIN; … ROLLBACK;
  • การวัดกินเวลา การจับเวลาราย node ต้องอ่านนาฬิกาซ้ำแล้วซ้ำอีก จากการวัดสามชุด ชุดละ 15 รอบ ค่า median ของ report ตอนไม่ใช้ EXPLAIN อยู่ที่ 77 ถึง 81 ms ตอน statistics เก่า และ 62 ถึง 64 ms หลัง ANALYZE ส่วน EXPLAIN ANALYZE เพิ่มเวลาประมาณ 16 ms ให้ทั้งสองแบบ ให้ใช้ TIMING OFF ถ้าจำนวน row ก็พอแล้ว และเปิด auto_explain.log_analyze ไว้แค่ตอนสืบสวนสั้น ๆ: ระหว่างที่เปิดอยู่ ทุก statement ต้องจ่ายค่าจับเวลา ไม่ว่าจะถูก log หรือไม่
  • cache ที่อุ่นอยู่ซ่อน plan ที่แย่ไว้ plan ที่ผิดแตะ buffer มากกว่า 12 เท่า แต่ช้ากว่าแค่ประมาณ 25% เพราะทุก page อยู่ใน memory อยู่แล้ว ถ้า page พวกนั้นต้องมาจาก disk แต่ละ lookup ในจำนวน 23,213 ครั้งก็อาจกลายเป็น random read แบบที่ random_page_cost ตีราคาไว้
  • statistics เป็นแค่ sample เพราะ ANALYZE สุ่ม sample ทำให้ค่าประมาณขยับไปนิดหน่อยหลังรันแต่ละครั้ง (ที่นี่ได้ 22,956 ส่วนอีกรอบของ script เดียวกันได้ 23,960) การตั้ง statistics target ของ column ให้สูงขึ้น (ALTER TABLE … ALTER COLUMN … SET STATISTICS) ทำให้ได้ histogram ที่ละเอียดขึ้นและ list ของค่าที่เจอบ่อยที่ยาวขึ้น แลกกับ ANALYZE ที่ช้าลง, กินที่ใน pg_statistic มากขึ้น และวาง plan ช้าลงเล็กน้อย
  • ค่าประมาณที่ถูกไม่ได้แปลว่างานน้อยลง ถึง statistics จะใหม่แล้ว report ก็ยังอ่าน orders ครบทั้ง 5,059 page และทิ้งไป 476,787 row ส่วนในการรันของเรา B-tree index บน created_at ลดเวลาเหลือประมาณ 60 ms ด้วย bitmap scan บน 3,963 page ที่มี order ล่าสุดอยู่ (ข้อมูลตัวอย่างกระจาย order พวกนี้ไปเกือบทั้ง table) ตัว index ยังช่วยเรื่องค่าประมาณด้วย: ถ้าค่าหนึ่งอยู่เลย histogram ไป planner จะอ่านค่าต่ำสุดหรือสูงสุดปัจจุบันของ column จาก index ทำให้ค่าประมาณเก่าที่เป็น 48 กลายเป็น 4,009 ถ้า report เก่าได้สักไม่กี่นาที ตัว materialized view ก็เลี่ยงงานนี้ได้ทั้งหมด
  • generic plan ตัว prepared statement จะถูกวาง plan ด้วยค่า parameter จริงในการรันห้าครั้งแรก หลังจากนั้น PostgreSQL อาจเปลี่ยนไปใช้ generic plan ตัวเดียว ถ้า cost ที่ประมาณไว้ของมันไม่ได้สูงกว่าค่าเฉลี่ยมากนัก ตัว generic plan มองไม่เห็น $1: สำหรับ created_at >= $1 มันสมมติไว้หนึ่งในสามของ table คือ 166,667 row ส่วนในการรันของเรา custom plan ยังถูกกว่า ทำให้ PostgreSQL ใช้ custom plan ต่อไป ส่วนถ้าค่ากระจายตัวไม่สม่ำเสมอ plan_cache_mode = force_custom_plan จะวาง plan ใหม่ทุกครั้งที่รัน
  • setting enable_* มีไว้ทดลอง SET enable_nestloop = off แสดงให้เห็นว่า planner จะทำอะไรถ้าไม่ใช้วิธีนั้น (เอกสารเรียก setting พวกนี้ว่าวิธีแบบหยาบ ๆ) บน production ให้แก้ค่าประมาณแทน

ข้อควรรู้ตอนลงมือทำ

  • PostgreSQL 18 เปิด BUFFERS เป็น default เมื่อใช้ EXPLAIN ANALYZE แล้วยังพิมพ์ actual row count เป็นทศนิยม แสดง Index Searches และให้ pg_upgrade เก็บ optimizer statistics ไว้ได้ แต่ extended statistics ยังต้อง ANALYZE หลัง upgrade
  • รักษา statistics ให้ใหม่อยู่เสมอ รัน ANALYZE หลังโหลดข้อมูลก้อนใหญ่และหลังลบข้อมูลเยอะ ๆ สำหรับ table ใหญ่ที่มีการ append เยอะ ให้ลด threshold ราย table ลง เช่น ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.01) ตัว autovacuum ไม่เคย analyze temporary table หรือ parent ของ partitioned table เลยต้อง analyze พวกนั้นเอง
  • replica ใช้ statistics ร่วมกับ primary ตัว hot standby รัน ANALYZE ไม่ได้ มันใช้ statistics ที่ primary เก็บไว้ ทำให้ report บน read replica ได้ค่าประมาณเหมือนบน primary
  • MySQL 8.4 มีของที่เทียบกันได้สองตัว EXPLAIN FORMAT=TREE พิมพ์ iterator tree พร้อม cost และ row ที่ประมาณไว้ ส่วน explain_format ที่เป็น default คือ table format แบบเก่า ที่บอกว่าเป็น hash join แค่ด้วย Using join buffer (hash join) ใน column Extra ของมัน ตัว EXPLAIN ANALYZE รัน statement แล้วเพิ่มข้อมูลให้แต่ละ iterator คือเวลาถึง row แรกและถึงทุก row เป็น millisecond (เฉลี่ยต่อ loop), จำนวน row จริง และจำนวน loop โดยใช้ได้แค่ใน tree format ส่วน InnoDB เก็บ persistent index statistics ที่ sample จาก 20 page ต่อ index โดย default และคำนวณใหม่เบื้องหลังเมื่อ row ของ table เปลี่ยนไปเกิน 10% ส่วน column histogram จะมีก็ต่อเมื่อสร้างเองด้วย ANALYZE TABLE … UPDATE HISTOGRAM และจะ refresh อัตโนมัติก็ต่อเมื่อสร้างด้วย AUTO UPDATE (default คือ MANUAL UPDATE)
  • ความนิ่งของ plan Aurora PostgreSQL (ดู Amazon RDS & Aurora) เพิ่ม query plan management คือ extension apg_plan_mgmt: มันเก็บ plan ไว้และบังคับให้ optimizer เลือกจาก plan ที่อนุมัติแล้ว เพื่อกันไม่ให้ plan แย่ลงหลัง statistics, parameter หรือ engine เปลี่ยน ส่วน Query Store ของ SQL Server (SQL Server 2016 ขึ้นไป และเปิดเป็น default สำหรับ database ใหม่ตั้งแต่ SQL Server 2022) เก็บประวัติ plan ของแต่ละ query พร้อม runtime statistics และบังคับให้ใช้ plan ก่อนหน้าได้
  • รันซ้ำให้ได้ผลเดิม ตัวเลขใน diagram มาจากการรัน script ข้างบนหนึ่งรอบบน PostgreSQL 18.6 โดยปิด parallel query ถ้าใช้ default คือ parallel worker สองตัวต่อ gather ตัว plan ชุดเดิมจะมี Gather Merge กับ partial และ final aggregate เพิ่มเข้ามา และ scan จะแสดง row ต่อ process พร้อม loops=3