ถ้าเราทำงานกับฐานข้อมูลและแอปพลิเคชัน เราอาจเคยได้ยินคำว่า ปัญหา N+1 (N+1 problem) มาบ้าง
มันเป็นปัญหาด้านประสิทธิภาพที่พบได้บ่อยมาก เกิดขึ้นเมื่อแอปยิงคำสั่ง query ไปที่ฐานข้อมูลมากเกินไป แทนที่จะดึงข้อมูลอย่างมีประสิทธิภาพในครั้งเดียว
ผลก็ คือ แอปทำงานช้าลงและขยายระบบ (scale) ได้ยากขึ้น
ปัญหา N+1 คืออะไร?
ลองนึกภาพว่าเรามีรายชื่อนักเรียนกลุ่มหนึ่ง และนักเรียนแต่ละคนลงเรียนได้หลายคอร์ส นี่ คือความสัมพันธ์แบบ one-to-many เพราะนักเรียนหนึ่งคนมีได้หลายคอร์ส
ทีนี้สมมติว่าเราต้องการดึงข้อมูลนักเรียนทุกคนพร้อมกับคอร์สที่แต่ละคนเรียนอยู่ ถ้าแอปของเรายังไม่ได้ปรับให้เหมาะสม มันอาจทำงานแบบนี้
ขั้นแรก ดึงรายชื่อนักเรียนทั้งหมดก่อน:
SELECT * FROM students;
จากนั้นวนลูปดึงคอร์สของนักเรียนทีละคน:
SELECT * FROM courses WHERE student_id = ?;
ถ้าเรามีนักเรียน 100 คน วิธีนี้จะกลายเป็น 101 queries (1 query สำหรับดึงนักเรียน + อีก 100 queries สำหรับดึงคอร์สของแต่ละคน)
นี่แหละ คือ ที่มาของชื่อ ปัญหา N+1 — เรายิง query ทั้งหมด N (จำนวนนักเรียน) + 1 ครั้งนั่นเอง
ทำไมมันถึงเป็นปัญหา?
ปัญหา N+1 ทำให้แอปช้าลงได้อย่างมาก ด้วยหลายเหตุผล เช่น
- มี query มากเกินไป ทำให้ฐานข้อมูลรับภาระหนัก
- ปริมาณ network traffic ระหว่างแอปกับฐานข้อมูลเพิ่มขึ้น
- เวลาตอบสนองช้าลง เพราะแอปต้องรอ query หลาย ๆ ตัวทำงานให้เสร็จ
- ยิ่งข้อมูลเยอะ ยิ่งแย่ลง เช่น ถ้ามีนักเรียน 1,000 คน ก็จะกลายเป็น 1,001 queries ทันที
วิธีแก้ปัญหา N+1
1. ใช้ JOIN เพื่อดึงข้อมูลในครั้งเดียว
แทนที่จะแยก query ดึงคอร์สของนักเรียนทีละคน ให้ใช้ JOIN ดึงข้อมูลทั้งหมดในคำสั่งเดียว:
SELECT students.*, courses.*
FROM students
LEFT JOIN courses ON students.id = courses.student_id;
ด้วยวิธีนี้ เรายิงแค่ query เดียวแทนที่จะเป็น 101 queries ทำให้แอปเร็วขึ้นมาก
2. ใช้ Batch Query แทนการดึงทีละรายการ
ถ้าเราไม่อยากใช้ JOIN อีกทางเลือกหนึ่ง คือ การดึงข้อมูลแบบ batch:
SELECT * FROM courses WHERE student_id IN (1, 2, 3, 4, 5);
แทนที่จะดึงคอร์สทีละครั้ง คำสั่งนี้ดึงทั้งหมดมาในคำขอเดียว กรณีนี้จึงเหลือแค่ 2 คำขอ คือ 1 สำหรับนักเรียน และอีก 1 สำหรับคอร์ส
ฟีเจอร์ของ ORM ที่ช่วยเลี่ยงปัญหา N+1
Object-Relational Mapper (ORM) หลายตัวมีวิธีรับมือกับปัญหา N+1 มาให้ในตัวอยู่แล้ว เพียงแต่เราต้องดูให้ดีๆ ว่าวิธีที่เราเขียนนั้นมันเป็น N+1 หรือเปล่า
ตัวอย่าง: TypeORM บน Node.js
แบบ eager loading:
const students = await getRepository(Student)
.createQueryBuilder("student")
.leftJoinAndSelect("student.courses", "course")
.getMany();
วิธีนี้ทำให้ดึงนักเรียนทุกคนพร้อมคอร์สของแต่ละคนมาได้ใน query เดียว
แบบ batch loading:
const students = await studentRepository.find({ relations: ["courses"] });
วิธีนี้ดึงนักเรียนพร้อมคอร์สมาในคำขอเดียวเช่นกัน
สรุป
ปัญหา N+1 เกิดขึ้นเมื่อแอปยิง query เล็กๆ จำนวนมาก แทนที่จะดึงข้อมูลอย่างมีประสิทธิภาพในครั้งเดียว
มันทำให้แอปทำงานช้าและขยายระบบได้ยาก แต่เราสามารถแก้ได้ด้วยการใช้ JOIN หรือ batch query และ ถ้าใช้ ORM อยู่แล้ว ก็มักจะมีฟีเจอร์อย่าง eager loading หรือ batch loading มาให้ช่วยจัดการปัญหานี้ได้เลย เพียงแต่เราต้องตรวจสอบให้ดีว่ามันแก้ปัญหานี้ได้จริงๆ หรือเปล่า