Triggers กับ background job workers สำหรับการแจ้งเตือนที่เชื่อถือได้
เรียนรู้ว่าเมื่อไหร่ที่การใช้ trigger หรือ background worker ปลอดภัยกว่าสำหรับการแจ้งเตือน พร้อมแนวทางปฏิบัติจริงเกี่ยวกับ retry, transaction และการป้องกันการส่งซ้ำ

ทำไมการส่งการแจ้งเตือนจึงพังก่อนจะถึงผู้ใช้ในแอปจริง ๆ
การแจ้งเตือนฟังดูเรียบง่าย: ผู้ใช้ทำการบางอย่าง แล้วส่งอีเมลหรือ SMS ออกไป ความล้มเหลวส่วนใหญ่เกิดจากจังหวะเวลาและการซ้ำ ข้อความถูกส่งก่อนข้อมูลจะถูกบันทึกจริง ๆ หรือถูกส่งสองครั้งหลังเกิดความล้มเหลวบางส่วน
“การแจ้งเตือน” อาจเป็นหลายอย่าง: ใบเสร็จทางอีเมล, รหัสครั้งเดียวจาก SMS, การแจ้งเตือน push, ข้อความในแอป, พิงก์ไปที่ Slack หรือ Telegram, หรือ webhook ไปยังระบบอื่น ปัญหาร่วมกันคือคุณพยายามประสานการเปลี่ยนแปลงฐานข้อมูลกับบางอย่างนอกแอปของคุณ
โลกภายนอกไม่ได้เรียบร้อย ผู้ให้บริการช้า ตอบ timeout หรือรับคำขอไว้โดยที่แอปของคุณไม่ได้รับการตอบกลับว่าประสบความสำเร็จ แอปของคุณเองอาจ crash หรือเริ่มใหม่กลางคำขอ แม้การส่งที่ “สำเร็จ” ก็อาจถูกรันซ้ำเพราะการ retry ของโครงสร้างพื้นฐาน การรีสตาร์ท worker หรือผู้ใช้กดปุ่มอีกครั้ง
สาเหตุทั่วไปของการส่งแจ้งเตือนที่พังรวมถึง timeout เครือข่าย, ผู้ให้บริการล่มหรือจำกัดอัตรา, รีสตาร์ทแอปในช่วงเวลาที่ไม่เหมาะสม, retry ที่รัน logic เดิมซ้ำโดยไม่มีการป้องกันคีย์เฉพาะ, และการออกแบบที่ผสานการเขียนฐานข้อมูลกับการส่งภายนอกเป็นขั้นตอนเดียว
เมื่อคนขอ “การแจ้งเตือนที่เชื่อถือได้” พวกเขามักหมายถึงสองอย่างใดอย่างหนึ่ง:
- ส่งให้ได้ครั้งเดียวเท่านั้น (exactly once), หรือ
- อย่างน้อยอย่าส่งซ้ำ (duplicates มักแย่กว่าการหน่วง)
การได้ทั้งเร็วและปลอดภัยสมบูรณ์นั้นยาก จึงต้องแลกเปลี่ยนระหว่างความเร็ว ความปลอดภัย และความซับซ้อน
นี่จึงไม่ใช่แค่การถกเถียงสถาปัตยกรรมระหว่าง triggers กับ background workers แต่มันเกี่ยวกับว่าเมื่อใดที่อนุญาตให้ส่งได้ การจัดการเมื่อเกิดความล้มเหลวอย่างไร และป้องกันอีเมลหรือ SMS ซ้ำอย่างไรเมื่อเกิดปัญหา
Triggers และ background workers หมายถึงอะไร
เมื่อต่างคนเปรียบเทียบ triggers กับ background job workers จริง ๆ แล้วพวกเขากำลังเปรียบเทียบว่า logic ของการแจ้งเตือนทำงานที่ไหนและผูกแน่นกับการกระทำนั้นแค่ไหน
Trigger คือ “ทำเดี๋ยวนั้นเมื่อ X เกิดขึ้น” ในหลายแอปหมายถึงส่งอีเมลหรือ SMS ทันทีหลังการกระทำของผู้ใช้ ภายในคำขอเว็บเดียวกัน Triggers ยังอาจอยู่ในระดับฐานข้อมูล: database trigger ทำงานอัตโนมัติเมื่อมีการแทรกหรืออัพเดตแถว ทั้งสองประเภทให้ความรู้สึกทันที แต่จะสืบทอดจังหวะเวลาและข้อจำกัดของสิ่งที่เรียกใช้งานมัน
Background worker คือ “ทำเร็ว ๆ นี้ แต่ไม่ใช่ในหน้าแรก” มันเป็น process แยกที่ดึงงานจากคิวและพยายามทำให้เสร็จ แอปหลักของคุณบันทึกสิ่งที่ควรทำแล้วคืนเร็ว ในขณะที่ worker ดูแลส่วนที่ช้าหรือเกิดความล้มเหลวง่ายเช่นเรียกผู้ให้บริการอีเมลหรือ SMS
“งาน” (job) คือหน่วยงานที่ worker ประมวลผล มักรวมว่าใครจะได้รับแจ้ง, เทมเพลตไหน, ข้อมูลอะไร, สถานะปัจจุบัน (queued, processing, sent, failed), จำนวนครั้งที่พยายาม, และบางทีก็กำหนดเวลาที่จะทำ
โฟลว์การแจ้งเตือนทั่วไปคือ: เตรียมรายละเอียดข้อความ, ใส่คิวงาน, ส่งผ่านผู้ให้บริการ, บันทึกผล แล้วตัดสินใจว่าจะ retry, หยุด, หรือตั้งเตือนคนดูแล
ขอบเขต transaction: เมื่อไหร่จึงปลอดภัยจริง ๆ ที่จะส่ง
ขอบเขต transaction คือเส้นแบ่งระหว่าง “เราพยายามบันทึกแล้ว” กับ “มันถูกบันทึกจริง ๆ” จนกว่าการ commit จะเกิดขึ้น การเปลี่ยนแปลงยังสามารถ rollback ได้ นั่นสำคัญเพราะการแจ้งเตือนยากที่จะเรียกคืน
ถ้าคุณส่งอีเมลหรือ SMS ก่อน commit คุณอาจส่งข้อความถึงใครบางคนเกี่ยวกับสิ่งที่ไม่เคยเกิดขึ้น ลูกค้าอาจได้ข้อความว่า “รหัสผ่านของคุณถูกเปลี่ยนแล้ว” หรือ “คำสั่งซื้อของคุณยืนยันแล้ว” แล้วการเขียนอาจล้มเหลวเพราะข้อผิดพลาดข้อจำกัดหรือ timeout ตอนนี้ผู้ใช้สับสนและซัพพอร์ตต้องแก้ปัญหา
การส่งจากภายใน database trigger น่าดึงดูดเพราะมันทำงานทันทีเมื่อข้อมูลเปลี่ยน แต่อุปสรรคคือ trigger ทำงานภายใน transaction เดียวกัน หาก transaction ถูก rollback คุณอาจได้เรียกผู้ให้บริการอีเมลหรือ SMS ไปแล้ว
Triggers ของฐานข้อมูลยังมักยากต่อการสังเกต ทดสอบ และ retry อย่างปลอดภัย และเมื่อพวกมันเรียกภายนอกช้า จะทำให้ล็อกถูกยืดนานกว่าที่คาดและทำให้การวินิจฉัยปัญหาฐานข้อมูลยากขึ้น
แนวทางที่ปลอดภัยกว่าคือแนวคิด outbox: บันทึกเจตนาที่จะส่งเป็นข้อมูล commit แล้วจึงส่งหลัง commit
คุณทำการเปลี่ยนแปลงทางธุรกิจและใน transaction เดียวกันแทรกแถว outbox ที่อธิบายข้อความ (ผู้รับ, เนื้อหา, ช่องทาง และคีย์เฉพาะ) หลัง commit worker อ่านแถว outbox ที่รอดำเนินการ ส่งข้อความ แล้วทำเครื่องหมายว่าได้ส่งแล้ว
การส่งทันทียังพอรับได้สำหรับข้อความสารสนเทศผลกระทบต่ำที่ผิดพลาดได้ เช่น “เรากำลังดำเนินการคำขอของคุณ” แต่สำหรับสิ่งที่ต้องตรงกับสถานะสุดท้าย ให้รอจนกว่าจะ commit แล้วค่อยส่ง
การ retry และการจัดการความล้มเหลว: วิธีไหนได้เปรียบ
การ retry มักเป็นปัจจัยตัดสิน
Triggers: เร็ว แต่เปราะเมื่อเกิดความล้มเหลว
การออกแบบแบบ trigger มักไม่มีเรื่องการ retry ที่ดี
ถ้า trigger เรียกผู้ให้บริการอีเมล/SMS แล้วการเรียกล้มเหลว คุณมักมีสองทางเลือกที่แย่:
- ให้ transaction ล้มเหลว (และบล็อกการอัพเดตเดิม), หรือ
- ดันข้อผิดพลาดทิ้ง (และเงียบ ๆ สูญเสียการแจ้งเตือน)
ทั้งสองอย่างยอมรับไม่ได้เมื่อความน่าเชื่อถือสำคัญ
การพยายามวนหรือล่าช้าภายใน trigger อาจทำให้แย่ลงโดยการเปิด transaction นานขึ้น เพิ่มเวลาล็อก และชะลอฐานข้อมูล และถ้าฐานข้อมูลหรือแอปล้มกลางการส่ง คุณมักจะไม่รู้ว่าผู้ให้บริการได้รับคำขอหรือไม่
Background workers: ออกแบบมาเพื่อ retry
Worker ถือการส่งเป็นงานแยกที่มีสถานะของตัวเอง ซึ่งทำให้การ retry เป็นเรื่องธรรมชาติ
โดยปฏิบัติ คุณควร retry กับความล้มเหลวชั่วคราว (timeout, ปัญหาเครือข่ายชั่วคราว, ข้อผิดพลาดเซิร์ฟเวอร์, rate limit ที่ต้องรอนานขึ้น) แต่ไม่ควร retry กับปัญหาถาวร (หมายเลขโทรศัพท์ไม่ถูกต้อง, อีเมลรูปแบบผิด, ปฏิเสธแบบถาวรอย่างการยกเลิกการรับ) สำหรับข้อผิดพลาด “ไม่ทราบ” ให้จำกัดจำนวนครั้งและทำให้สถานะมองเห็นได้
Backoff ป้องกันไม่ให้การ retry ทำให้สถานการณ์แย่ลง เริ่มด้วยการรอสั้น ๆ แล้วเพิ่มขึ้นในแต่ละครั้ง (เช่น 10s, 30s, 2m, 10m) และหยุดหลังจำนวนครั้งที่กำหนด
เพื่อให้รอดจากการ deploy และ restart ให้เก็บสถานะ retry กับแต่ละงาน: นับ attempts, เวลาที่จะพยายามครั้งถัดไป, ข้อผิดพลาดล่าสุด (สั้นและอ่านเข้าใจได้), เวลาของการพยายามครั้งล่าสุด และสถานะชัดเจนเช่น pending, sending, sent, failed
ถ้าแอปของคุณรีสตาร์ทกลางการส่ง worker สามารถตรวจงานติดค้างได้ (เช่น status = sending ที่มี timestamp เก่า) แล้ว retry อย่างปลอดภัย นี่คือที่ idempotency สำคัญเพื่อให้การ retry ไม่ส่งซ้ำ
ป้องกันการส่งซ้ำด้วย idempotency
Idempotency หมายความว่าคุณสามารถรันการกระทำ “ส่งการแจ้งเตือน” เดิมได้หลายครั้งและผู้ใช้ยังได้รับเพียงครั้งเดียว
กรณีซ้ำคลาสสิกคือ timeout: แอปของคุณเรียกผู้ให้บริการอีเมลหรือ SMS, คำขอ timeout และโค้ดของคุณ retry คำขอแรกอาจสำเร็จแล้ว การ retry ก็สร้างการซ้ำ
การแก้ที่ปฏิบัติได้คือให้แต่ละข้อความมีคีย์คงที่และถือคีย์นั้นเป็นแหล่งความจริงเดียว คีย์ที่ดีอธิบายความหมายของข้อความ ไม่ใช่เวลาที่คุณพยายามส่ง
วิธีที่ใช้บ่อยได้แก่:
notification_idที่สร้างเมื่อคุณตัดสินใจว่า “ข้อความนี้ต้องมี”, หรือ- คีย์จากธุรกิจเช่น
order_id + template + recipient(ถ้านั่นนิยามความเป็นเอกลักษณ์จริง ๆ)
จากนั้นเก็บ ledger การส่ง (มักคือตาราง outbox เอง) และให้การ retry ตรวจสอบก่อนส่ง รักษาสถานะให้เรียบง่ายและมองเห็นได้: created (ตัดสินใจแล้ว), queued (พร้อม), sent (ยืนยันได้), failed (ล้มเหลวยืนยัน), canceled (ไม่ต้องการอีกต่อไป) กฎสำคัญคืออนุญาตเพียงหนึ่งระเบียนใช้งานต่อ idempotency key
idempotency ฝั่งผู้ให้บริการช่วยได้เมื่อรองรับ แต่ไม่ทดแทน ledger ของคุณเอง คุณยังต้องจัดการ retry, การ deploy และการรีสตาร์ท worker
นอกจากนี้ให้ปฏิบัติต่อผลลัพธ์ “ไม่ทราบ” เป็นกรณีพิเศษ หากคำขอ timeout อย่าส่งซ้าทันที ทำเครื่องหมายเป็นรอการยืนยันและ retry อย่างปลอดภัยโดยตรวจสถานะการส่งจากผู้ให้บริการเมื่อเป็นไปได้ ถ้าไม่สามารถยืนยันได้ ให้หน่วงเวลาและเตือนแทนการส่งซ้ำ
รูปแบบค่าเริ่มต้นที่ปลอดภัย: outbox + background worker (ทีละขั้น)
ถ้าคุณต้องการค่าเริ่มต้นที่ปลอดภัย รูปแบบ outbox พร้อม worker ยากที่จะถูกเอาชนะ มันแยกการส่งออกจาก transaction ทางธุรกิจ ในขณะที่ยังรับประกันว่าเจตนาที่จะส่งถูกบันทึก
โฟลว์
ถือว่า “ส่งการแจ้งเตือน” เป็นข้อมูลที่คุณเก็บไว้ ไม่ใช่การกระทำที่คุณยิงออกไป
คุณบันทึกการเปลี่ยนแปลงธุรกิจ (เช่น การอัพเดตสถานะคำสั่งซื้อ) ในตารางปกติของคุณ ใน transaction เดียวกันคุณแทรกแถว outbox ที่มีผู้รับ, ช่องทาง (email/SMS), เทมเพลต, payload, และ idempotency key แล้ว commit transaction หลังจากจุดนั้นเท่านั้นที่อนุญาตให้มีการส่งเกิดขึ้น
background worker จะคอยหยิบแถว outbox ที่รอดำเนินการ ส่งข้อความ แล้วบันทึกผล
เพิ่มขั้นตอนการ claim แบบง่ายเพื่อไม่ให้สอง worker จับแถวเดียวกันพร้อมกัน วิธีนี้อาจเป็นการเปลี่ยนสถานะเป็น processing หรือบันทึก timestamp ที่ล็อก
ป้องกันการซ้ำและจัดการความล้มเหลว
การซ้ำมักเกิดเมื่อการส่งสำเร็จแต่แอปคุณล้มก่อนบันทึกว่า “sent” แก้โดยทำให้การเขียนว่า “ส่งแล้ว” ทำได้ซ้ำปลอดภัย
ใช้กฎความเป็นเอกลักษณ์ (เช่น unique constraint บน idempotency key และช่องทาง) Retry ด้วยกฎชัดเจน: จำกัด attempts, เพิ่มความหน่วงทีละขั้น, และ retry เฉพาะข้อผิดพลาดที่ retry ได้ หลังจากลองครั้งสุดท้าย ย้ายงานไปสถานะ dead-letter (เช่น failed_permanent) เพื่อให้คนมาดูและประมวลผลด้วยมือ
การมอนิเตอร์ทำได้เรียบง่าย: นับ pending, processing, sent, retrying, failed_permanent และดู timestamp ของรายการ pending ที่เก่าแก่ที่สุด
ตัวอย่างชัดเจน: เมื่อคำสั่งซื้อเปลี่ยนจาก “Packed” เป็น “Shipped” คุณอัพเดตแถว order และสร้างแถว outbox หนึ่งรายการพร้อม idempotency key order-4815-shipped ถึงแม้ worker จะ crash กลางส่ง การรันซ้ำจะไม่ส่งซ้ำเพราะการเขียนว่า “sent” ถูกป้องกันด้วยคีย์เฉพาะนั้น
เมื่อใดที่ background workers เหมาะสมกว่า
Database triggers เหมาะกับการตอบสนองทันทีเมื่อข้อมูลเปลี่ยน แต่ถ้างานคือ “ส่งการแจ้งเตือนอย่างเชื่อถือภายใต้สภาพโลกจริงที่ยุ่งเหยิง” workers มักให้การควบคุมมากกว่า
Workers เหมาะเมื่อคุณต้องการส่งตามเวลา (reminders, digests), ปริมาณสูงพร้อมการจำกัดอัตราและ backpressure, ทนต่อความผันผวนของผู้ให้บริการ (ข้อจำกัด 429, การตอบช้าหรือ outage สั้น ๆ), workflow หลายขั้น (ส่ง, รอการส่ง, แล้วติดตาม), หรือเหตุการณ์ข้ามระบบที่ต้อง reconcile
ตัวอย่างง่าย: คุณเรียกเก็บเงินลูกค้า แล้วส่งใบเสร็จ SMS แล้วค่อยส่งอีเมลใบแจ้งหนี้ ถ้า SMS ล้มเพราะเกตเวย์ คุณยังต้องการให้คำสั่งซื้อยังคงถูกชำระและ retry ปลอดภัยทีหลัง การใส่ logic นั้นใน trigger เสี่ยงผสมผสาน “ข้อมูลถูกต้อง” กับ “มีผู้ให้บริการพร้อมใช้งานในตอนนี้”
Workers ยังทำให้การควบคุมเชิงปฏิบัติการง่ายขึ้น คุณสามารถหยุดคิวระหว่างเหตุการณ์, ตรวจสอบความล้มเหลว, และ retry ด้วยความหน่วง
ข้อผิดพลาดทั่วไปที่ทำให้หายหรือซ้ำข้อความ
วิธีที่เร็วที่สุดที่จะได้การแจ้งเตือนที่ไม่เชื่อถือได้คือ “ส่งมันเลย” ทุกที่ที่สะดวก แล้วหวังให้ retry ช่วย ไม่ว่าคุณจะใช้ triggers หรือ workers รายละเอียดรอบการจัดการสถานะและความล้มเหลวจะตัดสินว่าผู้ใช้จะได้หนึ่งข้อความ สองข้อความ หรือไม่มีเลย
กับ triggers มักมีสมมติฐานผิด ๆ ว่าไม่สามารถล้มได้ Triggers ทำงานใน transaction ดังนั้นการเรียกผู้ให้บริการช้าที่ไม่คาดคิดอาจทำให้การเขียนติดขัด, เกิด timeout, หรือทำให้ตารางล็อกนานเกินไป แย่กว่านั้นถ้าการส่งล้มและคุณ rollback แล้วพยายามใหม่ทีหลัง คุณอาจส่งสองครั้งถ้าผู้ให้บริการอาจยอมรับคำขอครั้งแรก
ข้อผิดพลาดที่พบซ้ำ ๆ ได้แก่:
- retry ทุกอย่างแบบเดียวกัน รวมทั้งข้อผิดพลาดถาวร (อีเมลไม่ถูกต้อง, เบอร์ถูกบล็อก)
- ไม่แยกระหว่าง “queued” กับ “sent” จึงไม่รู้ว่าปลอดภัยที่จะ retry หลัง crash หรือไม่
- ใช้ timestamp เป็นคีย์ dedupe ทำให้การ retry ข้าม uniqueness ได้ง่าย
- เรียกผู้ให้บริการใน path ของคำขอผู้ใช้ (checkout และการส่งฟอร์มไม่ควรรอ gateway)
- ถือว่า timeout ของ provider เป็น “ไม่ได้ส่ง” ในขณะที่หลายครั้งเป็น “ไม่ทราบ” มากกว่า
ตัวอย่างง่าย: คุณส่ง SMS, provider timeout, และคุณ retry ถ้าคำขอแรกจริง ๆ แล้วส่งสำเร็จ ผู้ใช้จะได้รหัสสองครั้ง แก้โดยบันทึก idempotency key คงที่ (เช่น notification_id), ทำเครื่องหมายว่า queued ก่อนส่ง แล้วทำเครื่องหมายว่า sent เมื่อได้รับการตอบกลับที่ชัดเจนว่าประสบความสำเร็จ
เช็ครวดเร็วก่อนปล่อยการแจ้งเตือน
บั๊กการแจ้งเตือนส่วนใหญ่ไม่ใช่เรื่องเครื่องมือ แต่เป็นเรื่องจังหวะเวลา, retry, และการบันทึกที่ขาดหาย
ยืนยันว่าคุณส่งหลังการเขียนฐานข้อมูลถูก commit เท่านั้น ถ้าคุณส่งภายใน transaction เดียวกันแล้วมัน rollback ผู้ใช้จะได้รับข้อความเกี่ยวกับสิ่งที่ไม่เคยเกิดขึ้น
ต่อมา ให้แต่ละการแจ้งเตือนมีตัวตนที่เฉพาะ ให้คีย์ idempotency คงที่ (เช่น order_id + event_type + channel) และบังคับใน storage เพื่อไม่ให้ retry สร้างการแจ้งเตือนใหม่
ก่อนปล่อย ตรวจสอบพื้นฐานเหล่านี้:
- การส่งเกิดหลัง commit ไม่ใช่ระหว่างการเขียน
- แต่ละการแจ้งเตือนมี idempotency key เฉพาะ และปฏิเสธการซ้ำ
- การ retry ปลอดภัย: ระบบสามารถรันงานเดียวกันอีกครั้งแล้วส่งไม่เกินหนึ่งครั้ง
- ทุกความพยายามถูกบันทึก (สถานะ, last_error, timestamps)
- จำกัด attempts และรายการที่ติดค้างมีที่ชัดเจนให้ตรวจสอบและประมวลผลใหม่
ทดสอบพฤติกรรมการรีสตาร์ทอย่างตั้งใจ ฆ่า worker ระหว่างการส่ง แล้วรีสตาร์ทและยืนยันว่าไม่มีการส่งซ้ำ ทำแบบเดียวกันขณะที่ฐานข้อมูลมีภาระ
สถานการณ์ง่าย ๆ ที่ควรตรวจ: ผู้ใช้เปลี่ยนเบอร์โทร แล้วคุณส่ง SMS ยืนยัน ถ้าผู้ให้บริการ SMS timeout แอปของคุณ retry ด้วย idempotency key และบันทึก attempts คุณจะส่งหนึ่งครั้งหรือพยายามใหม่อย่างปลอดภัยทีหลัง แต่จะไม่สแปม
ตัวอย่างสถานการณ์: อัพเดตคำสั่งซื้อโดยไม่ส่งซ้ำ
ร้านค้าอาจส่งสองประเภทข้อความ: (1) อีเมลยืนยันคำสั่งซื้อทันทีหลังชำระ และ (2) SMS อัพเดตเมื่อตั้งค่าสถานะว่าออกส่งหรือส่งแล้ว
นี่คือสิ่งที่ผิดพลาดเมื่อคุณส่งเร็วเกินไป (เช่น ภายใน database trigger): ขั้นตอนชำระเงินเขียนแถว orders, trigger ทำงานและส่งอีเมลลูกค้า แล้วการ capture การชำระเงินล้มเหลวทีหลัง ตอนนี้คุณมีอีเมล “ขอบคุณสำหรับคำสั่งซื้อของคุณ” สำหรับคำสั่งซื้อที่ไม่เคยเกิดขึ้นจริง
อีกปัญหาคือสถานะการจัดส่งเปลี่ยนเป็น “Out for delivery”, คุณเรียกผู้ให้บริการ SMS แล้ว provider timeout คุณไม่รู้ว่ามันส่งหรือไม่ ถ้าคุณ retry ทันที คุณเสี่ยงส่ง SMS สองครั้ง ถ้าไม่ retry คุณเสี่ยงที่จะไม่มีการส่งเลย
โฟลว์ที่ปลอดภัยใช้แถว outbox และ background worker แอป commit คำสั่งซื้อหรือการเปลี่ยนแปลงสถานะ แล้วใน transaction เดียวกันเขียนแถว outbox เช่น “ส่งเทมเพลต X ให้ user Y, channel SMS, idempotency key Z” หลัง commit worker เท่านั้นที่จะส่งข้อความ
ไทม์ไลน์ง่าย ๆ เป็นดังนี้:
- ชำระเงินสำเร็จ, transaction commit, บันทึกแถว outbox สำหรับอีเมลยืนยัน
- Worker ส่งอีเมล แล้วทำเครื่องหมายว่า outbox ถูกส่งพร้อม provider message ID
- สถานะการจัดส่งเปลี่ยน, commit, บันทึกแถว outbox สำหรับอัพเดต SMS
- Provider timeout, worker ทำเครื่องหมายว่า outbox สามารถ retry ได้และจะพยายามใหม่โดยใช้ idempotency key เดิม
เมื่อ retry แถว outbox เป็นแหล่งความจริงเดียว คุณไม่ได้สร้างคำขอส่งใหม่ คุณกำลังทำให้คำขอเดิมสำเร็จ
สำหรับซัพพอร์ต นี่ช่วยให้เข้าใจง่ายขึ้น พวกเขาจะเห็นข้อความที่ติดอยู่ใน failed พร้อม last error (timeout, เบอร์ไม่ถูกต้อง, อีเมลถูกบล็อก), จำนวน attempts ที่ทำ, และว่าปลอดภัยหรือไม่ที่จะลองใหม่โดยไม่ส่งซ้ำ
ขั้นตอนต่อไป: เลือกรูปแบบและทำให้มันสะอาด
เลือกรูปแบบเริ่มต้นและเขียนมันลง เอกสารที่ไม่สอดคล้องมักมาจากการผสม triggers และ workers แบบสุ่ม
เริ่มเล็ก ๆ ด้วยตาราง outbox และ loop worker หนึ่งตัว เป้าหมายแรกไม่ใช่ความเร็ว แต่เป็นความถูกต้อง: เก็บสิ่งที่คุณตั้งใจจะส่ง, ส่งหลัง commit, และทำเครื่องหมายว่าได้ส่งเมื่อผู้ให้บริการยืนยัน
แผนการเปิดตัวง่าย ๆ:
- กำหนดเหตุการณ์ (order_paid, ticket_assigned) และช่องทางที่ใช้ได้
- เพิ่มตาราง outbox ที่มี event_id, recipient, payload, status, attempts, next_retry_at, sent_at
- สร้าง worker หนึ่งตัวที่ poll แถวรอดำเนินการ ส่ง และอัพเดตสถานะที่เดียว
- เพิ่ม idempotency ด้วยคีย์เฉพาะต่อข้อความและ “ไม่ทำอะไรถ้าส่งแล้ว”
- แยกข้อผิดพลาดเป็น retryable (timeout, 5xx) กับ not retryable (เบอร์ไม่ถูกต้อง, อีเมลถูกบล็อก)
ก่อนเพิ่มปริมาณ ให้เพิ่มการมองเห็นขั้นพื้นฐาน ติดตามจำนวน pending, อัตราความล้มเหลว, และอายุของ pending ที่เก่าแก่ที่สุด ถ้าอายุมากขึ้นเรื่อย ๆ คุณอาจมี worker ติด, provider outage, หรือบั๊กใน logic
ถ้าคุณกำลังสร้างใน AppMaster (appmaster.io) รูปแบบนี้แมปได้อย่างชัดเจน: ออกแบบ outbox ใน Data Designer, เขียนการเปลี่ยนแปลงธุรกิจและแถว outbox ใน transaction เดียวกัน แล้วรัน logic ส่งและ retry ใน process แยก การแยกนี้คือสิ่งที่ทำให้การส่งการแจ้งเตือนเชื่อถือได้แม้ผู้ให้บริการหรือการ deploy ผิดพลาด
คำถามที่พบบ่อย
Background workers มักเป็นตัวเลือกที่ปลอดภัยกว่าเพราะการส่งเป็นงานช้าและเกิดความล้มเหลวได้บ่อยกว่า และ workers ถูกออกแบบมาเพื่อรองรับการ retry และมองเห็นสถานะได้ง่ายกว่า Triggers เร็วแต่ผูกแน่นกับ transaction หรือ request ที่เรียกใช้งาน ทำให้การจัดการข้อผิดพลาดและการป้องกันการซ้ำทำได้ยากขึ้น
มันเสี่ยงเพราะการเขียนข้อมูลในฐานข้อมูลยังอาจถูก rollback ได้ คุณอาจแจ้งผู้ใช้เกี่ยวกับคำสั่งซื้อ การเปลี่ยนรหัสผ่าน หรือการชำระเงินที่จริง ๆ แล้วไม่ถูก commit ซึ่งไม่สามารถยกเลิกอีเมลหรือ SMS ที่ส่งออกไปแล้วได้
Trigger ของฐานข้อมูลทำงานภายใน transaction เดียวกับการเปลี่ยนแปลงแถว ถ้ามันเรียกผู้ให้บริการอีเมล/SMS แล้ว transaction ล้มเหลวภายหลัง คุณอาจได้ส่งข้อความจริงเกี่ยวกับการเปลี่ยนแปลงที่ไม่เกิด หรือ trigger ช้าจนทำให้การเขียนข้อมูลติดค้าง
รูปแบบ outbox เก็บเจตนาที่จะส่งเป็นแถวหนึ่งในฐานข้อมูล พร้อมกับการเปลี่ยนแปลงทางธุรกิจใน transaction เดียวกัน หลัง commit แล้ว worker จะอ่านแถว outbox ที่รอดำเนินการ ส่งข้อความ แล้วค่อยทำเครื่องหมายว่าได้ส่งแล้ว ซึ่งทำให้เรื่องเวลาและการ retry ปลอดภัยขึ้นมาก
เมื่อ provider timeout ผลลัพธ์ที่แท้จริงมักเป็น “ไม่ทราบ” มากกว่า “ล้มเหลว” ระบบที่ดีจะบันทึกการพยายามไว้ เลื่อนเวลา แล้วลองใหม่อย่างปลอดภัยโดยใช้ตัวตนของข้อความเดียวกัน แทนที่จะส่งใหม่ทันทีและเสี่ยงส่งซ้ำ
ใช้ idempotency: ให้แต่ละการแจ้งเตือนมีคีย์คงที่ที่อธิบายความหมายของข้อความ (ไม่ใช่เวลาที่คุณพยายามส่ง) เก็บคีย์นั้นในบัญชีแยกประเภท (เช่นตาราง outbox) และบังคับให้มีเพียงระเบียนเดียวต่อคีย์ เพื่อให้การ retry ทำให้การส่งเดิมเสร็จแทนที่จะสร้างการส่งใหม่
ลอง retry สำหรับข้อผิดพลาดชั่วคราว เช่น timeout, 5xx, หรือการ rate limit (พร้อมเวลารอ) แต่ไม่ต้อง retry กับข้อผิดพลาดถาวร เช่น ที่อยู่อีเมลไม่ถูกต้อง, เบอร์ถูกบล็อก, หรือ hard bounces — ให้ทำเครื่องหมายว่า failed และแสดงให้คนดูแลแก้ไขข้อมูลแทนการพยายามซ้ำแบบสแปม
worker สามารถสแกนงานที่ติดค้างในสถานะ sending เกินเวลาที่สมเหตุสมผล แล้วย้ายกลับไปเป็น retryable เพื่อพยายามอีกครั้งแบบ backoff วิธีนี้ทำได้อย่างปลอดภัยเมื่อแต่ละงานมีการบันทึกสถานะ (attempts, timestamps, last error) และ idempotency ป้องกันการส่งซ้ำ
หมายความว่าคุณสามารถตอบคำถามว่า “ปลอดภัยที่ลองใหม่ไหม?” ได้จริง ๆ เก็บสถานะชัดเจนเช่น pending, processing, sent, failed รวมทั้งนับ attempts และบันทึก last error — นั่นทำให้การซัพพอร์ตและการดีบั๊กเป็นไปได้จริง และระบบสามารถกู้คืนได้โดยไม่ต้องเดา
ใน AppMaster ให้โมเดลตาราง outbox ใน Data Designer เขียนอัพเดตธุรกิจและแถว outbox ใน transaction เดียวกัน แล้วรัน logic การส่งและ retry ใน process แยกต่างหาก เก็บ idempotency key ต่อข้อความ และบันทึก attempts เพื่อที่การ deploy, retry, และการรีสตาร์ท worker จะไม่สร้างการส่งซ้ำ


