N+1 ที่ซ่อนใน round-trip: ทุก loop ที่ยิง network ต่อ item คือ N+1 — รวมเป็น batch/pipeline ก่อนมันฆ่า latency
N+1 ไม่ได้มีแค่ใน ORM — มันเกิดกับทุก external call ต่อ item ทั้ง Redis REST, HTTP verify, cache lookup. บ่อยครั้ง helper ที่รวมเป็น batch มีอยู่แล้วในโค้ด แค่ไม่มีใครเรียก
อ่าน ~8 นาที
ปัญหา: N+1 ไม่ใช่แค่เรื่องของ ORM — มันคือทุก per-item network call
เวลาพูดคำว่า "N+1 query" คนส่วนใหญ่นึกถึง ORM ที่ดึง parent 1 ครั้งแล้ววน loop ดึง child ทีละตัว 40 ครั้ง นั่นถูก แต่มันแคบเกินไป ผมเจอ N+1 ตัวจริงที่ทำให้ API ช้าเป็นวินาที ในระบบ SaaS ไทยแห่งหนึ่งที่ผมดูแล โดยที่ไม่มี ORM เข้ามาเกี่ยวเลย — มันเป็นการยิง Redis ผ่าน REST API ทีละ slot
นี่คือ thesis ของบทความนี้: ทุก loop ที่ยิง network call ต่อ item หนึ่งตัว คือ N+1 ไม่ว่าปลายทางจะเป็น SQL, Redis, HTTP verify endpoint, หรือ cache lookup ต้นทุนที่แท้จริงไม่ใช่การประมวลผล แต่คือ round-trip — เวลาที่ packet เดินทางไป-กลับข้ามเครือข่าย และข่าวดีคือ แทบทุกครั้งวิธีแก้คือรวมให้เหลือ call เดียวด้วย pipeline หรือ batch API และบ่อยกว่าที่คิด helper ที่ทำ batch อยู่แล้วมีในโค้ด base ของคุณเรียบร้อย — แค่ code path เก่าไม่ได้เรียกมัน
เคสจริง: ~40 slots × 1 Redis REST GET = 40 HTTPS round-trip
ระบบจองคิวตัวหนึ่งมี endpoint ดึงตารางเวลาว่าง หน้าจอแสดง slot ทุก 30 นาที เปิดบริการ 10 ชั่วโมง มีพนักงาน 2 คน = ประมาณ 40 slot ต่อการเปิดหน้าหนึ่งครั้ง แต่ละ slot ต้องเช็คว่า "มีคนอื่นกำลังกดจองค้างไว้อยู่ไหม" ซึ่งเก็บ lock ไว้ใน Redis
โค้ดเดิมเขียนตรงไปตรงมามาก:
// วน slot ทีละตัว ยิง Redis REST GET หนึ่งครั้งต่อ slot
const results = await Promise.all(
slots.map((slot) => isSlotLockedByOther(slot.key, currentUserId))
);
// isSlotLockedByOther ข้างในคือ single GET ผ่าน Redis REST API:
// await fetch(`${REDIS_REST_URL}/get/${key}`, { headers: authHeader })
// = 1 HTTPS request ต่อ slot
อ่านผ่านๆ ดูดี มี Promise.all ด้วยซ้ำ เหมือนขนานแล้ว แต่ปัญหาคือ Redis ตัวนี้เข้าถึงผ่าน REST API over HTTPS ไม่ใช่ TCP socket ที่คา connection ไว้ ฉะนั้นทุก slot = 1 request HTTPS เต็มรูปแบบ ไป POP ที่อยู่คนละ region RTT ประมาณ 80–200ms ต่อ call 40 call = ระหว่าง 0.5 ถึง 2 วินาที เฉพาะการเช็ค lock อย่างเดียว ทั้งที่ข้อมูลจริงมีแค่ 40 key ที่รวมดึงทีเดียวได้สบายๆ
Pitfall: Promise.all ไม่ได้ลบ network cost — มันแค่ทับซ้อนเวลารอ
จุดที่คนพลาดกันมากที่สุดคือคิดว่า Promise.all แก้ N+1 ได้ มันไม่ได้แก้ มันแค่ทำให้ call ทั้ง 40 ตัวรอพร้อมกันแทนที่จะรอทีละตัว ซึ่งช่วยเรื่อง wall-clock ก็จริง แต่คุณยังจ่ายต้นทุนจริงทุกอย่างอยู่:
- Connection overhead จริง 40 ชุด — TLS handshake, DNS, TCP setup (ถ้าไม่มี keep-alive) คูณ 40
- Rate limit และ concurrency cap — REST endpoint และ HTTP client ส่วนใหญ่จำกัด concurrent connection ทำให้ 40 call ไม่ได้ขนานจริงทั้งหมด มันทยอยเป็น batch ย่อยๆ
- Tail latency ทวีคูณ —
Promise.allช้าเท่า call ที่ช้าที่สุด ยิ่งยิงเยอะ โอกาสเจอ call ที่หางยาว (p99) ยิ่งสูง 1 ใน 40 ที่ช้า 800ms ลากทั้ง batch ช้าตาม
หลักการที่ผมยึด: parallelism ซ่อนเวลารอ แต่ไม่ลบจำนวน round-trip ถ้าคุณอยากได้ latency ต่ำจริง คุณต้องลด จำนวน network call ไม่ใช่แค่จัดคิวมันใหม่ และนั่นคือสิ่งที่ pipeline/batch ทำได้แต่ Promise.all ทำไม่ได้
Pattern หลัก: 40 calls → 1 call ด้วย MGET / pipeline
Redis มี command ที่ดึงหลาย key พร้อมกันในคำสั่งเดียวอยู่แล้ว — MGET และ client ส่วนใหญ่มี pipeline() ที่รวมหลายคำสั่งไปในหนึ่ง round-trip โค้ดที่แก้แล้วหน้าตาแบบนี้:
// รวมทุก key ไปในคำสั่งเดียว = 1 HTTPS round-trip เดียวจบ
const keys = slots.map((slot) => slot.key);
// แบบ MGET: ดึงหลาย key พร้อมกัน
const values = await redis.mget(...keys);
// หรือแบบ pipeline: หลายคำสั่งต่างชนิดใน round-trip เดียว
const pipe = redis.pipeline();
for (const key of keys) pipe.get(key);
const values = await pipe.exec(); // ← ยิง network ครั้งเดียว
// จากนั้นค่อยประมวลผลใน memory (ฟรี ไม่ใช่ network)
const lockedByOther = slots.filter((slot, i) =>
values[i] && values[i] !== currentUserId
);
ผลลัพธ์: 40 round-trip เหลือ 1 latency ตกจาก 0.5–2s เหลือหลัก 80–200ms (RTT เดียว) การประมวลผลเทียบ value ย้ายไปทำใน memory ซึ่งเร็วกว่า network เป็นพันเท่า หลักคิด transferable: แยกให้ออกว่าอะไรคือ network cost (แพง นับต่อ round-trip) กับอะไรคือ CPU cost (ถูก ทำใน loop ได้) แล้วดัน network ให้เหลือ round-trip น้อยที่สุด ส่วน CPU ปล่อยให้ loop ไปเลย
Pitfall ที่เจ็บที่สุด: helper batch มีอยู่แล้ว แต่ code path เก่าไม่เรียก
ตอนผมไล่แก้เคสนี้ ผมเจอสิ่งที่ทำให้ขำไม่ออก — ใน lib/redis มีฟังก์ชัน areSlotsLocked(keys, userId) ที่ใช้ redis.pipeline() รวมทุก key เป็น round-trip เดียวอยู่แล้ว มันถูกเขียนไว้ ถูก test ไว้ พร้อมใช้ แต่ route ที่ดึงตารางเวลากลับไปเรียก isSlotLockedByOther ทีละตัวใน loop แทน การแก้จริงคือลบ loop ทิ้งแล้วเรียก helper ที่มีอยู่ — ประมาณ 5–10 บรรทัด
ทำไมมันเกิดแบบนี้บ่อย? เพราะ code path เขียนกันคนละเวลา คนเขียน route แรกเขียน isSlotLockedByOther ไว้สำหรับเช็ค slot เดียวตอน lock ต่อมามีคนเขียน batch helper สำหรับงานอื่น แล้ว route ตารางเวลาที่ต้องการ batch จริงๆ ก็ก็อป pattern เดิม (single call) มาวางใน loop เพราะมันคือสิ่งแรกที่เห็น ไม่มีใครรู้ว่ามี batch version อยู่แล้ว
บทเรียนที่ผมเอาไปใช้ทุกโปรเจกต์: ก่อนจะ optimize N+1 ให้ grep หา batch/pipeline/mget/bulk ใน codebase ก่อน โอกาสสูงมากที่ของที่คุณกำลังจะเขียนมีคนเขียนไว้แล้ว และถ้าคุณเป็นคนเขียน batch helper เอง ให้ตั้งชื่อมันให้เจอง่าย และถ้าเป็นไปได้ ทำ single-item version ให้เป็นแค่ wrapper บาง ๆ ของ batch version (เรียก batch ด้วย array ขนาด 1) เพื่อไม่ให้มีสอง implementation ที่หลุดจากกัน
Pattern เดียวกันกับ external HTTP: verify 2 ครั้ง serial ก็คือ N+1 ย่อส่วน
N+1 ไม่ได้จำกัดแค่ data store ในเคสเดียวกัน ผมเจอฟังก์ชัน verify token ที่ยิง external auth provider 2 ครั้งเรียงกันแบบ serial — call แรก verify ว่า token ถูกต้อง call สองดึง profile และมันรอ call แรกจบก่อนถึงเริ่ม call สอง:
// serial: call 2 รอ call 1 จบก่อน = latency บวกกัน 400ms–1s
const verify = await fetch(VERIFY_URL, { ... }); // 200–500ms
const profile = await fetch(PROFILE_URL, { ... }); // อีก 200–500ms
วิธีคิดเดียวกับ pipeline เป๊ะ ลด/รวม round-trip ก่อน มีสามทางเลือกเรียงตามความคุ้ม:
- ตัด call ที่ไม่จำเป็นออก — ถ้าขั้นตอนนี้ต้องการแค่ระบุตัวผู้ใช้ ไม่ต้องใช้ข้อมูล profile เต็ม ก็ตัด call ที่สองทิ้ง ลด round-trip ได้ทั้งก้อนโดยไม่ต้องเขียนอะไรเพิ่ม call ที่เร็วที่สุดคือ call ที่ไม่ได้ยิง
- ถ้าไม่ dependent กัน ให้ parallelize —
Promise.all([verify, profile])ถ้าสอง call ไม่ต้องรอผลของกันและกัน - Cache ผลที่แพงและ TTL ยาว — token ที่ verify แล้วมักมีอายุเป็นชั่วโมง cache ผล verify ไว้ก็ตัด round-trip ของ request ถัดไปทั้งหมด
สังเกตว่าลำดับความคิดเหมือนกับฝั่ง Redis ไม่มีผิด: ถามก่อนว่า call นี้จำเป็นไหม → ถ้าจำเป็นและ independent ก็รวม/ขนาน → ถ้าซ้ำบ่อยก็ cache มันคือ toolkit เดียวกันที่ใช้ได้กับทุก external dependency
ก่อนทั้งหมดนี้: profile ของจริงก่อน อย่าเดา hotspot
ผมต้องเน้นข้อนี้ให้หนัก เพราะมันคือกับดักของคนที่เพิ่งรู้จัก N+1 แล้วเห็นมันไปหมดทุกที่ — ทุกอย่างข้างบนเริ่มจากการวัดจริง ไม่ใช่การเดา ผมเปิด Network tab ของ browser ระหว่างกดใช้งานจริง แล้วเห็นชัดว่า endpoint ไหนใช้เวลาเท่าไหร่ call ไหนซ้ำกี่ครั้ง ก่อนจะลงมือแตะโค้ดสักบรรทัด
วิธี profile ที่ผมใช้เรียงตามความง่าย:
- Network tab / DevTools — เห็น waterfall ของทุก request เห็นทันทีว่า call ไหนซ้ำ 40 รอบ อันนี้ฟรีและเห็นภาพที่สุด
console.timeคร่อมแต่ละ step ใน handler — วัดว่าเวลาไปตกที่ block ไหน DB, verify, หรือ push notification- APM / logs analytics ของ platform — ดู p50/p95/p99 ของ endpoint จาก traffic จริง เพื่อยืนยันว่าที่แก้ไปได้ผลจริง before/after
เหตุผลที่ต้องวัดก่อน: บ่อยครั้ง hotspot ที่แท้จริงไม่ใช่จุดที่โค้ดดู "น่าเกลียด" ที่สุด ในเคส booking ของผม ตัวที่ลาก latency worst-case ไป 15–30 วินาทีจริงๆ กลับเป็น external call ที่ไม่มี timeout ค้างรอ ไม่ใช่ N+1 Redis ที่เห็นชัด — ทั้งสองต้องแก้ แต่ถ้าผมทุ่มเวลาแก้ N+1 ก่อนโดยไม่วัด ผมจะพลาดตัวที่เจ็บกว่าไปเลย optimize ตามตัวเลข ไม่ใช่ตามความรู้สึก
สรุป
- N+1 คือทุก loop ที่ยิง network ต่อ item ไม่ใช่แค่ ORM — Redis REST, HTTP verify, cache lookup นับหมด ต้นทุนคือ round-trip ไม่ใช่การคำนวณ
Promise.allไม่ได้แก้ N+1 มันซ่อนเวลารอด้วยการขนาน แต่ไม่ลดจำนวน round-trip ยังจ่าย connection overhead, rate limit และ tail latency เต็มๆ- รวมให้เหลือ call เดียว ด้วย
MGET/pipelineฝั่ง data store แล้วประมวลผลใน memory ซึ่งเร็วกว่า network เป็นพันเท่า - grep หา batch helper ก่อนเขียนใหม่ — บ่อยครั้งของที่คุณกำลังจะเขียนมีอยู่แล้ว แค่ code path เก่าไม่ได้เรียก ให้ single-item เป็น wrapper ของ batch เพื่อไม่ให้หลุดจากกัน
- External call ก็ใช้ toolkit เดียวกัน — ถามว่า call จำเป็นไหม → ตัดถ้าไม่ → parallelize ถ้า independent → cache ถ้าซ้ำบ่อยและ TTL ยาว
- profile ก่อน optimize เสมอ — วัดด้วย Network tab /
console.time/ APM จริง แก้ตามตัวเลข ไม่ใช่ตามจุดที่โค้ดดูน่าเกลียด




