ถ้าเราทำงานกับฐานข้อมูลและแอปพลิเคชัน เราอาจเคยได้ยินคำว่า ปัญหา 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 หรือเปล่า

💡
ส่วนตัวผมแนะนำให้เขียน query เองดีกว่า

ตัวอย่าง: 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 มาให้ช่วยจัดการปัญหานี้ได้เลย เพียงแต่เราต้องตรวจสอบให้ดีว่ามันแก้ปัญหานี้ได้จริงๆ หรือเปล่า