ปัญหา N+1 Query

ปัญหา N+1 Query

ปัญหา N+1 Query คือ anti-pattern ด้านประสิทธิภาพที่เกิดขึ้นเมื่อดึงข้อมูลรายการด้วย query ครั้งเดียว แล้วตามด้วยการ query แยกทีละรายการเพื่อดึงข้อมูลที่เกี่ยวข้องของแต่ละ record ส่งผลให้เกิดการเข้าถึงฐานข้อมูลทั้งหมด N+1 ครั้ง

"N" และ "1" ใน N+1

เมื่อใช้งาน ORM (Object-Relational Mapping) หรือ query builder มักเกิดปัญหานี้โดยไม่รู้ตัว กระบวนการเป็นดังนี้

  1. ดึงรายการจาก parent table — นี่คือ query ที่เป็น "1"
  2. สำหรับแต่ละรายการจาก N รายการที่ได้มา ให้สอบถามข้อมูลที่เกี่ยวข้องจาก child table — นี่คือ query ที่รันซ้ำ "N" ครั้ง

ลองพิจารณากรณีการแสดงหน้ารายการ glossary ก่อนอื่น SELECT 50 รายการจากตาราง glossaries จากนั้นดึงข้อมูลการแปลของแต่ละคำจากตาราง translations ทีละรายการ รวมแล้ว query จะรันทั้งหมด 51 ครั้ง หากจำนวนรายการเพิ่มเป็น 500 ก็จะรัน 501 ครั้ง

เหตุใดจึงเป็นปัญหาร้ายแรง

แม้ query แต่ละครั้งจะตอบสนองภายในไม่กี่มิลลิวินาที แต่เนื่องจากจำนวนครั้งเพิ่มขึ้นแบบ linear response time จึงแย่ลงตามสัดส่วนของปริมาณข้อมูล ในระหว่างการพัฒนาบนเครื่อง local ตารางอาจมีเพียงหลักสิบรายการ แต่ในระบบ production อาจพองตัวเป็นหลักพัน และความล่าช้าจะปรากฏให้เห็นเป็นครั้งแรกตรงนั้น — อุบัติเหตุแบบนี้ไม่ใช่เรื่องแปลก

นอกจากนี้ ในสภาพแวดล้อมที่ DB server และ application server แยกกันผ่านเครือข่าย overhead ของ round trip จะสะสมตามจำนวนครั้ง แม้ query เองจะเบา แต่ network latency กลายเป็นปัจจัยหลักที่ครอบงำประสิทธิภาพ

แนวทางการแก้ไข

วิธีแก้ปัญหาที่เป็นตัวแทนแบ่งออกเป็น 2 ประเภทหลัก ได้แก่ eager loading (การโหลดล่วงหน้า) และ JOIN

วิธีการตัวอย่าง frameworkภาพรวม
eager loadingincludes ของ Rails / select_related ของ Django / include ของ PrismaORM สร้าง query สำหรับดึงข้อมูลที่เกี่ยวข้องรวมกันโดยอัตโนมัติ
explicit JOINraw SQL / .select("*, children(*)") ของ Supabaseรวม parent และ child ใน query เดียวแล้วส่งคืน
DataLoader patternDataLoader ของ GraphQL / cache() ของ Next.jsรวบรวม key ภายใน request แล้วแปลงเป็น batch query

เมื่อใช้ Supabase การใช้ประโยชน์จาก embedded query ของ PostgREST (select("*, translations(*)")) ช่วยให้ดึงข้อมูลที่เกี่ยวข้องได้ใน 1 request โดยไม่ต้องเขียน loop ในฝั่ง application

การตรวจจับและการป้องกัน

ในระหว่างการพัฒนา การตรวจสอบ query log เป็นวิธีที่มีประสิทธิภาพในการค้นหา N+1 สำหรับ PostgreSQL ให้ตั้งค่า log_min_duration_statement เป็น 0 เพื่อบันทึก query ทั้งหมด แล้วตรวจสอบว่ามี query ที่มีรูปแบบเดียวกันรันต่อเนื่องกันหรือไม่ สำหรับ Rails มี bullet gem และสำหรับ Django มี django-debug-toolbar เป็นเครื่องมือตรวจจับ

เพียงแค่ตระหนักในขั้นตอน code review ว่า "มี await อยู่ภายใน loop หรือไม่" ก็สามารถป้องกัน N+1 ได้เป็นจำนวนมากก่อนที่จะเกิดปัญหา