เมื่อคุณสร้างแอปพลิเคชันด้วย Node.js การจัดการข้อผิดพลาด (error handling) ดูเหมือนจะง่ายเกินไปในช่วงแรก คุณแค่ครอบ route ด้วย try-catch ส่ง status code 500 กลับไป แล้วให้ client ตัดสินใจเองว่าจะทำอย่างไรต่อ โมเดลนี้ใช้ได้ดีกับ HTTP แต่จะพังทันทีเมื่อคุณเปลี่ยนไปทำงานกับ background jobs ในระบบ queue ไม่มี client คอยรออยู่ มีเพียง worker, payload และตัวนับการลองใหม่ (retry counter) ที่กำลังเพิ่มขึ้นใน Redis, RabbitMQ หรือ SQS หากคุณจัดการความล้มเหลวด้วยวิธีเดียวกับที่จัดการ web request ที่ล้มเหลว คุณจะไม่ใช่แค่ทำธุรกรรมหลุดไปเพียงรายการเดียว แต่คุณจะทำให้ pipeline ทั้งหมดหยุดชะงัก สิ้นเปลืองทรัพยากรคำนวณ หรือทำให้ worker ของคุณ crash ซ้ำแล้วซ้ำเล่าด้วยข้อความเดิมที่เป็นปัญหา (poisoned message)

แนวคิดแบบ HTTP ใช้ไม่ได้กับ Background Jobs

ในวงจร request-response วงจรการตอบกลับ (feedback loop) จะเกิดขึ้นทันที ผู้ใช้คลิกปุ่ม เซิร์ฟเวอร์โยน error และผู้ใช้ก็จะเห็นหน้าจอแจ้งความล้มเหลว การทำ cleanup นั้นง่ายมาก แต่ queue worker นั้นทำงานอย่างโดดเดี่ยว มันจะดึงงาน (job) ขึ้นมา ทำงานเป็นเวลาไม่กี่วินาทีหรือกี่นาที แล้วจึงส่งการยืนยันความสำเร็จ (acknowledgment) หากมีบางอย่างผิดพลาดระหว่างนั้น queue จะไม่รู้เลยว่าเกิดอะไรขึ้น มันรู้เพียงแค่ว่าไม่ได้รับการยืนยันเท่านั้น และขึ้นอยู่กับการตั้งค่าของคุณ มันอาจจะลองใหม่ (retry) ไปเรื่อยๆ อย่างไม่สิ้นสุด payload ที่ผิดรูปแบบเพียงอันเดียวอาจจะวิ่งวนไปมาระหว่าง worker นับร้อยครั้ง ทำให้เสีย CPU และไปแอบอยู่หลังงานปกติอื่นๆ ที่จำเป็นต้องได้รับการประมวลผลจริงๆ

ความล้มเหลวสองประเภท

กฎข้อแรกของ queue ที่มีความทนทาน (resilient queues) คือการหยุดปฏิบัติกับทุก error เหมือนกันหมด คุณต้องแยกความล้มเหลวออกเป็นสองกลุ่มทันทีที่มันเกิดขึ้น

Retryable failures คือความล้มเหลวแบบชั่วคราว (transient) เช่น network timeouts, การติด rate limits จาก third-party API หรือการเชื่อมต่อฐานข้อมูลที่หลุดเพราะ connection pool เต็มชั่วคราว สิ่งเหล่านี้คืออาการของระบบที่กำลังทำงานภายใต้ความกดดัน ซึ่งอาจจะทำงานสำเร็จในการลองครั้งถัดไปในอีกสองนาทีข้างหน้า

Permanent failures คือ "poison pills" (ปัญหาที่แก้ไม่ได้) ซึ่งรวมถึง payload ที่ผิดรูปแบบ, ข้อผิดพลาดจากการตรวจสอบ schema (schema validation errors) หรือฟิลด์ที่จำเป็นหายไปเนื่องจากบริการต้นทาง (upstream service) เปลี่ยนข้อกำหนด (contract) การลองใหม่ในกรณีนี้คือการเสียเวลาเปล่า เพราะมันจะล้มเหลวแบบเดิมซ้ำๆ ในการลองครั้งที่ร้อย

หาก catch block ของคุณไม่สามารถแยกความแตกต่างระหว่างสองสิ่งนี้ได้ queue ของคุณก็เหมือนกำลังบินโดยไม่มีเครื่องนำทาง

รูปแบบที่ 1: แยกประเภท Error ที่ Catch Block

catch block ของ worker ควรเป็นโค้ดที่คุณเขียนอย่างรอบคอบที่สุดในไฟล์ เมื่อมี error เกิดขึ้น ให้ตรวจสอบมันทันที Error code คือ ECONNRESET หรือเป็น timeout หรือไม่? ถ้าใช่ ให้เข้าคิวเพื่อ retry แต่ถ้ามันเป็น SyntaxError, การปฏิเสธจาก Joi validation หรือการขาด foreign key constraint ให้ส่งมันไปยัง dead-letter queue หรือ DLQ ทันที และอย่าเอาไปนับรวมใน retry limit ของคุณ

Library สำหรับ queue ใน Node.js ส่วนใหญ่ รวมถึง BullMQ และ Bee Queue อนุญาตให้คุณกำหนดกลยุทธ์ backoff และ error hooks แบบกำหนดเองได้ จงใช้มัน Error แบบถาวรไม่ควรถูกตั้งค่าให้ sleep และ retry สามครั้งโดยค่าเริ่มต้น แต่มันควรถูกย้ายออกจาก queue หลักเพื่อให้งานอื่นๆ ไหลต่อไปได้ DLQ จะช่วยเก็บ payload เดิมและบริบทของ error ไว้ ซึ่งจะช่วยให้คุณสามารถนำงานนั้นมาทำซ้ำ (replay) ได้ในภายหลังหลังจากที่คุณแก้ไข bug หรือแก้ไข schema เรียบร้อยแล้ว

รูปแบบที่ 2: Exponential Backoff พร้อม Jitter

การ retry ทันทีนั้นเป็นการกระทำที่รุนแรงเกินไป หากฐานข้อมูลปลายทางกำลังทำงานหนักจนแทบไม่ไหวอยู่แล้ว การยิงคำสั่งซ้ำเข้าไปทุกๆ สองวินาทีจาก worker ห้าสิบตัวจะทำให้มันพังลงทันที คุณจำเป็นต้องถอยออกมาและให้เวลาแก่ระบบในการฟื้นตัว

ใช้ exponential backoff ในการล้มเหลวครั้งแรก ให้รอหนึ่งวินาที ครั้งที่สอง รอสองวินาที จากนั้นเป็นสี่ แล้วแปด ไปจนถึงเพดานที่เหมาะสม เช่น ห้านาที แต่การกำหนดเวลาเพียงอย่างเดียวไม่เพียงพอ หากทุกงานที่ล้มเหลวใช้ช่วงเวลาที่เท่ากันเป๊ะ พวกมันจะพุ่งเข้าหาเป้าหมายพร้อมกันเมื่อถึงเวลา backoff หมดลง คลื่นที่ถาโถมเข้ามาพร้อมกันนี้ หรือที่บางครั้งเรียกว่า thundering herd สามารถทำให้บริการที่กำลังฟื้นตัวต้องรับภาระหนักเกินไปได้

เพิ่ม jitter เข้าไป โดยนำระยะเวลาหน่วง (delay) ที่คำนวณได้มาสุ่มเพิ่มหรือลดด้วยเปอร์เซ็นต์แบบสุ่ม เช่น สิบถึงยี่สิบเปอร์เซ็นต์ จากสี่วินาทีอาจกลายเป็น 4.2 หรือ 4.7 วินาที ความสุ่มที่เรียบง่ายนี้จะช่วยกระจายการพุ่งขึ้นของ retry spike และป้องกันไม่ให้โครงสร้างพื้นฐานของคุณถูกโจมตีเป็นระลอกคลื่น

รูปแบบที่ 3: ออกแบบเพื่อ Idempotency

นี่คือจุดที่โครงสร้างพื้นฐานของ queue มาบรรจบกับ business logic ลองจินตนาการถึงงานที่ต้องเรียกเก็บเงินลูกค้าผ่านผู้ให้บริการชำระเงิน worker ทำการเรียกเก็บเงินสำเร็จ แต่การเชื่อมต่อหลุดก่อนที่จะสามารถบันทึกความสำเร็จลงในฐานข้อมูลของคุณหรือส่งการยืนยันงาน (acknowledge) ได้ ในมุมของ queue มันเห็นว่าเป็นความล้มเหลว มันจึงทำการ retry และผลที่ตามมาคือลูกค้าจะถูกเรียกเก็บเงินซ้ำสองครั้ง

ใน Node.js ให้ป้องกันปัญหานี้ด้วยการทำให้ทุก side effect เป็น idempotent สร้าง idempotency key จาก job ID หรือตัวระบุเฉพาะทางธุรกิจ ก่อนที่คุณจะสร้างรายการเรียกเก็บเงิน ส่งอีเมล หรือปรับปรุงสต็อกสินค้า ให้ตรวจสอบก่อนว่างานนั้นทำเสร็จไปแล้วหรือยัง ส่ง key นั้นต่อไปยังฐานข้อมูลของคุณและไปยัง third-party APIs ต่างๆ ที่รองรับ ออกแบบโครงสร้างงานของคุณให้การรัน payload เดิมซ้ำกันสิบครั้ง ให้ผลลัพธ์เหมือนกับการรันเพียงครั้งเดียว นิสัยเพียงอย่างเดียวนี้จะช่วยกำจัดบั๊กด้านการเงินและความถูกต้องของข้อมูล (data-integrity) ไปได้ทั้งหมวดหมู่เลยทีเดียว

Pattern 4: ปฏิบัติต่อ Dead-Letter Queue ของคุณให้เหมือนกับ Dashboard

DLQ ไม่ใช่สุสานที่งานที่ผิดพลาดจะถูกทิ้งไว้ให้ลืมเลือน แต่มันคือเครื่องมือในการดำเนินงาน (operational tool) และควรเป็นหนึ่งในส่วนที่คุณต้องเฝ้าติดตามอย่างใกล้ชิดที่สุด

ตั้งค่าการแจ้งเตือน (alerts) ให้ทำงานเมื่อความลึก (depth) ของ DLQ เพิ่มขึ้น แม้จะมีข้อความเพียงข้อความเดียวใน DLQ ก็มักหมายความว่า validation logic ของคุณพัง, schema ของต้นทาง (upstream) เปลี่ยนแปลง หรือบริการปลายทาง (downstream) กำลังส่งข้อมูลขยะที่คุณไม่รู้จักอีกต่อไป สิ่งเหล่านี้คือสัญญาณที่คุณต้องการตรวจจับก่อนที่ลูกค้าจะเริ่มร้องเรียน สร้าง dashboard ที่ช่วยให้คุณตรวจสอบ raw payload, stack trace และ timestamp ได้ เตรียม runbook ให้พร้อม: ตรวจสอบความล้มเหลว, แก้ไขโค้ด (patch), จากนั้นจึงส่งข้อความซ้ำ (replay) ตามลำดับที่ถูกต้อง หากระบบ queue ของคุณรองรับ ให้แจ้งเตือนทั้งอัตราการเติบโต (growth rate) และจำนวนทั้งหมด (absolute count) ด้วย เพราะการ deploy ที่ผิดพลาดเพียงครั้งเดียวอาจทำให้ DLQ ล้นได้ภายในไม่กี่นาที

Pattern 5: หากไม่แน่ใจ ให้สั่ง Crash Process ไปเลย

Node.js ทำงานบน single event loop ภายใน V8 isolate การเกิด unhandled promise rejection หรือ...