☕ 5-Min Tech Story #44

ทำ Index ไว้ดิบดี แต่ทำไม Database ถึงเมินแล้วเดินไล่เปิดดูทีละแถวจนเซิร์ฟเวอร์ค้าง?

⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที

ตารางผู้ใช้งานของคุณมีข้อมูล 10 ล้านแถว... คุณสร้าง CREATE INDEX idx_users_created_at ไว้เป็นเกราะป้องกันอย่างมั่นใจ

แต่พอลองยิงคำสั่งค้นหาธรรมดาๆ บรรทัดนี้:

-- ดึงผู้ใช้ที่สมัครในปี 2026
SELECT * FROM users WHERE YEAR(created_at) = 2026;

แทนที่จะตอบกลับใน 2 มิลลิวินาที ฐานข้อมูลกลับนิ่งไป 8 วินาทีเต็ม! พอสั่ง EXPLAIN ANALYZE ดู ผลลัพธ์กลับทำเอาแทบช็อก เพราะมันขึ้นคำว่า:

-> Seq Scan on users (cost=0.00..289410.00 rows=52000 width=128)

Index ที่สร้างไว้หายไปไหน? ทำไม Database Engine ถึงแกล้งมองไม่เห็น แล้วเดินไปเปิดอ่านข้อมูลทั้ง 10 ล้านแถวบนดิสก์ทีละหน้า (Full Table Scan)?

📖 ดัชนีคือ "สมุดหน้าเหลืองที่จัดเรียงไว้แล้ว"

หัวใจของ B-Tree Index คือการเก็บค่าที่ถูกเรียงลำดับจากน้อยไปมาก (Sorted Order) เหมือนดัชนีท้ายเล่มพจนานุกรม

ถ้าคุณสั่งหาคำว่า "Apple" คุณสามารถเปิดกึ่งกลางเล่มแล้วกระโดดไปหาตัวอักษร 'A' ได้ทันที (Binary Search) โดยใช้เวลาแค่ระดับเสี้ยววินาที ($O(\log N)$)

"แต่ถ้าคุณบอกคนเปิดพจนานุกรมว่า: 'ช่วยหาคำศัพท์ที่ตัวอักษรสุดท้ายลงท้ายด้วย e ให้หน่อย'... ดัชนีที่เรียงตามตัวอักษรตัวหน้าจะไร้ประโยชน์ทันที! คนหาไม่มีทางลัด นอกจากต้องก้มหน้าเปิดอ่านทุกคำตั้งแต่หน้าแรกยันหน้าสุดท้าย!"

⚠️ 3 กับดักยอดฮิตที่ทำให้ Index กลายเป็นหมัน

1. เอาฟังก์ชันไปครอบคอลัมน์ (Function on Indexed Column):

ในตัวอย่าง WHERE YEAR(created_at) = 2026 ตัว Optimizer ไม่รู้ว่าค่าในตารางผ่านฟังก์ชันแล้วจะได้ผลลัพธ์เป็นอะไร มันจึงไม่สามารถกระโดดค้นหาบน B-Tree ได้ ทางแก้คือ ปล่อยคอลัมน์ให้บริสุทธิ์ แล้วย้ายตรรกะไปทำที่ฝั่งค่าคงที่แทน:

-- ท่าที่ถูกต้อง: คอลัมน์ created_at อยู่เดี่ยวๆ Index ทำงานได้ 100%
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'

2. ค้นหาด้วย Wildcard นำหน้า (Leading Wildcard %keyword):

คำสั่ง WHERE email LIKE '%@gmail.com' มีชะตากรรมเดียวกับการเปิดหาคำศัพท์จากตัวอักษรท้ายเล่ม B-Tree ไม่สามารถคาดเดาจุดเริ่มต้นได้ มันจึงยอมแพ้แล้วสแกนทั้งตารางทันที (ถ้าจำเป็นต้องค้นหาแบบนี้จริงๆ ต้องเปลี่ยนไปใช้ Trigram Index / pg_trgm หรือ Full-Text Search แทน)

3. ชนิดข้อมูลไม่ตรงจนเกิด Type Cast ล่องหน (Implicit Casting):

สมมติคอลัมน์ phone_number เก็บเป็นแบบ VARCHAR แต่ในโค้ดฝั่ง Backend ดันส่งตัวแปรเป็นตัวเลขเข้าไป:

SELECT * FROM users WHERE phone_number = 0812345678; -- ส่งเป็น Integer!

สิ่งที่ Database แอบทำข้างหลังคือแปลงเป็น WHERE CAST(phone_number AS integer) = 812345678 ซึ่งเท่ากับการเอาฟังก์ชันมาครอบคอลัมน์ Index จึงพังทลายลงทันที!

🧠 เมื่อ Optimizer ฉลาดเกินไป: Cost-Based Decision

มีอีกกรณีที่โค้ดเขียนถูกต้องทุกอย่าง แต่ Database ก็ยังจงใจเมิน Index: เมื่อผลลัพธ์ที่ดึงออกมามีสัดส่วนมากเกินไป (มักจะมากกว่า 15% - 20% ของทั้งตาราง)

เพราะการใช้ Index มี 2 สเต็ป:

  1. เปิดอ่าน B-Tree เพื่อหาตำแหน่ง Pointer ของแถวข้อมูล
  2. กระโดดสุ่มไปดึงข้อมูลจริงบนดิสก์ (Random Disk I/O)

ถ้าคุณค้นหาข้อมูลที่มีคนเข้าเกณฑ์ถึง 3 ล้านคนจาก 10 ล้านคน... ตัว Query Planner จะคำนวณต้นทุน (Cost) แล้วตัดสินใจว่า "การกระโดดไปมา 3 ล้านครั้ง ช้ากว่าการกวาดอ่านต่อเนื่องตั้งแต่ต้นจนจบ (Sequential I/O) รอบเดียว!" มันจึงตัดสินใจเมิน Index ทิ้งอย่างมีเหตุผล

💡 5-Min Takeaway

"การสร้าง Index เป็นเพียงคำแนะนำ แต่การที่ Database จะหยิบมาใช้หรือไม่ ขึ้นอยู่กับวิธีที่คุณเขียน Query" จงอย่าเอาฟังก์ชันไปครอบคอลัมน์ที่ทำ Index ไว้, หลีกเลี่ยงการใช้ Leading Wildcard, ระวังการจับคู่ชนิดข้อมูลข้ามประเภท และจำไว้ว่าถ้าคำสั่งค้นหาของคุณดึงข้อมูลออกมาเป็นกอบเป็นกำ การสแกนทั้งตารางอาจเป็นทางเลือกที่เร็วกว่าในสายตาของ Database Optimizer เสมอ