หายนะ N+1 Query: เมื่อ ORM หวังดี ยิง SQL ถล่ม Database รัวเป็นปืนกล
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 20 วินาที
คุณเขียนโค้ดเรียกข้อมูลแบบสวยหรู ดูสะอาดตา แค่บรรทัดเดียว:
ในเครื่องตัวเองมีข้อมูล Order แค่ 5 แถว ทุกอย่างก็รวดเร็ว สบายใจ... แต่พอขึ้น Production ที่มีลูกค้า 1,000 ออเดอร์ หน้าเว็บกลับค้างเติ่ง พอเปิดดู Log ของ Database กลับพบว่า มีคำสั่ง SQL ถูกยิงเข้ามาถึง 1,001 ครั้งในเสี้ยววินาทีเดียว!
🛵 บุรุษไปรษณีย์ขยันเกินเหตุ
สมมติว่าตาราง Order มีความสัมพันธ์กับตาราง
Customer แบบ One-to-One หรือ Many-to-One
สิ่งที่มนุษย์คิดว่าระบบจะทำคือ: "ไปหยิบคำสั่งซื้อทั้งหมดมา พร้อมพ่วงชื่อลูกค้ากลับมาด้วยในรอบเดียว (JOIN)"
แต่สิ่งที่ ORM อย่าง Hibernate ทำโดยค่าเริ่มต้น (หรือตอนตั้ง Lazy/Eager พลาด) คือ:
-
ยิง Query ที่ 1:
SELECT * FROM ordersเพื่อดึงออเดอร์ทั้งหมด (ได้มา N แถว) -
จากนั้น Hibernate วนลูปอ่านทีละแถว... พอเจอว่ามี field
customerมันก็เลยสั่งยิง Query ที่ 2, 3, 4, ... จนถึง N+1:SELECT * FROM customers WHERE id = ?แยกเดี่ยวๆ ทีละคน!
เหมือนคุณสั่งให้พนักงานไปซื้อกะเพรา 10 กล่อง แทนที่เขาจะไปสั่งร้านแล้วหิ้วกลับมาทีเดียว 10 กล่อง... เขากลับ ขี่มอเตอร์ไซค์ไปซื้อกล่องที่ 1 แล้วขี่กลับมาส่ง จากนั้นขี่ไปซื้อกล่องที่ 2 แล้วขี่กลับมาส่ง วนไปเรื่อยๆ จนครบ 10 รอบ!
🛡️ ท่าแก้เผ็ด: สั่งหิ้วกลับมาทีเดียว (JOIN FETCH / EntityGraph)
ทางแก้ที่ตรงจุดที่สุดคือการหยุดความขี้เกียจของ ORM แล้วสั่งอย่างชัดเจนว่า "กูจะเอาข้อมูลลูกค้ารอบนี้ด้วย อย่าแยกไปซื้อ!"
1. ใช้ JOIN FETCH ใน JPQL:
fun findAllWithCustomer(): List<Order>
2. หรือใช้ @EntityGraph บน Repository Method:
override fun findAll(): List<Order>
ผลลัพธ์คือ Hibernate จะสร้าง SQL ที่ผูก
LEFT OUTER JOIN ในระดับฐานข้อมูล ยิงแค่
1 Query จบครบทุกออเดอร์ จากเวลาโหลด 3 วินาที
จะลดเหลือไม่ถึง 10 มิลลิวินาทีทันที
💡 5-Min Takeaway
"ความสะดวกของ ORM มักซ่อนต้นทุนเครือข่ายมหาศาลเอาไว้"
อย่าหลงเชื่อว่าโค้ดที่สั้นและคอมไพล์ผ่าน
จะทำงานอย่างมีประสิทธิภาพในระดับ Database เสมอ หมั่นเปิดดู SQL Query
Log (เช่น ตั้งค่า show-sql=true ในช่วง Dev)
เพื่อตรวจจับว่าบุรุษไปรษณีย์แอบวิ่งไปซื้อของทีละชิ้นอยู่หรือเปล่า