บทเรียน Node.js ส่วนใหญ่มักมองว่าการจัดการข้อผิดพลาดเป็นเพียงเรื่องรอง คุณครอบ route handler ด้วย block try/catch, log stack trace และส่ง 500 กลับไป แนวคิดนี้ยังคงอยู่เพราะมีคนจริงๆ รออยู่ที่ปลายทางของ HTTP request แต่งานเบื้องหลัง (Background jobs) นั้นต่างออกไป ในระบบคิว ไม่มี client ที่รอคำตอบอย่างใจร้อน และไม่มีการรีเฟรชเบราว์เซอร์โดยอัตโนมัติ มีเพียง worker, payload และตัวนับการลองใหม่ (retry counter) ที่ค่อยๆ เพิ่มขึ้นอย่างเงียบเชียบ เมื่อเกิดปัญหา มันจะเริ่มจากช้าๆ แล้วค่อยๆ ลามไปจนพังทั้งหมดพร้อมกัน ข้อผิดพลาดที่จำแนกประเภทผิดเพียงครั้งเดียวอาจทำให้ pipeline ทั้งหมดหยุดชะงัก หรือปลุกวิศวกรให้ตื่นขึ้นมาตอนตีสาม

ความแตกต่างนั้นเรียบง่าย วงจร request-response จะล้มเหลวอย่างรวดเร็วและชัดเจน แต่ความล้มเหลวในคิวจะเงียบเชียบ Worker อาจจะประมวลผลงานหลายร้อยชิ้นก่อนที่การเชื่อมต่อฐานข้อมูลจะหลุด หากไม่มีกฎการจัดการที่ชัดเจน worker จะพยายามลองใหม่ทันที ซึ่งเป็นการโถมใส่ฐานข้อมูลที่กำลังมีปัญหาอยู่แล้วจนทำให้ระบบล่ม เนื่องจากไม่มีใครเฝ้าดู worker โดยตรง สัญญาณแรกของปัญหาจึงมักจะเป็นการสะสมของงานที่ค้างอยู่ (cascading backup) หรือดิสก์เต็มไปด้วยไฟล์ log คุณต้องการมากกว่าแค่ catch block คุณต้องการกลยุทธ์ที่จัดการกับความล้มเหลวแต่ละประเภทต่างกัน และปกป้องส่วนที่เหลือของระบบจากงานที่ผิดพลาดเพียงชิ้นเดียว

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

เริ่มต้นด้วยการแบ่งข้อผิดพลาดทุกอย่างออกเป็นสองกลุ่ม

ข้อผิดพลาดที่ลองใหม่ได้ (Retryable errors) คือปัญหาชั่วคราว เช่น network timeout ไปยัง third-party API, การตอบกลับแบบ 429 rate-limit หรือ database replica ที่ทำงานล่าช้ากว่าตัวหลักชั่วคราว สิ่งเหล่านี้คืออาการของความหนาแน่น (pressure) ไม่ใช่บั๊ก ระบบอาจจะกลับมาเป็นปกติได้เองในสามสิบวินาที งานที่ลองใหม่ได้ควรได้รับโอกาสอีกครั้ง แต่ต้องอยู่ภายใต้เงื่อนไขที่ควบคุมได้เท่านั้น

ข้อผิดพลาดถาวร (Permanent errors) คือความผิดพลาด เช่น JSON ใน payload ไม่ถูกต้อง, user ID หายไป หรือไฟล์ที่จำเป็นไม่มีอยู่ใน storage สิ่งเหล่านี้จะล้มเหลวในการลองครั้งที่ร้อยเหมือนกับที่ล้มเหลวในการลองครั้งแรก การพยายามลองใหม่จะสิ้นเปลือง CPU, เปลืองช่องว่างในคิว และสร้าง back-pressure ที่เป็นพิษซึ่งจะไปหน่วงงานอื่นๆ ที่ปกติอยู่ ทางเดียวที่มีประโยชน์สำหรับความล้มเหลวถาวรคือการบันทึกลง log, การแจ้งเตือน (alert) หรือส่งไปยัง dead-letter queue มันไม่ควรอยู่ในลูปการลองใหม่ (retry loop)

สร้างกลไกการตัดสินใจ

จำแนกประเภททันที อย่าปล่อยให้การตัดสินใจนี้เป็นหน้าที่ของ queue framework ทันทีที่คุณ catch ข้อผิดพลาดได้ ให้ตัดสินชะตากรรมของมันทันที

ในทางปฏิบัติ นี่หมายถึงการสร้าง custom error classes หรือ wrapper functions ที่ตรวจสอบความล้มเหลวก่อนที่จะส่ง error ต่อขึ้นไป (bubbling up) หาก database driver ส่ง error connection reset มา handler ของคุณควรระบุว่าเป็น retryable แต่หาก payload validator ส่ง error schema mismatch มา ให้ระบุว่าเป็น permanent ตัวประมวลผลงาน (job processors) หลายตัวมักจะตั้งค่าเริ่มต้นให้ลองใหม่กับทุกอย่าง ซึ่งเป็นการตัดสินใจที่สิ้นเปลืองที่สุด จงปฏิเสธงานที่เป็น permanent ทันที ไม่ว่าจะทิ้งไปเลยหรือส่งไปยัง dead-letter queue เพื่อไม่ให้มันไปทำลาย (poison) pipeline หลัก นิสัยเพียงอย่างเดียวนี้สามารถป้องกันผลกระทบแบบโดมิโน (snowball effects) ได้อย่างน่าเชื่อถือยิ่งกว่าการเปลี่ยนโครงสร้างพื้นฐานใดๆ

ถอยออกมา แต่ต้องฉลาดกว่าเดิม

เมื่อคุณต้องลองใหม่ อย่าทำทันที หากฐานข้อมูลล่ม การที่ worker จำนวนมากรุมกระหน่ำทุกวินาทีจะดูเหมือนการโจมตีแบบ denial-of-service จากภายใน จงใช้ exponential backoff รอหนึ่งนาที แล้วห้านาที แล้วสิบห้านาที เพื่อให้ระบบต้นทาง (upstream system) มีเวลาในการฟื้นตัว

แต่เพียงแค่ exponential backoff อย่างเดียวนั้นไม่พอ หากงานหนึ่งพันชิ้นล้มเหลวพร้อมกันเพราะบริการบางอย่างเพิ่ง restart ตารางเวลาการลองใหม่ของพวกมันจะตรงกันพอดี และพวกมันจะรุมโจมตีบริการนั้นพร้อมกันเมื่อมันกลับมาออนไลน์ ซึ่งอาจทำให้ระบบล่มอีกครั้ง จงเพิ่ม jitter: คือการสุ่มค่า offset เล็กน้อยในแต่ละช่วงเวลาที่รอ การกระจายการลองใหม่ออกไปในช่วงเวลาไม่กี่วินาทีจะช่วยป้องกันการรุมโจมตีพร้อมกัน (synchronized stampedes) คณิตศาสตร์นั้นเรียบง่าย แต่ความเสถียรที่ได้มานั้นมหาศาล

เก็บรักษาหลักฐาน

Dead-letter queue คือร่องรอยการตรวจสอบ (audit trail) ของคุณ ไม่ใช่ถังขยะ เมื่องานใช้จำนวนครั้งในการลองใหม่จนครบแล้ว อย่าเพียงแค่ลบมันทิ้ง แต่ให้ย้าย payload ทั้งหมด พร้อมกับบริบทของข้อผิดพลาด (error context) และประวัติการลองใหม่ไปยัง DLQ

สิ่งนี้ช่วยรักษาหลักฐาน มนุษย์สามารถตรวจสอบงาน แก้ไขบั๊ก และสั่งรันงานใหม่ (replay) ด้วยตนเองได้หากจำเป็น ที่สำคัญกว่านั้นคือ ให้เฝ้าติดตามจำนวนงานใน DLQ การเพิ่มขึ้นอย่างรวดเร็วของงานใน dead-letter มักเป็นสัญญาณเตือนล่วงหน้าถึงการ deploy ที่ผิดพลาด, การเปลี่ยน schema ที่ผิดพลาด หรือผู้ให้บริการภายนอก (external vendor) ทำผิดข้อตกลง จงมองว่าการเพิ่มขึ้นของ DLQ เป็นตัวบ่งชี้ล่วงหน้า (leading indicator) ไม่ใช่ตัวบ่งชี้ตามหลัง (trailing indicator) หาก DLQ ของคุณกำลังเต็ม แสดงว่ามีบางอย่างที่ระบบต้นทางเปลี่ยนไป และทีมของคุณจำเป็นต้องทราบก่อนที่งานค้าง (backlog) จะลุกลาม

ออกแบบเพื่อการลองใหม่

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

วิธีแก้ไขคือ idempotency ก่อนที่คุณจะดำเนินการใด ๆ ที่ส่งผลกระทบภายนอก (side effect) ให้ตรวจสอบก่อนว่าสิ่งนั้นได้เกิดขึ้นไปแล้วหรือยัง ใช้ unique identifier จาก job payload เป็น idempotency key แล้วจัดเก็บ key นั้นไว้ใน cache ระยะสั้น หรือในตารางฐานข้อมูลที่มี uniqueness constraint หากพบว่ามี key นี้อยู่แล้ว ให้ข้ามการทำงานนั้นไปและส่งผลลัพธ์กลับเป็นความสำเร็จ วิธีนี้จะเปลี่ยนการ retry จากความเสี่ยงให้กลายเป็นการทำงานที่ไม่มีผลกระทบ (no-op) แม้จะต้องเขียนโค้ดเพิ่มอีกไม่กี่บรรทัด แต่มันจะช่วยให้คุณไม่ต้องไปนั่งอธิบายกับฝ่ายการเงินว่าทำไมรายได้ถึงเพิ่มขึ้นเป็นสองเท่าเพียงชั่วข้ามคืน

Protect the Process

Unbounded promise rejections และ stray exceptions สามารถทำให้ Node.js process ตายได้โดยไม่มีการแจ้งเตือน สำหรับ worker นั่นหมายถึงงานที่หลุดหายไป และ orchestrator ที่ต้องรีบเร่งสั่ง restart container ใหม่

ลงทะเบียน global handlers สำหรับ unhandledRejection และ uncaughtException หน้าที่ของมันไม่ใช่การกู้คืนแอปพลิเคชัน แต่คือการทำความสะอาด (cleanup) ขั้นต่ำที่จำเป็น แล้วจึง exit ปล่อยให้ Docker, Kubernetes หรือ systemd เป็นผู้ restart worker ด้วยสถานะหน่วยความจำที่สะอาด การฝืนทำงานต่อไปหลังจาก global handler ทำงานจะนำไปสู่ปัญหา memory leaks และสถานะข้อมูลที่ผิดเพี้ยน (corrupted state) การตายอย่างรวดเร็วและสะอาดปลอดภัยกว่าการเป็น zombie process ที่ทำงานช้าและประมวลผลงานผิดพลาด จงเชื่อใจ orchestrator ในการดึงคุณกลับมา และอย่าพยายามฝืนสู้กับ runtime ที่เสียหายแล้ว

Respect the Signal

Worker จะถูกสั่งปิดระหว่างการ deployment, การทำ scaling และการหมุนเวียน node หาก process ของคุณตายทันทีที่ได้รับ SIGTERM คุณจะทำให้งานใด ๆ ที่กำลังดำเนินการอยู่ (in flight) ต้องหยุดชะงัก งานนั้นอาจไม่มีวันเสร็จสิ้น และตัวนับการ retry ของมันอาจจะยังไม่ทันได้เพิ่มขึ้นด้วยซ้ำ

คอยฟังสัญญาณ SIGTERM และ SIGINT เมื่อได้รับสัญญาณ ให้หยุดดึงงานใหม่จาก queue หากทำได้ ให้ทำงานปัจจุบันให้เสร็จสิ้น กำหนด hard timeout ไว้ เช่น สามสิบวินาที หลังจากนั้นให้ exit ไม่ว่ากรณีใดก็ตาม การทำ graceful shutdown แบบนี้เป็นการเคารพคิวงานและช่วยหลีกเลี่ยงความล้มเหลวที่ผิดพลาด (false failures) deployment pipeline ของคุณควรปฏิบัติกับ worker ที่ exit อย่างสะอาดว่าอยู่ในสถานะ healthy ในขณะที่ worker ที่ crash ควรจะส่งสัญญาณแจ้งเตือน

The Real Takeaway

การจัดการ queue ที่เชื่อถือได้ไม่ใช่เรื่องของการดักจับทุก error แต่มันคือการตัดสินใจอย่างรอบคอบสำหรับแต่ละรูปแบบความล้มเหลว (failure mode) จงอดทน retry สำหรับปัญหาชั่วคราว (transient errors) และจัดการปัญหาถาวรให้จบไปอย่างรวดเร็ว ปกป้อง worker ของคุณจาก stampedes, ปกป้องข้อมูลด้วย idempotency keys และปล่อยให้ process ที่กำลังจะตาย exit อย่างสะอาด เมื่อทุกความล้มเหลวมีเส้นทางที่กำหนดไว้ชัดเจน เวลาตีสามก็จะกลายเป็นเพียงชั่วโมงธรรมดาอีกชั่วโมงหนึ่งเท่านั้น Pipeline ของคุณจะยังคงเดินหน้าต่อไป และทีมของคุณก็ยังคงได้นอนหลับพักผ่อน