N+1 query บั๊กที่ไม่พังตอนเดฟ แต่พังตอนมีผู้ใช้จริง

นี่เป็นบั๊กที่เจ็บปวดที่สุด เพราะเวลาเดฟอยู่กับข้อมูล 20 แถว ทุกอย่างเร็วปรี้ด พอขึ้น production ที่มีห้าหมื่นแถว หน้าที่เคยโหลดใน 200 มิลลิวินาทีกลายเป็น 12 วินาที

มันเกิดขึ้นได้อย่างไร

ชื่อมันอธิบายตัวเองอยู่แล้ว เรายิง query หนึ่งครั้งเพื่อดึงรายการหลัก N รายการ แล้วยิงอีก N ครั้งเพื่อดึงข้อมูลประกอบของแต่ละราย รวมเป็น N+1

typescript
const orders = await db.order.findMany({ take: 100 });

for (const order of orders) {
  // ตรงนี้ยิง query ใหม่ทุกรอบ = 100 ครั้ง
  order.customer = await db.customer.findUnique({
    where: { id: order.customerId },
  });
}
// รวม 101 ครั้ง
โค้ดหน้าตาดูปกติมาก แต่ซ่อน query ไว้ข้างใน

ทำไมถึงหายาก

เพราะ ORM สมัยใหม่ซ่อนการเรียกฐานข้อมูลไว้เบื้องหลังการอ่าน property ธรรมดา ๆ เวลาอ่านโค้ด เราเห็นแค่ order.customer.name ซึ่งดูเหมือนอ่านค่าเฉย ๆ ไม่มีตรงไหนเขียนว่า SELECT

วิธีแก้ที่ใช้ได้จริง

1. ดึงข้อมูลสัมพันธ์มาพร้อมกัน

ORM ส่วนใหญ่มีโหมด eager loading อยู่แล้ว เช่น include, with หรือ Include() มันจะรวมเป็น JOIN หรือ query ชุดเดียวให้เอง

2. ดึงทีเดียวแล้ว map ในหน่วยความจำ

typescript
const orders = await db.order.findMany({ take: 100 });
const customerIds = [...new Set(orders.map((o) => o.customerId))];

const customers = await db.customer.findMany({
  where: { id: { in: customerIds } },
});

const customerById = new Map(customers.map((c) => [c.id, c]));
for (const order of orders) {
  order.customer = customerById.get(order.customerId);
}
จาก 101 ครั้ง เหลือ 2 ครั้ง

3. ใช้ DataLoader เมื่อทำ GraphQL

GraphQL เป็นแหล่งกำเนิด N+1 ชั้นดี เพราะ resolver แต่ละตัวทำงานแยกกัน แพทเทิร์น DataLoader จะรวบคำขอที่เกิดใน tick เดียวกันแล้วยิงทีเดียว

วิธีจับมันก่อนขึ้นจริง

  • เปิด query logging ใน dev แล้วนับจำนวนครั้งต่อ request
  • ใส่ assertion ในเทสต์ว่า endpoint นี้ต้องใช้ query ไม่เกินกี่ครั้ง
  • seed ข้อมูลให้ใกล้เคียงของจริง อย่าเทสกับ 10 แถว
  • ใช้ APM หรือ tracing ดู span ของฐานข้อมูลต่อหนึ่ง request

N+1 ไม่ได้แก้ยาก แต่มันหายาก ถ้าทีมมีนิสัยดูจำนวน query ตั้งแต่ตอนเดฟ มันจะไม่มีโอกาสโผล่สู่ production เลย

ความคิดเห็น

กำลังโหลดความคิดเห็น