ข้ามไปยังเนื้อหา

N+1 and DataLoader

ตอนนี้คุณรู้แล้วว่าจะสร้างโมเดล relationship และเปิดดู list ทีละหน้าอย่างไร ชิ้นสุดท้ายคือการรักษาให้ query ที่มี relationship เยอะ ๆ ยังคง เร็ว อยู่ กับดักที่ดักเกือบทุกคนคือ ปัญหา N+1 และยารักษามาตรฐานคือ library เล็ก ๆ ชื่อ DataLoader เข้าใจทั้งสองอย่างแล้วกราฟของคุณจะยังคงเร็วไม่ว่า client จะ traverse ลึกแค่ไหน

GraphQL resolve field แยกอิสระจากกัน เมื่อ query ขอ list ของ post แล้วขอ author ของแต่ละ post ด้วย engine จะรัน resolver author หนึ่งครั้งต่อหนึ่ง post ดึง post มาหนึ่งหน้า 50 อัน server จะทำ:

  • 1 query เพื่อโหลด 50 post จากนั้น
  • N = 50 query แยกต่างหากเพื่อโหลด author ของแต่ละ post

นั่นคือ 51 round trip — N + 1 — ทั้งที่ query ที่ออกแบบดี ๆ ทำได้ใน 2 รอบ ต้นทุนนี้มองไม่เห็นตอน development ที่มีข้อมูล seed สามแถว แต่กลายเป็นหายนะตอน production ที่มี traffic จริง นี่ไม่ใช่บั๊กใน resolver ของคุณ แต่เป็นผลตามธรรมชาติของการ resolve แต่ละ field แยกกัน

flowchart TD
  Q["Query: posts { author }"]
  Q -->|"1 query"| Posts["Load N posts"]
  Posts --> A1["author resolver: post 1"]
  Posts --> A2["author resolver: post 2"]
  Posts --> A3["author resolver: post 3"]
  Posts --> AN["author resolver: post N"]
  A1 -->|"query"| DB["Data source"]
  A2 -->|"query"| DB
  A3 -->|"query"| DB
  AN -->|"query"| DB
การ resolve author ทีละ post ทีละอันทำให้เกิดหนึ่ง query ต่อหนึ่ง post — การระเบิดแบบ N+1

DataLoader แก้ปัญหานี้ด้วยสองแนวคิด:

  • Batching แทนที่จะยิงการโหลดทันที DataLoader จะรวบรวมทุก key ที่ถูกร้องขอในหนึ่ง tick ของ event loop จากนั้นเรียก batch function ของคุณ หนึ่งครั้ง ด้วย array ของ key ทั้งหมด batch function ของคุณเปลี่ยนการเรียก “โหลด author aX” แยกกัน 50 ครั้งให้เป็นการเรียก “โหลด author [a1, a2, …]” ครั้งเดียว
  • Caching ภายในหนึ่ง request การขอ key เดิมสองครั้งจะคืนผลลัพธ์ที่ cache ไว้ — batch function จะไม่เห็นรายการซ้ำเลย

คุณให้ batchFn(keys) แก่ DataLoader ซึ่งรับ array ของ key และคืน array ของผลลัพธ์ ในลำดับเดียวกัน แต่ละ resolver เพียงเรียก loader.load(key) และ await promise ส่วน DataLoader จัดการการรวม (coalescing) ให้เอง

import DataLoader from 'dataloader';
// Called ONCE per tick with all collected keys.
const authorLoader = new DataLoader(async (ids: readonly string[]) => {
const rows = await db.authorsByIds(ids); // one batched query
return ids.map((id) => rows.find((r) => r.id === id)!);
});
// In a resolver: just load by key — batching is automatic.
const author = (post) => authorLoader.load(post.authorId);

runner ด้านล่าง resolve query เดียวกันสองวิธีและ นับว่า loader function ยิงไปกี่ครั้ง เวอร์ชันที่ไม่ batch เรียก data source หนึ่งครั้งต่อหนึ่ง post ส่วนเวอร์ชันที่ batch รวบรวม key ทั้งหมดแล้วเรียกทีเดียว กด Run แล้วเทียบจำนวนการเรียกของทั้งสองแบบในผลลัพธ์

JavaScript

ผลลัพธ์ทำให้ความต่างชัดเป็นรูปธรรม ด้วย post สี่อัน เส้นทางที่ไม่ batch เรียกค้นหา author 4 ครั้ง ส่วน batchFn ของ loader ที่ batch ยิงแค่ ครั้งเดียว เพราะ key ถูกรวบรวมภายใน tick เดียวแล้ว resolve ไปพร้อมกัน ใน production ตัว batch function นั้นก็คือ query WHERE id IN (…) เดียว แทนที่จะเป็นหลายสิบ query (package dataloader ตัวจริงที่ import ได้ด้วยวิธีเดียวกันผ่าน await import('https://esm.sh/dataloader@2') ทำแบบนี้เป๊ะ ๆ พร้อม caching และการจัดการ edge case เพิ่มให้ ส่วน loader ด้านบนเป็นเวอร์ชันขั้นต่ำไว้แสดงกลไกเฉย ๆ)

ข้อดี (DataLoader)ข้อแลกเปลี่ยน
batch N database query เป็น 1 query — ลด round tripต้องเพิ่ม DataLoader ทุก relationship — setup เพิ่ม
per-request cache — ไม่ query ซ้ำสำหรับ key เดียวในครั้งเดียวกันDataLoader เป็น per-request — ไม่ cache ข้าม request
transparent กับ resolver — resolver ไม่ต้องรู้ว่ามี batchingasync nature ต้องระวัง race condition
ลด database connection pressure ใน concurrent requestDataLoader หลายตัวใน schema ใหญ่ต้องจัดการอย่างดี

Resolver ที่ Query Database ทุก field อาการ:

  • Post.author resolver ทำ db.findUser(post.authorId) ทุกครั้ง
  • 100 post = 100 database query แยก
  • solution: DataLoader.load(post.authorId) — batch เป็น query เดียว

DataLoader ที่ Shared ข้าม Request อาการ:

  • สร้าง DataLoader ครั้งเดียวตอน server start แล้วใช้ร่วมกัน
  • per-request cache ทำงานผิด — user A เห็นข้อมูลของ user B
  • สร้าง DataLoader ใหม่ทุก request ใน context factory

💡 ตัวอย่างจากของจริง

Facebook (ผู้สร้าง DataLoader):

  • DataLoader เกิดจาก internal need ที่ Facebook ก่อนจะ open source
  • batch loading ช่วยลด database round trip จากหลักร้อยเหลือหลักสิบต่อ request

Shopify:

  • ใช้ DataLoader สำหรับทุก relationship ใน GraphQL schema
  • ประมวลผล request ที่มี product + variant + inventory ด้วย batch query
อะไรเป็นสาเหตุของปัญหา N+1 ใน GraphQL?
DataLoader ทำสองสิ่งใดเพื่อแก้ปัญหา N+1?
batch function ของ DataLoader ต้องคืนค่าอะไร?