ข้ามไปยังเนื้อหา

Acks, Retry & Dead Letters

ไม่มีโค้ดใหม่ — Exchanges & Queues → เขียน Qos call ของ Client.Connect, dead-letter arguments ของ DeclareTopology, และ ack/nack logic ของ Client.Consume ไว้แล้ว แล้วใช้ทั้งสามอย่างโดยไม่ได้อธิบายว่าทำไมถึงมีรูปแบบแบบนั้น บทนี้จะเรียกชื่อแต่ละอย่างให้ชัด แล้วใช้ flag -fail ของ cmd/rabbitmqdemo/consume ที่เพิ่มไว้แล้วแต่ยังไม่ได้ใช้ในบทก่อน มาบังคับให้ delivery หนึ่งตัว fail เพื่อดูว่าไปถึง notification.send.dead จริง ๆ

Consume เรียก ch.Consume โดยตั้ง autoAck เป็น false flag ตัวเดียวนี้คือความต่างระหว่างสองโมเดลการส่งข้อความที่ต่างกันโดยสิ้นเชิง: ถ้า autoAck=true RabbitMQ จะถือว่าข้อความถูกจัดการแล้วทันทีที่ส่ง byte ให้ client library ก่อนที่ handle จะรันแม้แต่บรรทัดเดียว — ถ้า crash ระหว่างส่งข้อความกับการประมวลผล ข้อความหายไปโดยไม่มีร่องรอย ถ้า autoAck=false ข้อความจะยังคง unacknowledged — ยังอยู่ใน queue ในทางตรรกะ แค่มองไม่เห็นสำหรับ consumer อื่น — จนกว่าโค้ดนี้จะเรียก Ack หรือ Nack อย่างชัดเจน ถ้า process crash ขณะที่ยังมี delivery ที่ unacked ค้างอยู่ RabbitMQ จะสังเกตเห็นว่า connection หลุดแล้ว redeliver ให้ consumer ตัวอื่น นี่คือสิ่งที่ทำให้ RabbitMQ job ในระบบนี้เป็น at-least-once การรับประกันแบบเดียวกับที่ consumer แบบ manual-commit ของ Kafka ใน Producer & Consumer → ทำ ด้วยเหตุผลเดียวกันเป๊ะ ๆ: การทำ notification.send job หายไปเงียบ ๆ แย่กว่าการส่ง notification ซ้ำเป็นบางครั้ง นั่นคือเหตุผลที่ข้อกำหนด idempotent-consumer ของ Architecture → ใช้ได้กับที่นี่เหมือนกับที่ใช้ได้กับ Kafka

Connect เรียก ch.Qos(prefetchCount, 0, false) โดยตั้ง prefetchCount เป็น 10 prefetch จำกัดจำนวน unacked delivery สูงสุดที่ consumer หนึ่งตัวถือได้พร้อมกัน RabbitMQ จะหยุด push ข้อความใหม่ทันทีที่ consumer ถือครบจำนวนนั้น แล้วกลับมาส่งต่อก็ต่อเมื่อมีบางส่วน ack หรือ nack ไปแล้ว

ถ้าไม่มีขีดจำกัด RabbitMQ จะ push ข้อความไปยัง consumer ตัวที่ส่งถึงได้เร็วที่สุด ในทางปฏิบัติแปลว่า consumer ที่ยุ่งอยู่แล้วตัวเดียวอาจถือ unacked message เป็นพัน ๆ ตัว ส่วน consumer ที่ว่างอยู่ข้าง ๆ ไม่ได้งานเลยสักชิ้น ตรงข้ามกับพฤติกรรม load balancing แบบ “competing consumers” ที่ Exchanges & Queues → ระบุไว้ว่าเป็นเหตุผลทั้งหมดที่ RabbitMQ มีอยู่ prefetch เล็ก ๆ อย่าง 10 หรือแม้แต่ 1 จึงยอมแลก throughput ต่อ consumer ไปเล็กน้อย เพื่อให้ dispatch แฟร์จริงระหว่าง worker ทุกตัวที่ consume queue เดียวกัน

ทีนี้มาดู failure path เมื่อ handle คืนค่า error Consume จะเรียก d.Nack(false, false) โดย argument ตัวที่สองคือ requeue เป็น false

จะเขียน Nack(false, true) เพื่อ requeue ข้อความกลับไปที่ queue เดิม แล้วลองใหม่ทันทีก็ดูง่ายกว่า แต่อย่าทำ ถ้าความล้มเหลวเป็นสิ่งที่ข้อความนี้กระตุ้นให้เกิดเสมอ — JSON ที่ผิดรูปแบบ, API ปลายทางที่จะปฏิเสธ payload ตัวนี้ตลอดไป, field ที่เป็น nil ซึ่ง handler ไม่เคยเช็ค — requeue=true จะส่งข้อความพิษตัวเดิมเป๊ะ ๆ กลับไปที่หน้า queue แล้ว consumer ตัวใดตัวหนึ่งก็ดึงมาอีก fail อีก requeue อีก วนไม่จบ

loop นั้นเผา CPU และถล่ม log โดยไม่มีความคืบหน้าเลย และถ้า prefetch เล็ก ยังบล็อกข้อความอื่นที่ต่อคิวอยู่ข้างหลังข้อความพิษได้ด้วย เพราะ consumer ที่ติดอยู่กับการ retry ไม่มีวันได้เห็นข้อความเหล่านั้น requeue=false ร่วมกับ dead-letter arguments ของ DeclareTopology (x-dead-letter-exchange: shopmicro.dlx, x-dead-letter-routing-key: notification.send) จะ route ข้อความที่ nack แล้วไปที่ notification.send.dead แทน — ออกจาก retry loop ไปเลย ไปอยู่ในที่ที่คนหรือเครื่องมือ reprocess แยกต่างหากเข้ามาดูได้ ส่วนข้อความที่ต่อคิวอยู่ข้างหลังก็ไหลต่อไปตามปกติ

ถึงอย่างนั้น การ dead-letter ตั้งแต่ความล้มเหลวครั้งแรกก็เป็นนโยบายที่ง่ายที่สุดเท่าที่จะเป็นได้ ไม่ใช่นโยบายเดียวที่สมเหตุสมผล — ความล้มเหลวชั่วคราว เช่น ผู้ให้บริการอีเมลปลายทาง timeout ไปครั้งหนึ่ง อาจสำเร็จได้จริงถ้าลองใหม่อีกไม่กี่วินาทีถัดมา การ dead-letter ถาวรหลัง fail ครั้งเดียวจึงเท่ากับทิ้ง job นั้นไปเลย

มีสองรูปแบบระดับ production ที่ควรรู้ ทั้งคู่ยังไม่ได้ทำใน pkg/amqp วันนี้ อย่างแรกคือ TTL + DLX cycling ที่ข้อความซึ่ง nack แล้วจะ dead-letter เข้า queue พัก ซึ่งมี message TTL สั้น ๆ และไม่มี consumer จากนั้น dead-letter arguments ของ queue พักจะชี้กลับไปที่ exchange เดิม พอ TTL หมดอายุ RabbitMQ จะ dead-letter อีกครั้ง ซึ่งพาข้อความกลับไปที่ notification.send เพื่อลองใหม่ ได้พฤติกรรม delay แล้วค่อย retry โดยไม่ต้องเขียนโค้ดแอปพลิเคชันมา poll เลย

อย่างที่สองคือ retry-count header ที่ consumer อ่าน header x-death ซึ่ง RabbitMQ ติดมาให้อัตโนมัติทุกครั้งที่ dead-letter โดยบันทึกไว้ว่ากี่ครั้งและเพราะอะไร หรือจะเก็บ counter ของตัวเองก็ได้ แล้วค่อยยอมแพ้จริง ๆ ด้วยการ dead-letter ไปยัง queue ที่ไม่มีใคร retry อีก ก็ต่อเมื่อลองครบ N ครั้งแล้วเท่านั้น ทั้งสองแบบเป็นชิ้นส่วน infrastructure แยกต่างหากที่ตั้งใจเพิ่มทับการ dead-letter แบบ one-strike ตรง ๆ ที่บทนี้ยืนยันไว้ notification.send ยังไม่ต้องใช้ แต่ queue ในอนาคตที่มี downstream dependency ไม่เสถียรกว่านี้อาจจำเป็น

Nack(false, false) → dead-letter queue (สิ่งที่ Consume ทำ) เทียบกับ Nack(false, true) → requeue

  • Pros: ข้อความที่ไม่มีวันสำเร็จจะหลุดออกจาก queue ที่ใช้งานจริงตั้งแต่ล้มเหลวครั้งแรก จึงไม่มีทางบล็อกหรือทำให้ข้อความที่ต่อคิวอยู่ข้างหลังอดอยาก แถมยังไปนอนอยู่ในที่ที่มองเห็นได้อย่าง notification.send.dead แทนที่จะหายไปใน retry loop ที่ไม่มีใครเห็น
  • Cons: ข้อความที่ fail ด้วยเหตุผลชั่วคราวล้วน ๆ อย่าง downstream timeout ก็โดน dead-letter ตั้งแต่ครั้งแรกเหมือนกัน โดยไม่มีการ retry อัตโนมัติ การกู้คืนจึงต้องรอให้คนหรือเครื่องมือแยกต่างหากไปเห็นใน dead-letter queue แล้ว republish เอง เว้นแต่จะเพิ่ม TTL+DLX cycling หรือ retry-count header เข้ามาให้ลองอัตโนมัติสักไม่กี่ครั้งก่อน

Prefetch เล็ก (10) เทียบกับ Prefetch ใหญ่หรือไม่จำกัด

  • Pros: prefetch เล็กรักษาความแฟร์ของ dispatch ระหว่าง competing consumer ทุกตัวของ queue เดียวกัน โดยไม่มี consumer ตัวไหนกักงานไว้คนเดียวขณะที่ตัวอื่นว่าง และยังจำกัดด้วยว่า consumer ที่ crash หนึ่งตัวจะทำงานที่ยัง in-flight และไม่ ack หายไปพร้อมกันได้มากที่สุดกี่ชิ้น
  • Cons: prefetch เล็กแปลว่ามี round trip ระหว่าง broker กับ consumer มากขึ้นเมื่อเทียบกับ prefetch ใหญ่ ซึ่งกด throughput สูงสุดของ consumer ตัวเดียวให้ต่ำกว่าที่ทำได้ถ้ามี buffer งานที่ prefetch ไว้ล่วงหน้ามากกว่านี้ นี่คือการแลกจริงกับความแฟร์ ไม่ใช่ของฟรี

ไม่มีอะไรใหม่ต้องเขียน — สองส่วนที่บทนี้พูดถึงอยู่ใน pkg/amqp/amqp.go จาก Exchanges & Queues → แล้ว คุ้มค่าที่จะกลับไปอ่านอีกครั้งด้วยมุมมองของบทนี้:

if err := ch.Qos(prefetchCount, 0, false); err != nil {
ch.Close()
conn.Close()
return nil, fmt.Errorf("amqp: set qos: %w", err)
}
for d := range deliveries {
if err := handle(context.Background(), d.Body); err != nil {
log.Printf("amqp: handle delivery from %s: %v — dead-lettering", queue, err)
if nackErr := d.Nack(false, false); nackErr != nil {
return fmt.Errorf("amqp: nack delivery: %w", nackErr)
}
continue
}
if err := d.Ack(false); err != nil {
return fmt.Errorf("amqp: ack delivery: %w", err)
}
}

และ flag ของ cmd/rabbitmqdemo/consume/main.go ที่ประกาศไว้แล้วแต่ยังไม่จำเป็นต้องใช้จนถึงตอนนี้:

fail := flag.Bool("fail", false, "always fail, forcing every delivery to be dead-lettered")

ตรวจสอบว่า container RabbitMQ ของ Module 1 รันอยู่:

Terminal window
cd deploy/compose && docker compose up -d rabbitmq

รัน consumer ที่ fail เสมอ:

Terminal window
go run ./cmd/rabbitmqdemo/consume -fail

เทอร์มินัลที่สอง publish demo job — ใช้ publisher ตัวเดิมจากบทก่อน ไม่ได้แก้อะไรเลย:

Terminal window
go run ./cmd/rabbitmqdemo/publish
published: notification.send

กลับไปที่เทอร์มินัลของ consumer ที่ fail: handle รัน log ข้อความ คืนค่า error แล้ว Consume ก็ nack โดยไม่ requeue:

consumed from notification.send: {"to":"customer@example.com","message":"Your order has shipped"}
amqp: handle delivery from notification.send: simulated handler failure — dead-lettering

หยุด consumer ตัวนั้นด้วย Ctrl-C ข้อความไม่ได้อยู่บน notification.send แล้ว แต่ย้ายไปอยู่บน notification.send.dead ลองดึงออกมาจาก dead-letter queue ด้วยโปรแกรมเดิม เพียงแค่ชี้ไปที่ dead queue แทน:

Terminal window
go run ./cmd/rabbitmqdemo/consume -queue notification.send.dead
consumed from notification.send.dead: {"to":"customer@example.com","message":"Your order has shipped"}

job ตัวเดียวกันเป๊ะ ๆ ที่ fail บน notification.send มาถึง notification.send.dead จริง ๆ เหมือนกันทุก byte คุณยืนยันสิ่งเดียวกันแบบภาพได้ที่ management UI http://localhost:15672Queues and Streamsnotification.send.deadGet messages ที่เป็นการตรวจสอบแบบเดียวกับที่ operator ใน production จะทำเพื่อตัดสินใจว่าจะแก้แล้ว republish job ที่ dead-letter ไปหรือไม่

จากนั้นยืนยันว่า module ยัง build ผ่าน:

Terminal window
go build ./...

ไม่มี output แปลว่าสำเร็จ

ตรวจสอบความเข้าใจของคุณ:

  • ทำไมการเปลี่ยน Nack(false, false) เป็น Nack(false, true) ใน Consume ถึงเสี่ยงต่อการเกิด loop ไม่จบสิ้นสำหรับข้อความที่ fail เสมอ แต่ไม่เสี่ยงสำหรับข้อความที่ fail แค่ครั้งเดียว?
  • retry-count header หรือ TTL+DLX cycling แก้ปัญหาอะไรที่การ dead-letter แบบ one-strike ตรง ๆ แก้ไม่ได้?
  • ทำไม prefetchCount เล็ก ๆ ถึงสำคัญกว่าเมื่อ queue มี competing consumer หลายตัว เทียบกับตอนที่มี consumer แค่ตัวเดียวตลอด?

autoAck=false บวกกับ Ack/Nack ที่เรียกเอง คือสิ่งที่ทำให้ RabbitMQ job ในระบบนี้เป็น at-least-once เหมือนกับ consumer แบบ manual-commit ของ Kafka เป๊ะ ๆ — และเป็นเหตุผลว่าทำไม handler ทุกตัวที่สร้างบน Consume ต้องเป็น idempotent Qos(prefetchCount, 0, false) จำกัดว่า consumer หนึ่งตัวถือ unacked delivery ได้กี่ตัว รักษาความแฟร์ของ dispatch ระหว่าง competing consumer ทุกตัวของ queue เดียวกัน Nack(false, false) ส่ง delivery ที่ล้มเหลวไปที่ notification.send.dead ผ่าน dead-letter arguments ที่ DeclareTopology ตั้งไว้แล้ว เราตั้งใจไม่ใช้ requeue=true เพราะเสี่ยงวนข้อความพิษไม่จบสิ้น แทนที่จะดึงปัญหาออกมาให้เห็น

TTL+DLX cycling กับ retry-count header คือสองวิธีมาตรฐานในการเพิ่ม delayed retry อัตโนมัติทับกลไกนี้ เหมาะกับ queue ที่ความล้มเหลวชั่วคราวเกิดบ่อยพอจะคุ้มกับการลองอัตโนมัติสักไม่กี่ครั้งก่อนยอมแพ้จริง ๆ เรายังไม่ได้ทำที่นี่ แต่ควรรู้ไว้ก่อนถึงตอนที่ Notification ใน Module 10 จะต้องใช้ บทนี้ปิด Module 7: Payment Service → กับ Notification Service → ทั้งคู่จะสร้างเซอร์วิสจริงทับ pkg/kafka และ pkg/amqp ในสภาพที่เป็นอยู่วันนี้เป๊ะ ๆ