Background Jobs ด้วย Node.js และ Redis ควรออกแบบยังไงให้ retry ได้จริง
หลายระบบเริ่มจาก request-response ตรงไปตรงมาก่อน
- ผู้ใช้กดปุ่ม
- backend รับ request
- ทำทุกอย่างให้เสร็จใน route
- ตอบกลับเมื่อทุกอย่างจบ
ช่วงแรกวิธีนี้ดูง่ายและเร็ว แต่พอระบบเริ่มมีงานที่หนักขึ้นหรือมี dependency ภายนอกมากขึ้น ปัญหาจะเริ่มชัดทันที เช่น
- ส่งอีเมลช้า
- เรียก third-party แล้ว timeout
- สร้าง report ใช้เวลานาน
- resize รูปและ scan ไฟล์กินเวลา
- webhook เข้าเป็น burst
- job บางตัว fail ชั่วคราวแล้วควรลองใหม่
- request หลักไม่ควรรอจนงานเบื้องหลังเสร็จทั้งหมด
ตรงนี้เองที่ background jobs เริ่มเข้ามามีบทบาท
แต่ทันทีที่คุณแยกงานออกไปเป็น async job อีกคำถามที่ตามมาจะสำคัญมากกว่าเดิม
ถ้า job fail จะ retry ยังไง
ถ้า retry แล้วทำ side effect ซ้ำจะเกิดอะไรขึ้น
ถ้า worker ตายกลางคันงานจะหายไหม
ถ้า job เดิมถูก enqueue ซ้ำโดยไม่ตั้งใจจะกันยังไง
ถ้า queue เริ่มค้าง จะรู้ได้ยังไงก่อนผู้ใช้เริ่มได้รับผลกระทบ
บทความนี้อธิบายว่า background jobs ด้วย Node.js และ Redis ควรออกแบบอย่างไรให้ retry ได้จริงใน production ไม่ใช่แค่ “มี queue แล้วเดี๋ยวค่อยลองใหม่”
TL;DR
สรุปให้สั้นที่สุด
retry ที่ดีไม่ได้หมายถึงการรัน job เดิมซ้ำไปเรื่อย ๆ
แต่หมายถึงการออกแบบให้ job ถูกลองใหม่ได้โดยไม่ทำให้ state เพี้ยน ไม่ทำ side effect ซ้ำเกินควร และตามย้อนหลังได้ว่ารอบไหนเกิดอะไรขึ้น
ถ้าจะทำ background jobs ให้ใช้ได้จริง คุณควรมีอย่างน้อย
- queue semantics ที่ชัด
- job payload ที่เล็กและ stable
- idempotent processing
- retry policy ที่แยก transient กับ permanent failures
- backoff
- dead-letter handling
- observability
- request/correlation context
- audit trail สำหรับ action สำคัญ
Background Job คืออะไร
background job คือการย้ายงานบางอย่างออกจาก request หลัก ไปให้ worker ทำภายหลังหรือทำแยกอีกชั้นหนึ่ง
ตัวอย่างเช่น
- ส่งอีเมล
- สร้างไฟล์ export
- OCR เอกสาร
- scan ไฟล์
- sync ข้อมูลกับ provider
- process webhook event
- สร้าง invoice PDF
- refresh cache หนัก ๆ
- ทำ reconciliation
- push notification
แก่นของมันคือ “งานนี้ไม่ควรทำให้ request หลักต้องรอ” หรือ “งานนี้ควรอยู่ใน execution model ที่ควบคุม retry และ concurrency ได้ดีกว่า synchronous route”
ทำไมระบบที่มี Background Jobs ถึงยังพังได้ง่าย
หลายทีมพอแยกงานเข้า queue แล้วรู้สึกว่าปัญหาหายไปครึ่งหนึ่ง เพราะ request หลักเร็วขึ้นจริง แต่ความยากเพิ่งย้ายที่เท่านั้น
จากเดิมปัญหาอยู่ที่ route
ตอนนี้ปัญหาจะย้ายไปอยู่ที่ worker semantics เช่น
- job ทำซ้ำ
- job ตกหล่น
- job retry แล้ว side effect ซ้ำ
- job ค้าง
- queue backlog โต
- worker fail เงียบ ๆ
- event เข้าไม่ทัน
- downstream API โดนยิงซ้ำจาก retry
- state ระหว่าง request กับ worker ไม่ตรงกัน
ดังนั้นถ้าพูดให้ตรง background jobs ไม่ได้ทำให้ระบบง่ายขึ้น มันทำให้ระบบ “แยกชั้นขึ้น” และต้องการวินัยมากขึ้น
Redis เข้ามาเกี่ยวตรงไหน
Redis มักถูกใช้เป็น queue backend เพราะเร็ว ใช้ง่าย และ ecosystem ของ Node.js รองรับดี โดยเฉพาะเมื่อใช้ร่วมกับ library ที่ออกแบบเรื่อง jobs มาแล้ว เช่น BullMQ หรือแนวคิดที่คล้ายกัน
แต่การใช้ Redis เป็น queue ต่างจากใช้เป็น cache หรือ session ตรงที่สิ่งที่คุณกำลังเก็บไม่ใช่แค่ข้อมูลชั่วคราว แต่มันคือ “งานที่ต้องถูกประมวลผล” และนั่นทำให้เรื่องต่อไปนี้สำคัญทันที
- at-least-once delivery
- retries
- concurrency
- failed jobs
- delayed jobs
- job deduplication
- stuck job recovery
- observability ของ queue depth และ lag
เริ่มต้นจากการถามก่อนว่าอะไรควรเป็น background job
ไม่ใช่งานทุกอย่างควรถูกโยนเข้า queue
งานที่เหมาะมักมีลักษณะอย่างใดอย่างหนึ่ง
- ใช้เวลานานเกินกว่าจะรอใน request หลัก
- เกี่ยวกับ third-party ที่ไม่นิ่ง
- ต้อง retry ได้
- ไม่จำเป็นต้องให้ผู้ใช้รอผลทันที
- มี burst แล้วควรคุม concurrency
- มีต้นทุนสูงและควรถูกแยกออกไปบริหารทรัพยากร
ตัวอย่างงานที่เหมาะ เช่น
- email / SMS
- report export
- OCR / file processing
- image transformation
- webhook ingestion follow-up
- payment reconciliation
- nightly sync jobs
แต่งานที่ต้องตอบผู้ใช้ทันทีมาก ๆ หรือมี transaction boundary ที่ต้องจบใน request เดียว บางครั้งไม่ควรถูกย้ายออกไปแบบไม่คิดให้รอบ
คำว่า “retry ได้จริง” หมายถึงอะไร
นี่คือหัวใจของเรื่องทั้งหมด
retry ได้จริง ไม่ได้หมายถึงแค่มี attempts: 3 แล้วจบ
แต่หมายถึงคุณตอบคำถามพวกนี้ได้
- อะไรคือความล้มเหลวที่ควร retry
- อะไรคือความล้มเหลวที่ retry ไปก็ไม่มีทางสำเร็จ
- ถ้า job ทำไปแล้วบางส่วนแล้ว fail จะเกิดอะไรขึ้นเมื่อ retry
- ถ้า worker crash กลางทาง จะมี job ซ้ำไหม
- ถ้า third-party รับคำขอไปแล้วแต่ตอบไม่ทัน จะ retry ได้โดยไม่สร้าง side effect ซ้ำหรือไม่
- ถ้า retry ครบแล้วยัง fail จะส่งไปไหนต่อ
- ทีมจะเห็นไหมว่า queue กำลังสะสมงานล้มเหลว
ถ้าตอบคำถามพวกนี้ไม่ได้ ระบบไม่ได้ “รองรับ retry” จริง แค่ “ลองอีกรอบแบบสุ่ม ๆ”
สิ่งที่ต้องแยกให้ออกก่อน: transient vs permanent failure
ไม่ใช่ทุก error ควรถูก retry
Transient failure
คือความล้มเหลวที่อาจหายได้ถ้าลองใหม่ เช่น
- network timeout
- temporary 5xx จาก provider
- Redis หรือ DB หน่วงชั่วคราว
- rate limit ชั่วคราว
- dependency unavailable ช่วงสั้น ๆ
กรณีนี้ retry มักมีเหตุผล
Permanent failure
คือความล้มเหลวที่ retry เท่าไรก็ไม่ผ่าน เช่น
- payload ไม่ถูกต้อง
- resource ไม่มีอยู่จริง
- permission ไม่ถูก
- business state ไม่อนุญาต
- configuration ผิดแบบถาวร
- required field หาย
กรณีนี้ retry อย่างเดียวไม่ช่วย และมักทำให้ queue ยิ่งรก
นี่คือเหตุผลว่าทำไม worker ที่ดีต้องแยกประเภทความผิดพลาด ไม่ใช่ catch error แล้ว throw ต่อทุกอย่าง
Idempotency สำคัญกว่าจำนวน retries
ถ้าคุณจะจำแค่เรื่องเดียวจากบทความนี้ ให้จำเรื่องนี้
job ที่ retry ได้จริงควรใกล้เคียงกับ idempotent ให้มากที่สุด
เพราะในโลกของ background jobs คุณควร assume ไว้เลยว่า
- job อาจถูกลองใหม่
- job อาจถูกหยิบซ้ำ
- worker อาจตายกลางคัน
- third-party อาจตอบช้า
- queue อาจ redeliver
- deployment อาจเกิดระหว่างประมวลผล
ถ้า job ของคุณไม่ทนต่อการรันซ้ำ ผลลัพธ์จะเริ่มเพี้ยนเร็วมาก เช่น
- ส่ง email ซ้ำ
- refund ซ้ำ
- sync ข้อมูลซ้ำ
- เพิ่มเครดิตซ้ำ
- update state ข้ามขั้น
- สร้างไฟล์ซ้ำโดยไม่มีเหตุผล
ดังนั้น retry ที่ดีเริ่มจากการออกแบบ side effect ให้ปลอดภัยต่อการ retry ไม่ใช่เริ่มจากเลขจำนวนครั้ง
Job payload ควรมีลักษณะยังไง
job payload ที่ดีไม่ควรใหญ่เกินไปและไม่ควรแบก state ที่ stale ง่าย
แนวทางที่ปลอดภัยกว่ามักเป็นแบบนี้
- เก็บ
resource_id - เก็บ
job_type - เก็บ metadata ที่จำเป็นจริง
- เก็บ
request_id/correlation_id - เก็บ
attempt_contextเท่าที่จำเป็น
หลีกเลี่ยงการโยน object ทั้งก้อนจาก request ลง queue ถ้า object นั้นอาจ stale หรือเปลี่ยนก่อน worker จะหยิบไปทำ
ตัวอย่างที่ดีกว่า:
{
"jobType": "send_receipt_email",
"orderId": "ord_1024",
"requestId": "req_abc123",
"correlationId": "corr_payment_1024"
}
แทนที่จะโยน order ทั้ง object ไปทั้งก้อน
อย่าให้ queue เป็นที่ซ่อน logic ที่สำคัญเกินไป
หลายระบบมี anti-pattern แบบนี้
- request หลักตอบ
202 Accepted - ทุกอย่างจริง ๆ ไปอยู่ใน worker หมด
- ไม่มี state กลางใน database
- ไม่มีใครรู้ว่างานไปถึงไหน
- ถ้า job fail ผู้ใช้ก็ไม่รู้
- ถ้าจะ debug ต้องเปิด queue อย่างเดียว
วิธีที่ปลอดภัยกว่าคือ ให้ระบบมี state ทางธุรกิจที่ชัด เช่น
report.status = queuedreport.status = processingreport.status = completedreport.status = failed
แบบนี้ต่อให้ queue มีปัญหา ระบบยังมี source of truth ทางธุรกิจให้ตามได้
Retry ควรใช้ backoff แบบไหน
retry แบบยิงทันทีสามครั้งติดมักไม่ช่วยอะไรกับ transient failures ส่วนใหญ่
สิ่งที่เหมาะกว่าคือ backoff เช่น
- fixed delay
- exponential backoff
- exponential backoff with jitter
เหตุผลคือถ้าปัญหาเกิดจาก
- provider หน่วง
- dependency ล่มชั่วคราว
- rate limit
- burst traffic
การเว้นระยะก่อนลองใหม่มักดีกว่าการกระหน่ำซ้ำทันที
ในระบบจริง jitter ก็สำคัญ เพราะช่วยลดการที่ jobs จำนวนมากตื่นพร้อมกันแล้วชน dependency อีกครั้งพร้อม ๆ กัน
Dead-letter queue หรือ failed jobs สำคัญยังไง
ต่อให้ retry ดีแค่ไหน ก็ยังมีงานบางก้อนที่ไม่สำเร็จอยู่ดี
คำถามคือพอมัน fail ครบแล้วเกิดอะไรขึ้นต่อ
ถ้าคำตอบคือ “ก็หายไป” นั่นคือปัญหาใหญ่ทันที
อย่างน้อยควรมีอย่างใดอย่างหนึ่ง
- failed jobs queue
- dead-letter queue
- failed set ที่ค้นได้
- alert เมื่อ fail เกิน threshold
- dashboard ให้เห็น backlog และ failures
- runbook ว่าทีมควรทำอะไรกับงานพวกนี้
เพราะถ้างาน fail แล้วไม่มีที่ไป ปัญหาจะซ่อนอยู่จนผู้ใช้หรือธุรกิจเป็นคนมาแจ้งแทน
Concurrency ต้องคิดคู่กับ retry
job ที่ retry ได้จริงต้องไม่ดูแค่ retry count แต่ต้องดู concurrency ด้วย
ตัวอย่างเช่น
- ส่ง email อาจรัน parallel ได้เยอะ
- process invoice อาจต้อง limit concurrency ตาม provider
- settlement job บางชนิดอาจต้องครั้งละหนึ่ง resource
- OCR jobs อาจต้องจำกัดเพื่อไม่ให้กิน CPU เกิน
ถ้า concurrency ไม่ถูกคุม ต่อให้ retry ดี queue ก็อาจกลายเป็นเครื่องยิงซ้ำขนาดใหญ่ใส่ dependency ของคุณเอง
Queue กับ distributed lock ต้องใช้คู่กันไหม
บางครั้งใช่ แต่ไม่ควรเป็น default
ถ้า queue framework ของคุณจัดการ “ใครหยิบ job ไหน” ได้อยู่แล้ว คุณอาจไม่ต้อง lock เพิ่มที่ระดับ job queue
แต่ถ้าภายใน worker ยังมี critical section ที่ resource เดียวกันอาจถูกแตะจากหลาย job คนละประเภท เช่น
- order เดียวกันมีทั้ง webhook job และ reconciliation job
- customer เดียวกันถูก sync จากหลาย source
- file เดียวกันถูก process จากหลาย pipeline
lock อาจยังมีเหตุผลเฉพาะจุด
สิ่งสำคัญคืออย่าใส่ distributed lock ทุกที่เพียงเพราะคำว่า concurrency ฟังดูน่ากลัว
Stripe / Payment / Webhook jobs ต้องระวังอะไรเป็นพิเศษ
งานกลุ่มนี้ต้องระวัง retry มากเป็นพิเศษ เพราะ side effect ทางธุรกิจชัดเจนและมักมี third-party เข้ามาเกี่ยวข้อง เช่น
- payment succeeded event
- refund processing
- order status update
- reconciliation
ใน flow แบบนี้ retry โดยไม่มี idempotency หรือ state checks จะเสี่ยงมาก เช่น
- order ถูก mark ซ้ำ
- email ถูกส่งซ้ำ
- settlement ถูกเรียกซ้ำ
- audit trail ไม่ตรง
ดังนั้นงานพวกนี้ควรมีอย่างน้อย
- event deduplication
- state validation
- idempotent update logic
- traceable request/correlation ids
- retry policy แยกตาม error type
Slack / Approval / Notification jobs ต้องระวังอะไร
งานแนว notification หรือ approval workflows มักดูเบา แต่จริง ๆ ถ้า retry ไม่ดีจะเกิด UX ที่แย่มาก เช่น
- ส่ง message ซ้ำ
- ส่ง reminder ซ้ำ
- approve action ถูก trigger ซ้ำ
- interaction state เพี้ยน
ดังนั้นแม้งานประเภทนี้จะไม่ใช่ payment แต่ก็ยังต้องคิดเรื่อง idempotency, dedup และ delivery semantics ให้ชัด
ตัวอย่างด้วย BullMQ ใน Node.js
ตัวอย่างนี้ใช้ BullMQ เพราะ practical กว่าการเขียน queue semantics ดิบ ๆ เองในระบบส่วนใหญ่
const { Queue, Worker, QueueEvents } = require("bullmq");
const IORedis = require("ioredis");
const connection = new IORedis();
const emailQueue = new Queue("email-jobs", { connection });
const emailQueueEvents = new QueueEvents("email-jobs", { connection });
async function enqueueReceiptEmail({ orderId, requestId, correlationId }) {
await emailQueue.add(
"send-receipt-email",
{
orderId,
requestId,
correlationId
},
{
jobId: `receipt:${orderId}`,
attempts: 5,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: 1000,
removeOnFail: false
}
);
}
const emailWorker = new Worker(
"email-jobs",
async (job) => {
const { orderId, requestId, correlationId } = job.data;
const order = await getOrderById(orderId);
if (!order) {
throw new PermanentJobError(`Order not found: ${orderId}`);
}
if (order.receiptEmailSentAt) {
return {
skipped: true,
reason: "Receipt email already sent"
};
}
await sendReceiptEmail(order);
await markReceiptEmailSent(orderId);
return {
success: true,
requestId,
correlationId
};
},
{
connection,
concurrency: 5
}
);
emailWorker.on("failed", (job, error) => {
console.error("Job failed", {
jobId: job?.id,
name: job?.name,
attemptsMade: job?.attemptsMade,
error: error.message
});
});
emailQueueEvents.on("completed", ({ jobId }) => {
console.log("Job completed", { jobId });
});
class PermanentJobError extends Error {}
async function getOrderById(orderId) {
return {
id: orderId,
email: "customer@example.com",
receiptEmailSentAt: null
};
}
async function sendReceiptEmail(order) {
console.log(`Sending receipt email for order ${order.id} to ${order.email}`);
}
async function markReceiptEmailSent(orderId) {
console.log(`Marking receipt email sent for order ${orderId}`);
}
/**
* ตัวอย่าง enqueue
*/
enqueueReceiptEmail({
orderId: "ord_1001",
requestId: "req_abc123",
correlationId: "corr_payment_1001"
}).catch(console.error);
โค้ดชุดนี้กำลังช่วยอะไร
มีอยู่หลายจุดที่สำคัญ
อย่างแรก เราใช้ jobId: receipt:${orderId} ซึ่งช่วยลดโอกาส enqueue งานซ้ำชนิดเดียวกันสำหรับ order เดียวกันโดยไม่ตั้งใจ
อย่างที่สอง เราใช้ attempts และ backoff เพื่อให้ retry เป็นระบบมากขึ้น ไม่ใช่ fail แล้วลองใหม่ทันทีแบบรัว ๆ
อย่างที่สาม ภายใน worker เราเช็ก order.receiptEmailSentAt ก่อนส่งอีเมลจริง ซึ่งเป็นชั้น idempotency เชิงธุรกิจ ถ้า job ถูกหยิบซ้ำหรือ retry ระบบจะไม่ส่ง receipt ซ้ำอีกรอบโดยไม่จำเป็น
อย่างที่สี่ เรา log failed jobs พร้อม context พอสมควร ทำให้ตามย้อนหลังได้
จุดที่ควรปรับต่อใน production
ตัวอย่างด้านบนยังเป็น baseline เท่านั้น ของจริงควรคิดต่ออย่างน้อยเรื่องเหล่านี้
1) แยก permanent failure ออกจาก transient failure ให้ชัด
ใน BullMQ หรือ worker logic จริง คุณควรตัดสินใจว่า error แบบใดควรโยนให้ retry และ error แบบใดควรถูก mark fail ทันที
2) อย่าพึ่ง jobId อย่างเดียวแทน idempotency ทั้งหมด
jobId ช่วย dedup การ enqueue ระดับหนึ่ง แต่ไม่ได้แทน business idempotency ภายใน worker เพราะ job อาจถูกสร้างจากหลายเส้นทางหรือรันใหม่ในบริบทอื่นได้
3) อย่าเก็บ payload ใหญ่เกินไป
ควร enqueue แค่ข้อมูลที่จำเป็นต่อการหา resource และ trace context ไม่ใช่ snapshot ทั้งโลก
4) ต้องมี monitoring จริง
อย่างน้อยควรเห็น
- queue depth
- jobs waiting
- jobs active
- jobs failed
- retry rates
- job latency
- oldest pending job age
ถ้าไม่มีสิ่งเหล่านี้ queue จะกลายเป็น black box
5) ควรมี runbook สำหรับ failed jobs
ไม่ใช่แค่มี failed count แต่ต้องรู้ว่าจะ replay ยังไง, requeue ยังไง, หรือควรแก้ข้อมูลต้นทางก่อน
Error classification แบบง่ายที่ใช้ได้จริง
worker หลายตัวจะดีขึ้นทันทีถ้ามีการแยก error คร่าว ๆ แบบนี้
Retryable
- timeout
- 502/503/504 จาก provider
- connection reset
- temporary rate limit
- network glitch
Non-retryable
- invalid payload
- missing resource
- unauthorized
- business state invalid
- unsupported operation
การแยกแบบนี้ช่วยลด failed jobs ที่เกิดจาก retry แบบไม่มีความหมาย
Request ID และ Correlation ID ควรตามเข้า queue ไปด้วย
นี่เป็นเรื่องเล็กที่ช่วยชีวิตตอน debug มาก
ถ้า request หนึ่งเป็นคนสร้าง job และต่อมามี incident คุณจะอยากรู้ว่า
- job นี้มาจาก request ไหน
- อยู่ใน flow ไหน
- เกี่ยวกับ payment/order/document ไหน
- log ฝั่ง worker กับฝั่ง API ต่อกันตรงไหน
ดังนั้นอย่างน้อยควรพา field พวกนี้ไปด้วยเมื่อ enqueue
request_idcorrelation_idresource_idactor_idถ้าจำเป็น
Audit Trail ควรเข้ามาเมื่อไร
ไม่ใช่ทุก job ต้องมี audit trail แบบหนัก แต่ถ้างานนั้นทำ side effect สำคัญ เช่น
- approve / reject
- send document
- update status สำคัญ
- trigger refund
- change access
- generate official artifact
ควรมี audit trail หรืออย่างน้อย structured event log ที่ชัดว่า
- job อะไร
- ทำกับ resource ไหน
- สำเร็จหรือ fail
- retry รอบไหน
- ใครหรือ request ไหนเป็นต้นทาง
Checklist สำหรับ background jobs ที่ retry ได้จริง
ก่อนปล่อย worker หรือ queue สำคัญขึ้น production ควรถามให้ครบว่า
- job นี้ retry ได้เพราะ logic ด้านใน idempotent พอหรือยัง
- แยก transient กับ permanent failures หรือยัง
- มี backoff หรือยัง
- job payload เล็กและ stable พอหรือยัง
- มี dedup ระดับ enqueue หรือไม่
- มี state กลางใน database หรือไม่
- มี failed jobs handling หรือ dead-letter path หรือยัง
- log และ metrics พอจะตามย้อนหลังหรือยัง
- มี request/correlation context หรือยัง
- ถ้า worker crash กลางทาง จะเกิดอะไรขึ้น
บทความที่ควรอ่านต่อ
- Redis ใช้เป็น Cache, Session และ Queue ต่างกันยังไง
- Redis Distributed Lock ใน Node.js จำเป็นตอนไหน และระวังอะไร
- Slack Bot สำหรับ approval workflow ควรออกแบบยังไงไม่ให้มั่ว
- Stripe Webhook กับ Order State Machine ควรออกแบบยังไงไม่ให้สถานะเพี้ยน
สรุป
background jobs ที่ดีไม่ได้จบที่การเอางานออกจาก request หลัก แต่ต้องออกแบบให้โลกของ async processing มีวินัยพอจะรับมือกับ timeout, retries, worker crashes และ side effects ที่อาจเกิดซ้ำ
ถ้าจะทำให้ retry ได้จริง คุณต้องคิดตั้งแต่ payload, idempotency, backoff, failure classification, observability และวิธีรับมือกับงานที่ fail ถาวร ไม่ใช่แค่ตั้ง attempts แล้วหวังว่าทุกอย่างจะดีเอง
สรุปสั้นที่สุดคือ
retry ที่ดีเริ่มจากการออกแบบ job ให้รันซ้ำได้อย่างปลอดภัย
ไม่ใช่เริ่มจากการสั่งให้ queue ลองใหม่หลายครั้ง