N+1 Query Problem บั๊กเงียบที่ทำให้ Backend ช้าลงแบบไม่รู้ตัว
N+1 Query Problem คือปัญหาด้านประสิทธิภาพที่โค้ดดูถูกต้องและอ่านง่าย แต่กลับทำให้ backend ยิง SQL มากเกินจำเป็นโดยไม่รู้ตัว บทความนี้อธิบายสาเหตุ อาการ วิธีตรวจจับ และแนวทางแก้ที่เหมาะสมในงานจริง

N+1 Query Problem บั๊กเงียบที่ทำให้ Backend ช้าลงแบบไม่รู้ตัว
N+1 Query Problem คือบั๊กด้าน performance ที่อันตรายตรงที่มันดูเหมือนไม่ใช่ปัญหา
- โค้ดอ่านง่าย
- ผลลัพธ์ถูกต้อง
- ระบบดูทำงานปกติ
แต่เบื้องหลัง backend อาจยิง SQL มากเกินจำเป็นแบบเงียบ ๆ จนทำให้ระบบช้าลงเมื่อข้อมูลใน production โตขึ้น
N+1 Query Problem คืออะไร
เคสคลาสสิกคือการดึงข้อมูลรายการหลักมาก่อน 1 ครั้ง แล้วค่อยวนลูปไปดึงข้อมูลที่เกี่ยวข้องทีละรายการ
ตัวอย่างเช่น
- query แรกดึง
users - จากนั้นวน
userทีละคนเพื่อดึงordersหรือcomments
ถ้ามีผู้ใช้ 100 คน ระบบจะยิง
- 1 query เพื่อดึง users
- 100 queries เพื่อดึง orders ของแต่ละ user
รวมทั้งหมดเป็น 101 queries และนี่คือที่มาของคำว่า N+1
Nคือจำนวนแถวของข้อมูลหลัก+1คือ query แรกที่ใช้ดึงรายการหลัก
ตัวอย่างโค้ดที่ทำให้เกิดปัญหา
ใน Node.js โค้ดลักษณะนี้พบได้บ่อยมาก
const users = await userRepo.find()
for (const user of users) {
user.orders = await orderRepo.findByUserId(user.id)
}
ตอนพัฒนาบนเครื่อง local อาจมีผู้ใช้แค่ 5 คน ทุกอย่างจึงดูเร็วและปกติ แต่เมื่อขึ้น production แล้วมีผู้ใช้ 2,000 คน หน้าเดียวอาจกลายเป็น 2,001 queries ทันที
ทำไมปัญหานี้จึงเจอบ่อยกับ ORM
N+1 มักเกิดร่วมกับ ORM เพราะหลายระบบรองรับ lazy loading ซึ่งหมายถึงความสัมพันธ์ของข้อมูลจะยังไม่ถูกดึงมาจนกว่าเราจะเรียกใช้ field นั้นจริง ๆ
ตัวอย่างเช่น
user.orderspost.comments
โค้ดดูสะอาดและเขียนง่าย แต่ทุกครั้งที่แตะ relation อาจมี SQL ใหม่เกิดขึ้นโดยที่เราไม่ทันสังเกต
นี่คือเหตุผลที่ N+1 มักแอบซ่อนอยู่ในโค้ดที่ดูเรียบร้อยและไม่มี error
ผลกระทบไม่ได้มีแค่จำนวน query ที่เพิ่มขึ้น
หลายคนคิดว่าปัญหาคือแค่ยิง query เยอะ แต่ผลกระทบจริง ๆ กว้างกว่านั้นมาก
แต่ละ query มีต้นทุน เช่น
- network round trip ระหว่าง app กับ database
- เวลา parse, plan และ execute ใน database
- เวลารอ connection จาก pool
เมื่อมี request หลายชุดเข้ามาพร้อมกัน ปริมาณ query ที่มากเกินไปอาจทำให้ connection pool เต็ม จน request อื่นต้องรอต่อคิว สุดท้าย backend จะช้าทั้งระบบ แม้ CPU ของ application จะยังไม่เต็มก็ตาม
วิธีสังเกตและตรวจจับ N+1
วิธีเริ่มต้นที่ดีที่สุดคือ เปิด query logging แล้วดูว่า endpoint หนึ่งยิง SQL กี่ครั้ง
หากเห็น pattern เดิมซ้ำ ๆ เช่น
SELECT * FROM orders WHERE user_id = ?
โผล่มาหลายสิบหรือหลายร้อยครั้ง ให้สงสัย N+1 ได้ทันที
สิ่งสำคัญคืออย่าดูแค่เวลารวมตอน local เพราะข้อมูลจำนวนน้อยอาจไม่แสดงปัญหา ควรทดสอบด้วย dataset ที่ใกล้เคียง production เช่น
- 10 users กับ 1,000 users ให้ภาพต่างกันมาก
- endpoint ที่ดูเร็วใน local อาจช้ามากเมื่อจำนวน row โตขึ้น
แนวทางแก้ไขแบบที่ 1: ใช้ JOIN
ถ้าหน้าเดียวต้องการ users พร้อม orders การใช้ JOIN หรือ relation load strategy ที่ ORM รองรับมักเป็นทางแก้แรกที่ชัดเจน
ข้อดีคือ database สามารถรวมข้อมูลให้ใน query เดียว ทำให้ลดจำนวนรอบที่ต้องคุยกับฐานข้อมูล
อย่างไรก็ตาม การใช้ join ก็มีข้อควรระวังเช่นกัน หาก join หลาย relation พร้อมกัน อาจเกิดข้อมูลซ้ำจำนวนมาก
ตัวอย่างเช่น
- user 1 คนมี orders 50 รายการ
- และมี comments 100 รายการ
ถ้า join ทั้งสอง relation พร้อมกัน จำนวน row ที่ได้อาจพุ่งสูงมาก จน payload ใหญ่เกินจำเป็นและกระทบ performance ในอีกด้านหนึ่ง
แนวทางแก้ไขแบบที่ 2: ใช้ IN batching
อีกวิธีที่นิยมและมักควบคุมได้ง่ายกว่า คือดึงข้อมูลหลักก่อน แล้วค่อยดึงข้อมูลความสัมพันธ์แบบ batch
ขั้นตอนคือ
- ดึง
usersมาก่อน - เก็บ
userIds - query
ordersด้วยWHERE user_id IN (...ids) - นำผลลัพธ์มา group กลับเข้าผู้ใช้ใน memory
วิธีนี้ทำให้จาก 101 queries เหลือเพียง 2 queries
- query แรกสำหรับ users
- query ที่สองสำหรับ orders ทั้งหมดของ users ชุดนั้น
แพตเทิร์นนี้มักปลอดภัยกว่า join ใหญ่ ๆ และเหมาะมากเมื่อรูปข้อมูลมี one-to-many ที่ขยายตัวง่าย
แนวทางแก้ไขแบบที่ 3: ใช้ eager loading ใน ORM
ORM ส่วนใหญ่มีวิธีบอกล่วงหน้าว่าความสัมพันธ์ใดต้องถูกโหลดมาด้วย เพื่อไม่ให้ lazy loading ยิง query เพิ่มทีหลัง
ตัวอย่างเช่น
- Sequelize ใช้
include - TypeORM ใช้
relationsหรือquery builder - Prisma ใช้
includeหรือselectและพิจารณา relation load strategy ที่เหมาะสม
แนวคิดสำคัญคืออย่าปล่อยให้ ORM ตัดสินใจโหลด relation ทีละตัวโดยอัตโนมัติ หากเรารู้อยู่แล้วว่าหน้านั้นต้องใช้ข้อมูลอะไร ก็ควรระบุไปตรง ๆ
กรณี GraphQL ควรใช้ DataLoader
ถ้าใช้ GraphQL ต้องระวัง N+1 เป็นพิเศษ เพราะโครงสร้างแบบ resolver ทำให้ปัญหานี้เกิดง่ายมาก
ตัวอย่างเช่น resolver ของ User.orders อาจถูกเรียกหนึ่งครั้งต่อ user แต่ละคน เมื่อมี users จำนวนมาก ก็เท่ากับมี query เพิ่มขึ้นตามจำนวน users ไปด้วย
แนวทางมาตรฐานคือใช้ DataLoader เพื่อรวมหลาย key ที่เกิดขึ้นใน tick เดียวกัน แล้ว batch เป็น query เดียว
สิ่งนี้ช่วยลดจำนวน query ได้มาก และทำให้ resolver scale ได้ดีขึ้นอย่างชัดเจน
Workflow แนะนำในการจัดการปัญหา
ถ้าต้องตรวจและแก้ N+1 อย่างเป็นระบบ สามารถใช้ขั้นตอนนี้ได้
- เปิด query log
- นับจำนวน query ต่อ endpoint
- หา query ซ้ำที่ต่างกันแค่ id
- เลือกใช้ join หรือ batch ให้เหมาะกับรูปข้อมูล
- วัดผลก่อนและหลังแก้
การวัดผลเป็นเรื่องสำคัญ เพราะบางกรณีเป้าหมายไม่ใช่การลดให้เหลือ query เดียวเสมอไป
อย่าหวังพึ่ง cache อย่างเดียว
การใช้ cache อาจช่วยซ่อนอาการได้ชั่วคราว แต่ไม่ได้แก้ pattern การ query ที่ผิดอยู่ข้างใน
เมื่อ cache miss ปัญหาเดิมก็ยังกลับมาเหมือนเดิม และอาจพังหนักในช่วงที่โหลดสูง ดังนั้น cache ควรเป็นตัวเสริม ไม่ใช่ข้ออ้างในการปล่อยให้ N+1 อยู่ต่อไป
เป้าหมายไม่ใช่ query เดียวเสมอไป
หลายคนพอรู้จัก N+1 แล้วจะพยายามบีบทุกอย่างให้เหลือ query เดียว แต่ในโลกจริง นั่นไม่จำเป็นต้องเป็นคำตอบที่ดีที่สุดเสมอไป
บางครั้ง
- 3 queries แบบ batch
- ดีกว่า 1 query ที่ join มหาศาล
สิ่งที่ควรคิดคือ tradeoff ระหว่าง
- จำนวน query
- ขนาด payload
- จำนวน row ซ้ำจากการ join
- memory ที่ application ต้องใช้
- index ที่ database ใช้ได้จริง
การออกแบบที่ดีจึงไม่ใช่การลดตัวเลขอย่างเดียว แต่ต้องเลือกวิธีที่สมดุลกับรูปแบบข้อมูลและโหลดของระบบ
กฎง่าย ๆ สำหรับผู้เริ่มต้น
ถ้าเห็น loop แล้วข้างในมี await ไปที่ database ให้หยุดคิดก่อนทันที
มันอาจเป็น N+1
แน่นอนว่าบางกรณีอาจจำเป็นต้อง query ใน loop จริง ๆ แต่ควรมีเหตุผลชัดเจน ไม่ใช่เพียงเพราะ ORM ทำให้เขียนง่าย
สรุป
N+1 Query Problem เป็นบั๊ก performance ที่อันตรายเพราะโค้ดมักดูถูกต้อง อ่านง่าย และให้ผลลัพธ์ครบถ้วน แต่กลับซ่อนต้นทุนมหาศาลไว้ในจำนวน query ที่เพิ่มตามขนาดข้อมูล
วิธีรับมือที่ดีคือเปิด log ตรวจสอบจำนวน query ต่อ endpoint ทดสอบกับข้อมูลที่ใกล้ production และเลือกแก้ด้วย JOIN, batching, eager loading หรือ DataLoader ตามลักษณะงานจริง
backend ที่ดีไม่ได้แค่ตอบถูก แต่ต้องตอบด้วยจำนวน query ที่สมเหตุสมผลด้วย