☕ 5-Min Tech Story #11

หายนะ N+1 Query: เมื่อ ORM หวังดี ยิง SQL ถล่ม Database รัวเป็นปืนกล

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

คุณเขียนโค้ดเรียกข้อมูลแบบสวยหรู ดูสะอาดตา แค่บรรทัดเดียว:

val orders = orderRepository.findAll()

ในเครื่องตัวเองมีข้อมูล Order แค่ 5 แถว ทุกอย่างก็รวดเร็ว สบายใจ... แต่พอขึ้น Production ที่มีลูกค้า 1,000 ออเดอร์ หน้าเว็บกลับค้างเติ่ง พอเปิดดู Log ของ Database กลับพบว่า มีคำสั่ง SQL ถูกยิงเข้ามาถึง 1,001 ครั้งในเสี้ยววินาทีเดียว!

🛵 บุรุษไปรษณีย์ขยันเกินเหตุ

สมมติว่าตาราง Order มีความสัมพันธ์กับตาราง Customer แบบ One-to-One หรือ Many-to-One

สิ่งที่มนุษย์คิดว่าระบบจะทำคือ: "ไปหยิบคำสั่งซื้อทั้งหมดมา พร้อมพ่วงชื่อลูกค้ากลับมาด้วยในรอบเดียว (JOIN)"

แต่สิ่งที่ ORM อย่าง Hibernate ทำโดยค่าเริ่มต้น (หรือตอนตั้ง Lazy/Eager พลาด) คือ:

  1. ยิง Query ที่ 1: SELECT * FROM orders เพื่อดึงออเดอร์ทั้งหมด (ได้มา N แถว)
  2. จากนั้น 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:

@Query("SELECT o FROM Order o JOIN FETCH o.customer")
fun findAllWithCustomer(): List<Order>

2. หรือใช้ @EntityGraph บน Repository Method:

@EntityGraph(attributePaths = ["customer"])
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) เพื่อตรวจจับว่าบุรุษไปรษณีย์แอบวิ่งไปซื้อของทีละชิ้นอยู่หรือเปล่า