N+1 and DataLoader
ตอนนี้คุณรู้แล้วว่าจะสร้างโมเดล relationship และเปิดดู list ทีละหน้าอย่างไร ชิ้นสุดท้ายคือการรักษาให้ query ที่มี relationship เยอะ ๆ ยังคง เร็ว อยู่ กับดักที่ดักเกือบทุกคนคือ ปัญหา N+1 และยารักษามาตรฐานคือ library เล็ก ๆ ชื่อ DataLoader เข้าใจทั้งสองอย่างแล้วกราฟของคุณจะยังคงเร็วไม่ว่า client จะ traverse ลึกแค่ไหน
ปัญหา N+1
หัวข้อที่มีชื่อว่า “ปัญหา N+1”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 DataLoader: batch และ cache
หัวข้อที่มีชื่อว่า “DataLoader: batch และ cache”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 แล้วเทียบจำนวนการเรียกของทั้งสองแบบในผลลัพธ์
ผลลัพธ์ทำให้ความต่างชัดเป็นรูปธรรม ด้วย 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 ไม่ต้องรู้ว่ามี batching | async nature ต้องระวัง race condition |
| ลด database connection pressure ใน concurrent request | DataLoader หลายตัวใน schema ใหญ่ต้องจัดการอย่างดี |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Resolver ที่ Query Database ทุก field อาการ:
Post.authorresolver ทำ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