ทำไมการส่งแจ้งเตือนถึงต้องมีคิว (และ retry ที่ไม่ส่งซ้ำ)
5 แบบที่โค้ดส่งแจ้งเตือนแบบยิงตรงพัง — API ปลายทางช้า ส่งหาย ส่งซ้ำหลัง restart worker แย่งงานกัน — และโครงสร้างขั้นต่ำที่แก้ได้ พร้อม SQL จริง
โค้ดส่งแจ้งเตือนเวอร์ชันแรกของแทบทุกระบบหน้าตาแบบนี้
app.post('/orders', async (req, res) => {
const order = await createOrder(req.body);
await sendLineMessage(order.customerId, `ออเดอร์ ${order.id} ได้รับแล้ว`);
res.json(order);
});
มันใช้ได้ จนวันหนึ่งมันไม่ได้ บทความนี้เล่าว่ามันพังยังไง และโครงสร้างขั้นต่ำที่ทำให้มันไม่พังคืออะไร เขียนจากสิ่งที่ผมเจอจริงตอนทำ Ting ไม่ใช่ทฤษฎี
พังแบบที่ 1: API ปลายทางช้า แล้วระบบคุณช้าตาม
sendLineMessage คือการเรียก HTTP ไปหาคนอื่น วันที่ LINE ตอบช้า 5 วินาที ลูกค้าคุณกดสั่งซื้อแล้วรอ 5 วินาที วันที่ LINE ตอบ 500 ออเดอร์ของคุณ "ล้มเหลว" ทั้งที่บันทึกลง DB ไปแล้ว
หลักข้อแรก: การรับงาน (บันทึกออเดอร์) กับการส่งแจ้งเตือน ต้องไม่อยู่ใน request เดียวกัน ให้ request แค่ "จดไว้ว่าต้องส่ง" แล้วตอบกลับทันที ตัวส่งจริงเป็นอีกโปรเซสที่ทำงานจากรายการที่จดไว้
นั่นคือคิว ไม่ต้องเป็น Kafka หรือ RabbitMQ ตารางใน DB ที่มีคอลัมน์ status ก็เป็นคิวได้ และสำหรับระบบส่วนใหญ่มันพอ
พังแบบที่ 2: ส่งไม่ถึงแล้วหายไปเลย
ไม่มีคิว = ส่งครั้งเดียว พลาดคือจบ เน็ตสะดุด, rate limit, token หมดอายุชั่วคราว ข้อความนั้นหายไปโดยไม่มีใครรู้
พอมีคิว การ retry กลายเป็นเรื่องธรรมดา: งานที่ล้มเหลวกลับไปสถานะ "รอส่ง" พร้อมเวลาที่จะลองใหม่ แต่ retry ที่ดีมีสองเงื่อนไข
- เว้นระยะเพิ่มขึ้นเรื่อย ๆ 30 วินาที, 2 นาที, 10 นาที ไม่ใช่ยิงรัวทุกวินาทีจนโดนแบน
- มีเพดาน ลองแล้ว 5 ครั้งยังไม่ได้ ให้หยุดและบอกคน ไม่ใช่ลองตลอดไปเงียบ ๆ
พังแบบที่ 3: ส่งซ้ำ
อันนี้แย่กว่าส่งไม่ถึง เพราะลูกค้าเห็นและจำ
สถานการณ์คลาสสิก: งานหนึ่งคือ "ส่งหาผู้รับ 2,000 คน" worker ส่งไปได้ 800 คน แล้ว restart (deploy, crash, เครื่องรีบูต) งานยังอยู่ในสถานะ "กำลังส่ง" worker ตัวใหม่หยิบขึ้นมาแล้วเริ่มจากคนแรก
ทางแก้ไม่ใช่ "อย่า restart" เพราะคุณคุมไม่ได้ ทางแก้คือ
- จำระดับผู้รับ ไม่ใช่ระดับงาน มีตารางบันทึกว่า งาน X ส่งให้ผู้รับ Y ช่องทาง Z สำเร็จแล้ว ก่อนส่งแต่ละคนเช็คตารางนี้ก่อน
- งานที่ค้างต้องตรวจจับได้ worker ที่ถืองานอยู่ต้องอัปเดต heartbeat เป็นระยะ ถ้า heartbeat หยุดเกิน N นาที ให้ถือว่าตัวถือตาย งานกลับเข้าคิวได้ และเพราะข้อ 1 การทำต่อจะข้ามคนที่ส่งแล้วเอง
- ตอนปิดโปรเซสให้ปิดสวย ๆ รับสัญญาณ SIGTERM แล้วหยุดหยิบงานใหม่ ทำงานปัจจุบันให้จบคน (ไม่ใช่จบงาน) แล้วค่อยออก
ใน Ting ผมทดสอบเรื่องนี้ด้วยการส่งจริง 2,000 คนแล้ว restart เซิร์ฟเวอร์กลางทางสองรอบ ก่อนจะกล้าเขียนว่า "ไม่ส่งซ้ำ" ถ้าคุณทำระบบเอง แนะนำให้ทดสอบแบบนี้จริง ๆ ไม่ใช่แค่อ่านโค้ดแล้วเชื่อ
พังแบบที่ 4: worker หลายตัวหยิบงานเดียวกัน
พอระบบโต คุณจะรัน worker มากกว่าหนึ่งตัว และสองตัว SELECT ... WHERE status = 'pending' พร้อมกัน ได้แถวเดียวกัน ส่งซ้ำอีกแบบ
ถ้าใช้ PostgreSQL คำตอบสั้นมาก
UPDATE send_queue
SET status = 'sending', claimed_by = $1, claimed_at = now()
WHERE id = (
SELECT id FROM send_queue
WHERE status = 'pending' AND next_attempt_at <= now()
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
FOR UPDATE SKIP LOCKED ทำให้แต่ละ worker หยิบคนละแถวโดยไม่ต้องมี lock กลาง เพิ่ม worker ได้เรื่อย ๆ โดยไม่แก้โค้ด ฐานข้อมูลอื่นมีของคล้ายกัน (SQL Server ใช้ READPAST)
พังแบบที่ 5: ไม่รู้ว่าเกิดอะไรขึ้น
ทุกอย่างข้างบนทำแล้ว แต่ไม่มีที่ดู วันที่ลูกค้าบอกว่าไม่ได้รับ คุณยังต้องเปิด server log อยู่ดี
ตารางคิวควรเป็นแหล่งข้อมูลที่ดูได้จากหน้าจอ: งานไหนรอ ส่งแล้วกี่คน ล้มเหลวกี่คน เพราะอะไร (เก็บ error จริงจากปลายทาง เช่น LINE บอกว่า user บล็อก) และเนื้อหาที่ส่งไปจริง
โครงสร้างขั้นต่ำ
ถ้าสรุปเป็นตาราง
| สิ่งที่ต้องมี | แก้ปัญหา |
|---|---|
| ตารางคิว + worker แยกโปรเซส | request ไม่ช้าตาม API ปลายทาง |
| retry แบบเว้นระยะ + เพดาน | ส่งไม่ถึงแล้วหาย / retry รัวจนโดนแบน |
| ตารางบันทึกรายผู้รับ | ส่งซ้ำหลัง restart |
| heartbeat + งานค้างกลับคิว | worker ตายแล้วงานค้างตลอดกาล |
SKIP LOCKED |
worker หลายตัวหยิบงานเดียวกัน |
| log ผลต่อผู้รับต่อช่องทาง | ตอบลูกค้าได้ว่าเกิดอะไร |
ทั้งหมดนี้ทำเองได้ใน 1–2 สัปดาห์ถ้าเคยทำมาแล้ว และ 1–2 เดือนถ้ายังไม่เคย (ส่วนใหญ่หมดไปกับการเจอ edge case ข้อ 3) นี่คือเหตุผลที่ผมทำมันเป็นของกลางใน Ting — ไม่ใช่เพราะมันยากในเชิงทฤษฎี แต่เพราะมันเป็นของที่ต้องทำซ้ำในทุกโปรเจกต์ และทุกครั้งจะพลาดข้อเดิม ๆ
ถ้าคุณจะทำเอง หวังว่าตารางข้างบนช่วยให้พลาดน้อยกว่าผม