🐘 PostgreSQL เจาะลึกแบบเห็นภาพ

EXPLAIN ANALYZE • Index Algorithms • Database Pages & Row Scanning • Query ดี vs แย่

EXPLAIN ANALYZE คืออะไร และอ่านยังไง

EXPLAIN แสดง "แผนการทำงาน" ที่ Planner เลือก ส่วน ANALYZE จะรัน query จริงแล้ววัดเวลา/จำนวนแถวจริงมาเทียบกับที่ประมาณการไว้

🔎 วิธีอ่านตัวเลขในแต่ละบรรทัด

Seq Scan on orders  (cost=0.00..2181.00 rows=100000 width=97) (actual time=0.010..15.234 rows=100000 loops=1)
  Filter: (status = 'paid'::text)
  Rows Removed by Filter: 5000
  Buffers: shared hit=1081 read=320
Planning Time: 0.120 ms
Execution Time: 16.502 ms
ส่วนความหมาย
cost=0.00..2181.00ต้นทุนโดยประมาณ (startup..total) หน่วยไม่ใช่เวลา แต่เป็นหน่วยเปรียบเทียบ
rows=100000 (สีเหลือง)จำนวนแถวที่ คาดการณ์ จาก statistics
actual rows=100000 (สีเขียว)จำนวนแถวที่ เกิดขึ้นจริง — ถ้าต่างจากค่าประมาณมาก แปลว่า statistics ล้าสมัย ควร ANALYZE ตาราง
loops=1node นี้ถูกเรียกกี่รอบ (สำคัญมากใน Nested Loop — ต้องคูณ actual time ด้วย loops)
Buffers: hit / read (สีแดง)hit = เจอใน cache แล้ว, read = ต้องอ่านจาก disk — read เยอะ = cache ไม่พอ
Rows Removed by Filterอ่านมาแล้วทิ้งเพราะไม่ตรงเงื่อนไข — ถ้าเยอะมาก บ่งบอกว่าควรมี index ช่วยกรองตั้งแต่ต้น
💡 คำสั่งแนะนำที่ควรใช้เสมอ: EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...

ชนิดของ Scan Node (คลิกเล่นอนิเมชัน)

🟨 Seq Scan (Sequential Scan)

อ่านทุกหน้าของตารางเรียงตามลำดับกายภาพ ตั้งแต่ต้นจนจบ เหมาะกับตารางเล็ก หรือ query ที่ต้องอ่านเกือบทั้งตารางอยู่แล้ว

Seq Scan on orders  (cost=0.00..2181.00 rows=100000 ..) (actual rows=100000 loops=1)

🟦 Index Scan

เดินลง B-tree จาก root → branch → leaf เพื่อหาตำแหน่งแถว แล้ว "กระโดด" (random I/O) เข้าไปอ่านข้อมูลจริงที่ heap ทีละแถว

ROOT
Branch L
Branch R
Leaf
Leaf ★
Leaf
Leaf

⬇ จากนั้นกระโดดเข้า Heap (สุ่มตำแหน่ง ไม่เรียงลำดับ)

Index Scan using orders_id_idx on orders  (cost=0.42..8.44 rows=1) (actual rows=1 loops=1)
  Index Cond: (id = 42)

🟩 Index Only Scan

เหมือน Index Scan แต่ ไม่ต้องเข้า heap เลย เพราะข้อมูลที่ต้องการมีครบใน index อยู่แล้ว (covering index) และ Visibility Map บอกว่าแถวนั้น "all-visible"

ROOT
Branch
Branch
Leaf
Leaf ★
Leaf
Heap: ❌ ไม่ถูกแตะเลย
Index Only Scan using orders_id_idx on orders (cost=0.42..4.44 rows=1) (actual rows=1 loops=1)
  Heap Fetches: 0   ← ยิ่งเลขนี้เป็น 0 ยิ่งดี

🟧 Bitmap Heap Scan / Bitmap Index Scan

2 เฟส: (1) เดิน index สร้าง "bitmap" ของตำแหน่งที่ตรงเงื่อนไขทั้งหมดก่อน (2) เรียง bitmap ตามลำดับกายภาพแล้วค่อยไปอ่าน heap — ลด random I/O เมื่อ match หลายแถว

เฟส 1 — สร้าง Bitmap จาก Index (สุ่มลำดับ):
เฟส 2 — อ่าน Heap ตามลำดับกายภาพ (เรียงแล้ว เร็วกว่า):
Bitmap Heap Scan on orders  (cost=12.5..456.2 rows=800)
  Recheck Cond: (customer_id = 88)
  ->  Bitmap Index Scan on orders_customer_idx  (cost=0.00..12.3 rows=800)

🔁 Nested Loop Join

สำหรับแต่ละแถวใน outer table จะ "loop" ไปหาแถวที่ match ใน inner table — เหมาะกับข้อมูลจำนวนน้อย/มี index ฝั่ง inner แต่ถ้า outer เยอะและ inner ไม่มี index จะช้ามาก (loops คูณกันได้มหาศาล)

Outer: customers
Inner: orders (scan ซ้ำทุกรอบ)
รอบที่ทำงาน (loops): 0
Nested Loop  (cost=0.42..1200 rows=40) (actual time=.. rows=40 loops=1)
  ->  Seq Scan on customers  (rows=4)
  ->  Index Scan on orders  (rows=10) (loops=4) ← รันซ้ำ 4 รอบ!

🪣 Hash Join

สร้าง hash table จากตารางเล็กก่อน (Build) แล้วเอาตารางใหญ่มา "probe" หา bucket เดียวกัน — เร็วกว่า Nested Loop เมื่อทั้งสองตารางใหญ่และไม่มี index ที่เหมาะ

Build phase — ตารางเล็กตกลง bucket ตาม hash(key):
Bucket 0
0
Bucket 1
0
Bucket 2
0
Bucket 3
0
Probe phase — ตารางใหญ่เช็ค bucket เดียวกัน:
Hash Join  (cost=45..980 rows=500)
  Hash Cond: (o.customer_id = c.id)
  ->  Seq Scan on orders o
  ->  Hash  (cost=20..20 rows=200)
        ->  Seq Scan on customers c

🔀 Merge Join

ทั้งสองฝั่งต้องเรียงลำดับอยู่แล้ว (จาก index หรือ Sort node) ใช้ 2 pointer เดินคู่ขนานเทียบค่าเรื่อยๆ — เหมาะกับข้อมูลใหญ่ที่เรียงลำดับมาแล้ว

A (เรียงแล้ว): 1, 3, 4, 7, 9
B (เรียงแล้ว): 2, 3, 4, 8
ผลลัพธ์ที่ match:
Merge Join  (cost=210..340 rows=2)
  Merge Cond: (a.id = b.id)

📊 Sort — และปัญหา "spill to disk"

ถ้าข้อมูลที่ต้อง sort ใหญ่กว่า work_mem Postgres จะต้องเขียนไฟล์ชั่วคราวลง disk (external merge) ซึ่งช้ากว่ามาก

Sort  (cost=..) (actual rows=100000 loops=1)
  Sort Key: created_at
  Sort Method: quicksort  Memory: 25kB

⚡ Parallel Seq Scan + Gather

แบ่งตารางให้ worker หลายตัวช่วยกันสแกนพร้อมกัน แล้วรวมผลด้วย Gather / Gather Merge — ใช้ได้เมื่อตารางใหญ่พอและ max_parallel_workers_per_gather อนุญาต

Worker 0
Worker 1
Worker 2
Gather  (cost=.. rows=100000)
  Workers Planned: 3   Workers Launched: 3
  ->  Parallel Seq Scan on orders  (rows=33333 loops=3)

🚩 สัญญาณอันตรายที่ต้องระวังใน EXPLAIN

  • Seq Scan บนตารางที่มีแถวหลักแสน/ล้าน โดยไม่มีเหตุผล (ไม่ได้ต้องการเกือบทุกแถว)
  • estimated rows กับ actual rows ต่างกันหลายเท่า → statistics ล้าสมัย ต้อง ANALYZE
  • Nested Loop ที่มี loops สูงมากและ inner ไม่มี index รองรับ
  • Sort Method: external merge Disk: ... → ต้องเพิ่ม work_mem
  • Buffers read สูงกว่า hit มาก (cache miss เยอะ) เกิดขึ้นซ้ำๆ ในเวลาทำงานปกติ
  • Rows Removed by Filter สูงมาก → filter ทำงานหลัง scan ทั้งตาราง ควรมี index ที่ตรงเงื่อนไข
  • Heap Fetches มากใน Index Only Scan → ตารางมี dead tuples เยอะ ต้อง VACUUM

Index Algorithm แบบต่างๆ ต่างกันยังไง

Postgres มี index หลายชนิด แต่ละแบบถูกออกแบบมาให้เหมาะกับรูปแบบข้อมูล/query ต่างกัน เลือกผิดแบบ = index มีแต่ไม่ถูกใช้ หรือใช้แล้วไม่คุ้ม

🌳 B-tree (ค่า default, ใช้บ่อยที่สุด)

โครงสร้างต้นไม้สมดุล (balanced tree) รองรับ = < > <= >= BETWEEN, ORDER BY, LIKE 'prefix%' ค้นหา O(log n)

50
20
80
10,15
30,42 ★
60,70
90,99

#️⃣ Hash Index

ใช้ hash function แปลงค่าเป็น bucket แล้วเข้าตรงจุดเลย — เร็วมากแต่รองรับ เฉพาะ = เท่านั้น (ไม่รองรับ range/ORDER BY) เหมาะกับ equality lookup ที่ key ยาวมากๆ

hash('abc')
➡️
Bucket 2
🎯

📚 GIN (Generalized Inverted Index)

เก็บแบบ "inverted index": 1 คีย์ → posting list ของหลายแถว เหมาะกับข้อมูลที่แต่ละแถวมีหลายค่า เช่น jsonb, array, full-text search (tsvector) เขียนช้ากว่าอ่านเยอะ

คำค้น "postgres" ชี้ไปยังเอกสารที่มีคำนี้:

🗺️ GiST (Generalized Search Tree)

ต้นไม้ที่ใช้ bounding box ครอบข้อมูล เหมาะกับข้อมูลเชิงพื้นที่ (geometry, PostGIS), full-text, exclusion constraints และ nearest-neighbor search (<->)

🧩 SP-GiST (Space-Partitioned GiST)

ต้นไม้แบบไม่สมดุล แบ่งพื้นที่เป็นส่วนๆ (เช่น quad-tree/radix tree) เหมาะกับข้อมูลที่มีโครงสร้างไม่สม่ำเสมอ เช่น IP address (inet), เบอร์โทร, ข้อความที่มี prefix ร่วมกัน

Q1
Q2 ★
Q3
Q4

📦 BRIN (Block Range Index)

เก็บแค่ min/max ของแต่ละช่วง "block" (เช่น 128 หน้า/ช่วง) เล็กมาก (เล็กกว่า B-tree หลายร้อยเท่า) เหมาะกับตารางใหญ่มากที่ข้อมูลเรียงตามธรรมชาติอยู่แล้ว เช่น created_at ที่ insert ตามเวลา

ค้นหา: created_at = '2024-05-10' (แถบไหน min-max ไม่ครอบคลุมจะข้ามทันที)
หน้า 1-1000: 01-01 → 01-15
หน้า 1001-2000: 01-16 → 02-20
หน้า 2001-3000: 02-21 → 04-01
หน้า 3001-4000: 04-02 → 05-20 ★
หน้า 4001-5000: 05-21 → 07-01

📋 สรุปเปรียบเทียบ

Indexรองรับ Range/Sortเหมาะกับขนาดต้นทุนตอนเขียน
B-tree✅ทั่วไป, equality + rangeกลางกลาง
Hash❌ (= เท่านั้น)equality lookup, key ยาวเล็ก-กลางต่ำ
GIN❌jsonb, array, full-textใหญ่สูง (ใช้ fastupdate ช่วยได้)
GiSTบางส่วนgeometry, KNN, exclusionกลาง-ใหญ่กลาง
SP-GiSTบางส่วนIP, phone, prefix dataเล็ก-กลางต่ำ-กลาง
BRIN✅ (แบบหยาบ)ตารางใหญ่ที่ข้อมูลเรียงตามธรรมชาติเล็กมากต่ำมาก

กลไก Database Page: ที่เก็บข้อมูลจริงเพื่อ Scan แถว

Postgres ไม่ได้เก็บข้อมูลเป็น "แถว" ลอยๆ แต่เก็บเป็นไฟล์แบ่งเป็นหน้า (page) ขนาดคงที่ 8KB เรียงต่อกัน ทุกการ Scan ไม่ว่าจะ Seq Scan หรือ Index Scan สุดท้ายต้องอ่านผ่านหน้าเหล่านี้เสมอ

📄 โครงสร้างภายในของ 1 Page (8KB)

แต่ละหน้าแบ่งเป็น 4 ส่วน: Page Header (metadata) → Line Pointer Array (ที่อยู่ของแต่ละแถว เติบโตจากบนลงล่าง) → Free Space (พื้นที่ว่างตรงกลาง) → Tuple Data (ข้อมูลแถวจริง เติบโตจากล่างขึ้นบน)

Header
Free Space
0 แถวในหน้านี้
-- แต่ละแถวมี "line pointer" (item id) ชี้ไปตำแหน่ง offset ของ tuple จริงในหน้าเดียวกัน
-- เมื่อ Free Space หมด → Postgres ต้องเปิด page ใหม่มาเก็บแถวถัดไป (นี่คือเหตุผลที่ตารางโตเป็นไฟล์หลาย page)

🧬 Tuple Header กับ MVCC (xmin / xmax)

ทุก tuple มี header เก็บ xmin (transaction ที่สร้างแถวนี้) และ xmax (transaction ที่ทำให้แถวนี้ตาย ถ้ายังไม่ตายจะเป็น NULL) — Postgres ไม่เคยแก้ข้อมูลทับที่เดิม แต่เขียนแถวใหม่เสมอ (append-only)

🔥 HOT Update (Heap-Only Tuple)

ถ้าแถวใหม่ยังอยู่ "หน้าเดียวกัน" กับแถวเดิม (มีที่ว่างพอ) และไม่ได้แก้คอลัมน์ที่มี index เลย Postgres จะทำ HOT update — แก้แค่ลิงก์ภายในหน้า โดย ไม่ต้องแตะ index เลยสักตัว เร็วกว่ามาก

✅ HOT Update (ในหน้าเดียวกัน)
❌ Update ปกติ (ต้องย้ายหน้า + แก้ index)

🗺️ Free Space Map (FSM)

Postgres เก็บแผนที่แยกต่างหากไว้บอกว่าแต่ละหน้ามีที่ว่างเหลือเท่าไหร่ เวลา INSERT จะเปิด FSM มาหาหน้าที่ "พอมีที่" ก่อน แทนที่จะไปต่อท้ายไฟล์เสมอ

👁️ Visibility Map (VM) — หัวใจของ Index Only Scan

แต่ละหน้าจะมี 1 บิตบอกว่า "ทุกแถวในหน้านี้มองเห็นได้จากทุก transaction แล้ว" (all-visible) ถ้าใช่ Postgres จะข้ามการเข้า heapได้เลยตอน Index Only Scan และ VACUUM ก็ข้ามหน้านี้ได้เช่นกัน (ประหยัดงานมหาศาล)

🧠 Buffer Pool (shared_buffers) — ก่อน Scan ต้องผ่าน RAM เสมอ

ไม่ว่าจะ scan แบบไหน Postgres ต้องขอ "pin" หน้านั้นเข้ามาใน buffer pool (RAM) ก่อนเสมอ ถ้าหน้านั้นเคยถูกใช้แล้วจะเจอใน cache (hit) แต่ถ้าไม่เคยต้องไปอ่านจาก disk (miss) — นี่คือที่มาของ Buffers: hit/read ใน EXPLAIN

💽 Disk (pages เรียงเป็นไฟล์)
🧠 Buffer Pool (RAM, จำนวนช่องจำกัด — ที่นี่มี 4 ช่อง)

📍 ctid — ที่อยู่กายภาพของแถว (Page, Offset)

ทุกแถวมี ctid ซ่อนอยู่ เช่น (0,5) หมายถึง "หน้าเลข 0 ช่อง line pointer ที่ 5" — นี่คือสิ่งที่ Index เก็บไว้ชี้กลับมาที่ heap เวลาทำ Index Scan (ตรงกับอนิเมชัน "กระโดดเข้า Heap" ในแท็บ EXPLAIN)

🧹 VACUUM — เก็บกวาด Dead Tuples คืนพื้นที่

เมื่อ UPDATE/DELETE ทำให้เกิด dead tuple ค้างอยู่ VACUUM จะไล่ mark line pointer เป็น "unused" คืนพื้นที่กลับเข้า FSM และอัปเดต Visibility Map ให้ Index Only Scan เร็วขึ้น (ถ้าไม่ VACUUM เป็นประจำ ตารางจะบวม/"bloat" และ scan ช้าลงเรื่อยๆ)

ตัวอย่าง Query แย่ vs ดี (จับคู่เทียบกัน)

1. SELECT คอลัมน์ทั้งหมด
❌ แย่
SELECT * FROM orders WHERE id = 1;
ดึงคอลัมน์เกินจำเป็น เสีย I/O, เสีย network, และทำให้ Index Only Scan เป็นไปไม่ได้
✅ ดี
SELECT id, status, total FROM orders WHERE id = 1;
ดึงเฉพาะที่ใช้จริง เปิดโอกาสให้ index ครอบคลุม (covering index) และ Index Only Scan ทำงานได้
2. ใช้ฟังก์ชันครอบคอลัมน์ที่มี Index
❌ แย่
SELECT * FROM users WHERE lower(email) = 'a@x.com';
ครอบ index column ด้วยฟังก์ชัน ทำให้ B-tree index ธรรมดาใช้ไม่ได้ (non-sargable) → กลายเป็น Seq Scan
✅ ดี
-- สร้าง expression index
CREATE INDEX ON users (lower(email));
SELECT * FROM users WHERE lower(email) = 'a@x.com';
สร้าง index บน expression เดียวกัน → planner ใช้ Index Scan ได้ปกติ
3. NOT IN กับค่าที่อาจมี NULL
❌ แย่
SELECT * FROM a WHERE id NOT IN (SELECT id FROM b);
ถ้า subquery มีค่า NULL แม้แค่แถวเดียว ผลลัพธ์ทั้งหมดจะกลายเป็นว่างเปล่า (logic ผิดแบบเงียบๆ) และ performance แย่
✅ ดี
SELECT * FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id);
ปลอดภัยจาก NULL, planner มักแปลงเป็น Anti Join ที่มีประสิทธิภาพดีกว่ามาก
4. LIKE ที่ขึ้นต้นด้วย %
❌ แย่
SELECT * FROM products WHERE name LIKE '%phone%';
B-tree index ใช้ไม่ได้กับ wildcard นำหน้า → ต้อง Seq Scan เต็มตาราง
✅ ดี
CREATE EXTENSION pg_trgm;
CREATE INDEX ON products USING gin (name gin_trgm_ops);
SELECT * FROM products WHERE name LIKE '%phone%';
GIN + pg_trgm รองรับการค้นหาคำกลางข้อความได้อย่างมีประสิทธิภาพ
5. OFFSET หน้าลึกๆ
❌ แย่
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100000;
ต้องอ่านและทิ้ง 100,000 แถวทุกครั้ง ยิ่งหน้าลึกยิ่งช้า
✅ ดี
SELECT * FROM posts
WHERE id > :last_id ORDER BY id LIMIT 20;
Keyset pagination ใช้ index seek ตรงจุด เร็วคงที่ทุกหน้า
6. N+1 Query (loop query ใน application)
❌ แย่
-- ใน loop ของแอป
SELECT * FROM comments WHERE post_id = ?;  -- เรียก N ครั้ง
ยิงคำสั่งไปกลับหาฐานข้อมูล N ครั้ง เสีย network round-trip มหาศาล
✅ ดี
SELECT * FROM comments
WHERE post_id = ANY($1::int[]);  -- ครั้งเดียว
ดึงข้อมูลทั้งหมดในคำสั่งเดียวแล้วจับกลุ่มฝั่งแอป ลด round-trip เหลือ 1 ครั้ง
7. Type mismatch ทำ Index พัง
❌ แย่
-- phone เป็น varchar แต่ query ส่ง int
SELECT * FROM users WHERE phone = 0812345678;
เกิด implicit cast ทำให้ index บนคอลัมน์เดิมใช้ไม่ได้ตรงๆ
✅ ดี
SELECT * FROM users WHERE phone = '0812345678';
ชนิดข้อมูลตรงกัน planner ใช้ index ได้ตามปกติ
8. COUNT(*) แบบแม่นยำบนตารางมหาศาล
❌ แย่
SELECT COUNT(*) FROM big_events;
ต้อง Seq Scan ทั้งตารางเพื่อนับแถวจริง (Postgres ไม่มี count cache) ช้ามากถ้ามีหลักสิบล้านแถว
✅ ดี
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'big_events';
ถ้าต้องการแค่ตัวเลขโดยประมาณ (เช่นแสดงหน้าเว็บ) ใช้สถิติที่เก็บไว้แล้วเร็วกว่ามาก
9. ลำดับคอลัมน์ใน Composite Index ผิด
❌ แย่
CREATE INDEX ON orders (created_at, customer_id);
-- แต่ query กรองด้วย customer_id อย่างเดียว
SELECT * FROM orders WHERE customer_id = 5;
Index เรียง created_at เป็นหลัก ทำให้ filter customer_id อย่างเดียวใช้ index ได้ไม่เต็มประสิทธิภาพ
✅ ดี
CREATE INDEX ON orders (customer_id, created_at);
SELECT * FROM orders WHERE customer_id = 5
ORDER BY created_at DESC;
คอลัมน์ที่ query ใช้กรอง equality ควรอยู่หน้าสุด ตามด้วยคอลัมน์ที่ใช้ sort/range
10. OR หลายเงื่อนไขคนละคอลัมน์
❌ แย่
SELECT * FROM orders
WHERE customer_id = 5 OR product_id = 9;
OR ข้ามคอลัมน์มักบังคับให้ planner เลือก Seq Scan เพราะรวมผลจาก index 2 ตัวด้วย OR ตรงๆ ทำได้ไม่เสมอไป
✅ ดี
SELECT * FROM orders WHERE customer_id = 5
UNION
SELECT * FROM orders WHERE product_id = 9;
แยกเป็น UNION ทำให้แต่ละ query ใช้ index ของตัวเองได้เต็มที่ (Postgres จัด BitmapOr ให้อัตโนมัติได้ในหลายกรณีเช่นกัน)