N+1 query บั๊กที่ไม่พังตอนเดฟ แต่พังตอนมีผู้ใช้จริง
นี่เป็นบั๊กที่เจ็บปวดที่สุด เพราะเวลาเดฟอยู่กับข้อมูล 20 แถว ทุกอย่างเร็วปรี้ด พอขึ้น production ที่มีห้าหมื่นแถว หน้าที่เคยโหลดใน 200 มิลลิวินาทีกลายเป็น 12 วินาที
มันเกิดขึ้นได้อย่างไร
ชื่อมันอธิบายตัวเองอยู่แล้ว เรายิง query หนึ่งครั้งเพื่อดึงรายการหลัก N รายการ แล้วยิงอีก N ครั้งเพื่อดึงข้อมูลประกอบของแต่ละราย รวมเป็น N+1
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 ครั้งทำไมถึงหายาก
เพราะ ORM สมัยใหม่ซ่อนการเรียกฐานข้อมูลไว้เบื้องหลังการอ่าน property ธรรมดา ๆ เวลาอ่านโค้ด เราเห็นแค่ order.customer.name ซึ่งดูเหมือนอ่านค่าเฉย ๆ ไม่มีตรงไหนเขียนว่า SELECT
วิธีแก้ที่ใช้ได้จริง
1. ดึงข้อมูลสัมพันธ์มาพร้อมกัน
ORM ส่วนใหญ่มีโหมด eager loading อยู่แล้ว เช่น include, with หรือ Include() มันจะรวมเป็น JOIN หรือ query ชุดเดียวให้เอง
2. ดึงทีเดียวแล้ว map ในหน่วยความจำ
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);
}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 เลย
อยู่ในซีรีส์: ช่างฝีมือโค้ด
อ่านตอนต่อไป
ความคิดเห็น
กำลังโหลดความคิดเห็น