ทำ Index ไว้ดิบดี แต่ทำไม Database ถึงเมินแล้วเดินไล่เปิดดูทีละแถวจนเซิร์ฟเวอร์ค้าง?
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที
ตารางผู้ใช้งานของคุณมีข้อมูล 10 ล้านแถว... คุณสร้าง CREATE INDEX idx_users_created_at ไว้เป็นเกราะป้องกันอย่างมั่นใจ
แต่พอลองยิงคำสั่งค้นหาธรรมดาๆ บรรทัดนี้:
SELECT * FROM users WHERE YEAR(created_at) = 2026;
แทนที่จะตอบกลับใน 2 มิลลิวินาที ฐานข้อมูลกลับนิ่งไป 8 วินาทีเต็ม! พอสั่ง EXPLAIN ANALYZE ดู ผลลัพธ์กลับทำเอาแทบช็อก เพราะมันขึ้นคำว่า:
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 ได้ ทางแก้คือ ปล่อยคอลัมน์ให้บริสุทธิ์ แล้วย้ายตรรกะไปทำที่ฝั่งค่าคงที่แทน:
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 ดันส่งตัวแปรเป็นตัวเลขเข้าไป:
สิ่งที่ Database แอบทำข้างหลังคือแปลงเป็น WHERE CAST(phone_number AS integer) = 812345678 ซึ่งเท่ากับการเอาฟังก์ชันมาครอบคอลัมน์ Index จึงพังทลายลงทันที!
🧠 เมื่อ Optimizer ฉลาดเกินไป: Cost-Based Decision
มีอีกกรณีที่โค้ดเขียนถูกต้องทุกอย่าง แต่ Database ก็ยังจงใจเมิน Index: เมื่อผลลัพธ์ที่ดึงออกมามีสัดส่วนมากเกินไป (มักจะมากกว่า 15% - 20% ของทั้งตาราง)
เพราะการใช้ Index มี 2 สเต็ป:
- เปิดอ่าน B-Tree เพื่อหาตำแหน่ง Pointer ของแถวข้อมูล
- กระโดดสุ่มไปดึงข้อมูลจริงบนดิสก์ (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 เสมอ