BullMQ คืออะไร? แล้วทำไม Backend ต้องมี Queue
Bull MQ?
BullMQ คืออะไร? แล้วทำไม Backend ต้องมี Queue
Bull MQ?
ก่อนอื่นผมต้องขอนิยามตัวของมันก่อนว่ามันคืออะไร Bull MQ เนี่ย คือ NodeJS library เอาไว้ implement ระบบ message queue หรือว่า job queue บน redis ใช้ Lua script (ที่เค้าเอาไว้เขียนเกม Roblox) + pipelining เพื่อให้ throughput สูง และ polling-free design
เอาจริงๆมันก็เป็นได้หลายอย่าง
- Job Queue = เอาไว้สั่งงาน async
- Task Scheduler = รัน job ตาม cron หรือ delay
- Workflow Engine(แค่เบื้องต้น) = parent-child job flow
- Rate-limited dispatcher = กับ api hit เกิน quota
- retry framework = auto retry + exponential backoff
รวมๆก็ คือ ใช้ redis เป็น storage layer คือ สามารถ distributed ได้ทันที (distributed คือ หลาย worker หลาย process/ หลาย machine แชร์ queue เดียวกันได้)
Lua Script กับ Redis — ทำไมต้อง Lua?
Lua Script กับ Redis แล้วทำไมต้อง Lua?
ก่อนจะไปต่อ ผมขออธิบายเรื่อง Lua ก่อนเพราะมันเป็นหัวใจของ BullMQ เลย
ปัญหาคือแบบนี้ สมมติ worker 2 ตัวจะหยิบ job ไปทำพร้อมกัน ถ้า BullMQ ใช้คำสั่ง Redis แยกกันเป็นทีละขั้น เช่น อ่าน job ก่อน แล้วค่อยลบออกจาก queue แล้วค่อยเพิ่มเข้า active list มันจะเกิด race condition ได้ คือ worker ทั้ง 2 ตัวอ่านเห็น job เดียวกัน แล้วทั้งคู่ก็หยิบไปทำซ้ำ
Lua แก้ปัญหานี้ได้เพราะ Redis มี built-in Lua engine อยู่แล้ว คือเราส่ง Lua script ไปรันบน Redis server ได้โดยตรงผ่านคำสั่ง EVAL และที่สำคัญคือ Redis รัน Lua script แบบ single-threaded, blocking ระหว่างที่ script กำลังทำงาน จะไม่มีคำสั่งอื่นแทรกเข้ามาได้เลย เหมือนได้ transaction ฟรีๆ
พูดง่ายๆ คือ Lua เป็น “กาว” ที่ให้ BullMQ รวมหลายๆ Redis operation (ลบจาก wait, เพิ่มเข้า active, เซ็ต lock, อัพเดท timestamp) เข้าเป็นก้อนเดียวที่ atomic ไม่มีใครแทรกได้
ตัว BullMQ เองเขียนด้วย TypeScript ทั้งหมด แต่ข้างในมี Lua script ฝังอยู่หลายไฟล์ที่จะถูกส่งไปรันฝั่ง Redis ทุกครั้งที่ทำ state transition ของ job
เจ้านาย(Producer) vs ลูกจ้าง(Worker)
Producer / Publisher
- หลักๆ คือ มีหน้าที่โยน job ลง queue
- code syntax ก็เช่น queue.add(…)
- เป็นฝั่งที่ต้องตัดสินใจว่ามีงานที่ต้องทำ (เป็นคนสั่งงาน)
- เช่น API Endpoint ที่รับ user signup
Worker/ Consumer/ Subscriber
- มีหน้าที่ หยิบงานไปทำ
- code syntax ก็เช่น async (job) ⇒ {…}
- อยู่ฝั่งที่มี logic จริงของงาน หรือ ก็คือ ฝั่งที่ทำงานนั่นแหละ
- เช่น Background process ตอนที่ ส่ง email
วงจรชีวิตของ Job (Job Lifecycle)
หลายคนอาจจะคิดว่า job queue มันก็แค่ “โยนงานเข้าไป แล้วก็ทำ จบ” แต่จริงๆ แล้ว job แต่ละตัวใน BullMQ มันมี state machine อยู่ข้างหลัง คือมันมีสถานะที่เปลี่ยนไปเรื่อยๆ ตามนี้
add() → wait ──► active ──► completed
│ │
│ └────► failed ──► (retry?) ──► wait
↓
delayed (ถ้า delay > 0 หรือ repeatable cron) ──► wait
อธิบายแต่ละสถานะ
- wait = job เพิ่งถูก add เข้า queue นั่งรอ worker มาหยิบ
- delayed = ถ้าตั้ง delay ไว้ (เช่น ส่ง email อีก 5 นาที) หรือเป็น repeatable cron job จะอยู่ตรงนี้ก่อน พอถึงเวลาก็ย้ายไป wait อัตโนมัติ
- active = worker หยิบไปทำแล้ว กำลัง process อยู่
- completed = ทำเสร็จ return ค่าเรียบร้อย
- failed = throw error ถ้า config retry ไว้ก็จะวนกลับไป wait ใหม่ ถ้าไม่ก็จบที่ failed
สิ่งที่น่าสนใจคือ ทุก state transition พวกนี้ถูกทำผ่าน Lua script ที่เราพูดไปเมื่อกี้ คือเป็น atomic ทั้งหมด ไม่มี race condition
Idempotency คือ สิ่งที่ต้องรู้ก่อนใช้ BullMQ
เรื่องนี้สำคัญมากแต่คนมักมองข้าม BullMQ เป็น at-least-once delivery คือ การันตีว่า job จะถูกทำ “อย่างน้อย 1 ครั้ง” แต่อาจจะมากกว่า 1 ครั้งได้
สถานการณ์ที่เกิดขึ้นได้ เช่น worker ทำงานเสร็จแล้ว (ส่ง email ไปแล้ว) แต่ดันเกิด crash ก่อนที่จะ ack กลับไปบอก Redis ว่า “เสร็จแล้วนะ” ผลคือ BullMQ จะคิดว่า job ยังไม่เสร็จ แล้วก็ retry ให้ worker ตัวอื่นมาทำซ้ำอีกรอบ
ผลลัพธ์ คือ user อาจจะได้ email ยืนยัน 2 ฉบับ หรือถูกตัด stock 2 ครั้ง ถ้าเราไม่ได้ออกแบบรับมือไว้
วิธีแก้คือทำให้ job เป็น idempotent คือ ทำกี่ครั้งก็ได้ผลเหมือนเดิม ตัวอย่างเช่น
// ❌ ไม่ idempotent - ส่งซ้ำ user ได้ email 2 ฉบับ
async function processJob(job) {
await sendEmail(job.data.email, "ยืนยัน order");
}
// ✅ idempotent - เช็คก่อนว่าส่งไปแล้วยัง
async function processJob(job) {
const alreadySent = await db.emailLog.findUnique({
where: { orderId: job.data.orderId }
});
if (alreadySent) return; // ส่งไปแล้ว ข้าม
await sendEmail(job.data.email, "ยืนยัน order");
await db.emailLog.create({
data: { orderId: job.data.orderId, sentAt: new Date() }
});
}
Retry & Exponential Backoff
BullMQ มีระบบ retry ในตัว คือ ถ้า job fail แทนที่จะทิ้งไปเลย มันจะลองใหม่ตามที่เรา config ไว้
// ตัวอย่าง config retry
await queue.add('send-receipt', { orderId: 123 }, {
attempts: 5, // ลองใหม่ได้สูงสุด 5 ครั้ง
backoff: {
type: 'exponential', // รอนานขึ้นเรื่อยๆ
delay: 1000, // เริ่มต้นรอ 1 วินาที
}
});
Exponential Backoff คืออะไร?
คือ แทนที่จะ retry ทันที (ซึ่งถ้า API ปลายทางล่มอยู่ ยิงไปก็ fail อีก) มันจะรอนานขึ้นเรื่อยๆ แบบทวีคูณ
- ครั้งที่ 1 fail → รอ 1 วินาที → ลองใหม่
- ครั้งที่ 2 fail → รอ 2 วินาที → ลองใหม่
- ครั้งที่ 3 fail → รอ 4 วินาที → ลองใหม่
- ครั้งที่ 4 fail → รอ 8 วินาที → ลองใหม่
- ครั้งที่ 5 fail → หมด attempts → ย้ายไป failed ถาวร
ทำไมต้องรอนานขึ้น? เพราะถ้า API ปลายทางล่ม แล้วเรายิงซ้ำถี่ๆ มันก็แค่ทำให้เขาล่มหนักขึ้น (เหมือนคนกดกริ่งรัวๆ ตอนเจ้าของบ้านไม่อยู่) การรอนานขึ้นเรื่อยๆ ให้เวลา server ปลายทางฟื้นตัว
Distributed Queue (redis + bull mq)
คือ ระบบทำงานกระจายอยู่บนหลาย process หรือหลายเครื่อง แต่ยังเห็น queue เป็นอันเดียวกัน
- Horizontal Scaling(แนวนอน) = สมมติว่าเราอยู่เมกาแล้วเป็นวัน black friday (user เข้ามาใช้เยอะขึ้น) เราเลย spawn worker เพิ่มไปอีก 10 ตัว หลังจจากนั้น Bull MQ จะจัดการ load balance ให้อัติโนมัติแบบไม่ต้องเขียน logic เอง
- Producer/Worker แยกกันได้ = API Server (Producer) อยู่บน machine A, worker อยู่บน machine B คนละ codebase ก็ได้ขอแค่ redis ตัวเดียวกัน
- Fault tolerance = สมมติ worker 2 crash ตัว job ที่ยังรันค้างอยู่เนี่ยจะถูก worker 1 หรือ 3 หรือสักตัวที่ว่างมารับไม้ต่อ (รับผ่าน stalled detection)
- Rolling Deploy = สมมติ Deploy worker เวอร์ชันใหม่ มันจะ ปิด worker เก่าทีละตัว แต่ว่า job จะไม่หายเพราะว่าอยู่ใน redis
- Cross-service queue = สมมติ service A push job, service B ที่อยู่คนละ repo เนี่ยสามารถ consume(ใน context นี้คือ เอาไปทำต่อ) job ของ service A ได้ผ่าน redis คือ ใช้ redis เป็น contract กลาง
แล้ว BullMQ รู้ได้ยังไงว่า worker ตาย?
คำตอบคือ Stalled Detection หลักการง่ายมาก คือ worker ที่กำลังทำ job อยู่จะส่ง heartbeat (สัญญาณบอกว่า “ฉันยังอยู่นะ”) กลับไปที่ Redis ทุกๆ ~30 วินาที
ถ้า Redis ไม่ได้รับ heartbeat ภายในเวลาที่กำหนด (stalledInterval) ก็จะถือว่า worker ตัวนั้นตายแล้ว แล้ว BullMQ จะย้าย job จาก active กลับไป wait อัตโนมัติ เพื่อให้ worker ตัวอื่นที่ยังอยู่มาหยิบไปทำต่อ
เปรียบเทียบง่ายๆ คือ เหมือนเจ้านายโทรเช็คลูกจ้างทุก 30 วิ ถ้าโทรไปแล้วไม่รับ ก็เอางานไปให้คนอื่นทำแทน
Redis = Contract กลาง
ไอ่ตรง cross service ที่พูดถึงไปเมื่อกี้เนี่ยที่บอกว่าใช้ redis เป็น contract กลาง คือ หมายถึงว่า service A ไม่ต้องไปรู้กันว่า service B เนี่ยเป็นยังไง เช่น เขียนด้วยภาษาอะไร, รันที่ไหน (machine ไหน, cloud ไหน), ยังออนไลน์อยู่ไหม เรื่องพวกนี้ service A ไม่จำเป็นต้องสน สิ่งที่ service A ต้องรู้เกี่ยวกับ service B ก็แค่
- ชื่อ queue คือ send-receipt
- Job data มี shape แบบไหน เช่น { orderId: number, email: string }

ข้อดีของการแยกแบบนี้
ผมสมมติว่า
- Email service ล่ม 5 นาที Order service ไม่กระทบ เพราะว่า job แค่ค้างใน Redis รอ email service กลับมา
- ต้องเปลี่ยน email provider (SendGrid → Mailgun) ก็ไปแก้แค่ Service B, Service A ไม่ต้องรู้เรื่องด้วย
- อยากเพิ่ม SMS notification ด้วยก็ไปสร้าง Service C ที่ฟัง queue เดียวกันได้ (จริงๆ ใน BullMQ จะใช้ queue แยก แต่หลักการคือ decouple แล้ว)
อธิบายเพิ่มเติม
decouple = การแยกหรือไม่ผูกติดเหมือนที่ job process แบบ redis + bull mq ไม่ได้ผูกติดกัน คือ service นึงล่มก็แค่ส่ง queue เข้าไปที่ redis แล้วรอ service อื่นหยิบ queue ไปทำงานต่อ แต่ถ้าเป็น
couple = การผูกติดกัน service A ต้องรู้ว่า B มีอยู่ (A เป็น dependency ของ B) B พัง A ก็พังตาม
Black Friday traffic spike
ในสังคมอเมริกันเนี่ยมันจะมีสิ่งที่เรียกกันว่า black friday เป็นวันศุกร์เดือน พฤศจิกายน (ศุกร์หลัง thanks giving) เป็นวันลดราคาใหญ่ที่สุดของปีในอเมริกา สิ่งที่เกิดขึ้นก็ คือ คนเมกันแห่เข้าไปที่เว็บไซต์ e-comerce พร้อมกัน ทำให้เว็บไซต์เนี่ยมี traffic ที่สูงผิดปกต(อาจจะ 10–50 เท่า load พุ่งกระทันหัน) เช่น วันธรรมดาอาจมี traffic order แค่ 1000/ชั่วโมง แต่พอเป็น black friday ก็อาจจะเป็น 50,000/ชั่วโมง

ภาพ: https://studybreaks.com/culture/black-friday-differs-overseas/
ทำไมมันเกี่ยวกับ Bull MQ
ให้ลองนึกภาพร้านค้า online ตอน black friday
ปกติ 1,000 order/ชั่วโมง
black friday 50,000 order/ชั่วโมง
คือ แต่ละ order เนี่ยมันมี job background ที่ต้องทำ เช่น ส่ง email ยืนยัน, ตัด stock, เรียก payment gateway, ออกใบเสร็จเป็น PDF, แจ้งไปที่ warehouse ที่ว่ามาทั้งหมดนี้ไม่สามารถถูก handle ได้ด้วย HTTP request ตรงๆ ถ้าเรา handle job background พวกนี้ที่มี traffic 50,000 order/ชั่วโมงด้วย HTTP อย่างเดียวก็เตรียมตัว เรือหายไปได้เลย เพราะสิ่งที่เสียมันไม่ใช่แค่ความเชื่อใจจาก user ที่หายไปเพราะเห็นระบบล่ม สิ่งที่เสียหายไปมากที่สุด คือ โอกาสทางการค้า เช่น ถ้าระบบสามารถรองรับ traffic เหล่านั้นได้ก็อาจได้รายได้เข้าบริษัท หลายแสน หรือ หลายล้าน แค่สมมติว่าแต่ละ order มีมูลค่า 100 บาท แล้วระบบรองรับไม่ได้ก็หมายความว่าเราเสียโอกาสไป 5 ล้านบาท
BullMQ ช่วยยังไง?
50,000 orders เข้ามา
↓
API แค่ queue.add() → return 200 OK ทันที (เร็ว <50ms)
↓
Job ค้างใน Redis 50,000 ตัว
↓
Spawn worker pods เพิ่มจาก 2 → 20 ตัว (autoscale)
↓
Workers ช่วยกันเคลียร์งาน — ค่อยๆ process ตาม capacity ที่มี
ผลลัพธ์ คือ
- User ไม่ต้องรอ คือ API response เร็ว เพราะแค่โยนงานลง queue
- ระบบไม่ล่ม — งานกองใน Redis ได้ ไม่ overload downstream services (DB, payment API)
- Scale ตามต้องการ — เพิ่ม worker → เคลียร์เร็วขึ้น, หมด event → ลดกลับ
Queue ทั้ง 2 แบบ
1. Non-distributed queue
- เป็นพวก in-memory queue เช่น p-queue, async.queue คือ มันเป็น queue ที่อยู่ใน RAM ของ node process เดียว แค่เรา restart ก็จะหายไป แล้วก็ไมาสามารถ scale ได้
- มันเหมาะกับงานเล็กๆใน process เดียว เช่น Limit concurrent HTTP calls ภายในฟังก์ชัน
2. distributed queue
- เป็น queue ที่ไม่ได้ผูกกับ process ใด process นึง เพราะว่า state เพราะว่า state ทั้งหมดจะถูกเก็บใน redis ที่เป็น service กลางที่ worker แต่ละตัวสามารถใช้ redis โยกย้าย queue มาทำงานต่อกันได้(รับไม้ต่อ)
- อย่างที่บอกไปมันสามารถ แชร์ queue ข้าม service ได้ หรือ ข้ามเครื่องก็ได้ ขอแค่ใช้ redis ตัวเดียวกันก็พอ
- สามารถ scale ตัว worker เป็นแนวนอนได้ (horizontal scaling)
- queue ไม่หายไปเมื่อ process ตาย เพราะว่า service ที่ว่างและใช้ redis ตัวเดียวกันจะมารับ queue นี้ไป process ต่อไป ต่างกับ in-process ที่หายตอนที่เรา refresh
ควรใช้ตอนไหน? เหมาะกับงานแบบไหน?
- งาน async นานเกิน 100ms ที่ไม่ควรอยู่ใน HTTP request lifecycle (email, image processing, PDF gen)
- งาน schedule (cron daily report, weekly cleanup)
- งานที่ต้อง retry (webhook delivery, external API calls)
- งานที่ต้อง decouple system A กับ system B
- งานที่ต้อง rate limit (call OpenAI ไม่เกิน X req/min)
- Background processing pipeline (parent-child flow)
- Multi-instance backend ที่ต้องแชร์ queue (load balancing งาน)
ไม่ควรใช้ตอนไหน?
- Real-time streaming (sub-millisecond latency) แบบนี้ไปหาใช้ Kafka/Redpanda ดีกว่า (ผมขอยังไม่พูดถึง Kafka มากใน blog นี้เดี๋ยวมันยาวไป)
- High-throughput event log (millions events/sec) Kafka ดีกว่า
- In-memory simple queue ภายใน process เดียว ใช้ p-queue หรือ async.queue พอไม่ต้อง Redis (เดี๋ยว over engineer)
- Persistent message bus ที่ต้องเก็บ event นาน (audit log) ใช้ Kafka/EventStore
- ไม่มี Redis — BullMQ บังคับใช้ Redis (4.0+)
- Pub/Sub ที่ฟังหลาย consumers BullMQ design เป็น work queue (job ตัวเดียวถูก consume แค่ครั้งเดียว) ไม่ใช่ broadcast
- Distributed transactions ที่ต้อง 2-phase commit — BullMQ ไม่มี
- Cross-language workers — BullMQ เป็น Node.js only (มี Python port bullmq-py แต่ partial)
สามารถอ่าน docs เต็มๆได้ที่ https://docs.bullmq.io/
ขอบคุณที่สละเวลาอ่านมาถึงตอนนี้ ขอให้เป็นวันที่ดีครับ
메타데이터
- post_id
- 71ec35f75bcc
- slug
- bullmq-คืออะไร-แล้วทำไม-backend-ต้องมี-queue-71ec35f75bcc
- url
- https://medium.com/myorder/bullmq-%E0%B8%84%E0%B8%B7%E0%B8%AD%E0%B8%AD%E0%B8%B0%E0%B9%84%E0%B8%A3-%E0%B9%81%E0%B8%A5%E0%B9%89%E0%B8%A7%E0%B8%97%E0%B8%B3%E0%B9%84%E0%B8%A1-backend-%E0%B8%95%E0%B9%89%E0%B8%AD%E0%B8%87%E0%B8%A1%E0%B8%B5-queue-71ec35f75bcc
- canonical_url
- https://medium.com/myorder/bullmq-%E0%B8%84%E0%B8%B7%E0%B8%AD%E0%B8%AD%E0%B8%B0%E0%B9%84%E0%B8%A3-%E0%B9%81%E0%B8%A5%E0%B9%89%E0%B8%A7%E0%B8%97%E0%B8%B3%E0%B9%84%E0%B8%A1-backend-%E0%B8%95%E0%B9%89%E0%B8%AD%E0%B8%87%E0%B8%A1%E0%B8%B5-queue-71ec35f75bcc
- author_url
- https://medium.com/@tanapatwave14
- status
- ok
- fetched_at
- 2026-07-11 00:14:15