← All articles

8 min read Read in English

ทำไมการส่งแจ้งเตือนถึงต้องมีคิว (และ 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" เพราะคุณคุมไม่ได้ ทางแก้คือ

  1. จำระดับผู้รับ ไม่ใช่ระดับงาน มีตารางบันทึกว่า งาน X ส่งให้ผู้รับ Y ช่องทาง Z สำเร็จแล้ว ก่อนส่งแต่ละคนเช็คตารางนี้ก่อน
  2. งานที่ค้างต้องตรวจจับได้ worker ที่ถืองานอยู่ต้องอัปเดต heartbeat เป็นระยะ ถ้า heartbeat หยุดเกิน N นาที ให้ถือว่าตัวถือตาย งานกลับเข้าคิวได้ และเพราะข้อ 1 การทำต่อจะข้ามคนที่ส่งแล้วเอง
  3. ตอนปิดโปรเซสให้ปิดสวย ๆ รับสัญญาณ 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 — ไม่ใช่เพราะมันยากในเชิงทฤษฎี แต่เพราะมันเป็นของที่ต้องทำซ้ำในทุกโปรเจกต์ และทุกครั้งจะพลาดข้อเดิม ๆ

ถ้าคุณจะทำเอง หวังว่าตารางข้างบนช่วยให้พลาดน้อยกว่าผม