บอทแจ้งเตือนซ้ำทุกครั้งที่เซิร์ฟเวอร์รีสตาร์ต: ทำไมห้ามเก็บ state สำคัญไว้ในแรม
ระบบดักงานของผมทำงานแบบนี้: ทุก 30 นาทีดึงงานใหม่จากเว็บฟรีแลนซ์ งานไหนน่าสนใจก็ส่ง LINE มาบอก
และจำไว้ว่างานนี้แจ้งไปแล้ว จะได้ไม่แจ้งซ้ำ วิธีจำตอนแรกคือเก็บ ID งานไว้ในตัวแปรชนิด set()
มันทำงานดีอยู่หลายวัน จนวันหนึ่งที่ผม deploy โค้ดถี่ๆ ระบบเริ่มแจ้งงานเก่าซ้ำ รวมถึงงานที่ผมคุยกับลูกค้าไปแล้ว สาเหตุคือทุกครั้งที่เซิร์ฟเวอร์รีสตาร์ต ตัวแปรในหน่วยความจำ (RAM) หายหมด บอทจึงลืมว่าเคยแจ้งอะไรไปแล้ว
ทำไมบริการคลาวด์ถึงรีสตาร์ตบ่อยกว่าที่คิด
ถ้ารันบน Render, Railway, Fly.io หรือ Heroku โปรเซสของเราอาจถูกเริ่มใหม่ได้จากหลายสาเหตุ
- ทุกครั้งที่ deploy — push โค้ดวันละ 5 ครั้ง ก็รีสตาร์ต 5 ครั้ง
- แผนฟรีหลับเมื่อไม่มีคนใช้ — Render แผนฟรีจะพักเซอร์วิสเมื่อไม่มี traffic สักพัก แล้วเริ่มใหม่ตอนมี request
- ผู้ให้บริการย้ายเครื่อง — ซ่อมบำรุง หรือย้ายไปเครื่องใหม่ โดยไม่บอกล่วงหน้า
- หน่วยความจำเกิน — โปรเซสถูกฆ่าแล้วเริ่มใหม่
ไฟล์ที่เขียนลงดิสก์ของเซอร์วิสเองก็หายได้เหมือนกัน เพราะดิสก์ของคอนเทนเนอร์ส่วนใหญ่เป็นแบบชั่วคราว (ephemeral) ยกเว้นจะต่อ disk แบบถาวรแยก
อะไรเก็บในแรมได้ อะไรห้าม
| เก็บในแรมได้ | ต้องเก็บใน DB |
|---|---|
| ค่าที่คำนวณใหม่ได้ เช่น cache ของ config, ผลลัพธ์ที่ดึงซ้ำได้ | รายการที่เคยแจ้งเตือน / เคยตอบ / เคยส่งไปแล้ว |
| ตัวนับสถิติคร่าวๆ ที่หายแล้วไม่เสียหาย | ออเดอร์ การจอง การชำระเงิน |
| rate limit ระยะสั้น (ถ้ายอมให้รีเซ็ตได้) | สถานะที่บอกว่างานไหนทำเสร็จหรือยัง |
วิธีถามตัวเองง่ายๆ: ถ้าค่านี้หายตอนตีสาม จะมีอะไรเกิดซ้ำหรือหายไปไหม ถ้าคำตอบคือมี ต้องเก็บนอกโปรเซส
ย้าย dedup ไปเก็บในฐานข้อมูล
ผมใช้ Supabase (Postgres) ทำตารางเล็กๆ ตารางเดียว
create table hunter_seen_jobs (
job_id text primary key,
seen_at timestamptz default now()
);
-- เปิด RLS และไม่ให้สิทธิ์ anon เพื่อไม่ให้คนนอกอ่าน/เขียนผ่าน public key ได้
alter table hunter_seen_jobs enable row level security;
แล้วให้โค้ดทำงานแบบนี้
- ตอนเริ่มรอบ โหลด ID ที่เคยเห็นจาก DB มาไว้ในแรม (ใช้เป็น cache ให้เร็ว)
- งานไหนอยู่ในรายการแล้ว ข้าม
- งานใหม่ที่แจ้งเตือนแล้ว เขียนลง DB ทันที ไม่ใช่รอเขียนตอนจบรอบ
ใช้ job_id เป็น primary key ทำให้เขียนซ้ำไม่ได้อยู่แล้ว ถ้าสองโปรเซสทำงานพร้อมกันก็ไม่แจ้งซ้ำ
(ใช้ upsert หรือ on conflict do nothing)
กับดักตอนเปิดใช้ครั้งแรก: แจ้งเตือนงานเก่ารัวๆ
วันแรกที่ deploy ระบบใหม่ ตาราง DB ยังว่าง ระบบจะเห็นทุกงานบนหน้าเว็บเป็นงานใหม่ แล้วยิงแจ้งเตือนทีเดียวหลายสิบข้อความ (และกินโควตา LINE ไปด้วย)
วิธีกันคือมีโหมด prime: ถ้าตารางว่าง ให้รอบแรกบันทึกทุก ID ที่เห็นลง DB โดยไม่ส่งแจ้งเตือน รอบต่อจากนั้นค่อยแจ้งเฉพาะงานที่ใหม่จริง
seen = load_seen_ids() # จาก DB
priming = len(seen) == 0
for job in fetch_jobs():
if job.id in seen:
continue
if not priming:
notify(job)
save_seen_id(job.id) # เขียนทันที
seen.add(job.id)
ล้างข้อมูลเก่า
ตาราง dedup จะโตขึ้นเรื่อยๆ ถ้าเว็บต้นทางไม่เอางานเก่ากลับมาแสดง ลบแถวที่เก่ากว่า 30–60 วันได้
ตั้ง job รายวันลบ where seen_at < now() - interval '45 days' ก็พอ
สรุป
- บนบริการคลาวด์ ให้คิดไว้ก่อนเลยว่าโปรเซสจะถูกเริ่มใหม่เมื่อไหร่ก็ได้
- อะไรที่ต้องจำข้ามการรีสตาร์ต ต้องเก็บในฐานข้อมูลหรือ storage ถาวร ไม่ใช่ตัวแปรในโค้ด
- เขียนทันทีที่ทำเสร็จ และมีโหมด prime กันแจ้งเตือนเก่าตอนเริ่มระบบครั้งแรก
อ่านต่อ: ออกแบบยังไงให้ของพังแล้วส่งเสียง