เว็บฮุค LINE ยิงซ้ำตอนเน็ตกระตุก แล้วยอดขายเบิ้ลเป็นสองใน Meta: ออกแบบ Idempotency Key ให้ทนการยิงซ้ำจริง

สรุปสั้น ๆ
event_id ที่ส่งให้ Meta/Google ช่วยกันนับซ้ำแค่ฝั่งแพลตฟอร์ม แต่ถ้า webhook จาก LINE ยิงเข้าเซิร์ฟเวอร์คุณซ้ำสองรอบ โค้ดของคุณเองต้องมีชั้นกันซ้ำก่อนที่จะเรียก CAPI ด้วยซ้ำ ไม่งั้นค่าโฆษณาจะพองจากข้อมูลที่ผิดตั้งแต่ต้นทาง
สัปดาห์ก่อนทีมหนึ่งที่ผมช่วยดูระบบ ส่งข้อความมาถามว่า ทำไมยอด Purchase ใน Meta Events Manager วันเสาร์ที่ผ่านมาสูงกว่ายอดโอนจริงในบัญชีธนาคารอยู่ 18 รายการ ทั้งที่ event_id ก็ตั้งไว้แล้ว ตรวจโค้ดไปมาสุดท้ายเจอว่า ตอนแคมเปญยิงหนักช่วงเที่ยง เซิร์ฟเวอร์ตอบ webhook ของ LINE ช้ากว่า 3 วินาทีในบางคำขอ LINE เลยยิง event เดิมซ้ำมาอีกรอบตามพฤติกรรมมาตรฐานของมัน แล้วโค้ดฝั่งเซิร์ฟเวอร์ก็ประมวลผลทั้งสองรอบเป็นออเดอร์ใหม่ทั้งคู่
ปัญหานี้ไม่เกี่ยวกับ event_id ที่ส่งให้ Meta เลยสักนิด เพราะกว่าจะไปถึงขั้นตอนนั้น backend ของคุณก็สร้าง event สองชุดที่มี event_id คนละค่าไปแล้ว (เพราะสร้างจาก timestamp หรือ UUID ใหม่ทุกครั้งที่ฟังก์ชันถูกเรียก) Meta เลยนับเป็นสอง conversion ที่ถูกต้องตามที่มันเห็น ปัญหาจริงเกิดที่ชั้นก่อนหน้านั้น คือตอนที่ backend คุณตัดสินใจว่า ‘นี่คือออเดอร์ใหม่’ ทั้งที่จริงมันคือคำขอเดิมที่มาซ้ำ
บทความนี้จะไม่พูดถึงแนวคิด dedup ระดับแพลตฟอร์มที่หลายคนรู้จักกันแล้ว แต่จะเจาะเรื่อง idempotency key ที่ backend ของคุณต้องมี ก่อนที่ event จะเดินทางไปถึงขั้นตอนสร้าง event_id ด้วยซ้ำ ซึ่งเป็นจุดที่ทีมพัฒนาส่วนใหญ่มองข้าม เพราะคิดว่าเดี๋ยวแพลตฟอร์มปลายทางจะกันซ้ำให้เอง
ทำไม LINE ถึงยิง webhook ซ้ำ และทำไมมันเป็นเรื่องปกติ ไม่ใช่บั๊ก
LINE Messaging API ทำงานบนหลักการเดียวกับ webhook ทั่วไปคือ ยิง HTTP request มาที่ endpoint ของคุณ แล้วรอ response กลับภายในเวลาที่กำหนด ถ้าเซิร์ฟเวอร์คุณตอบ 200 ไม่ทันเวลา ไม่ว่าจะเพราะ query ฐานข้อมูลช้า โดนคิวรอ หรือแค่ traffic พีคจนประมวลผลไม่ทัน LINE จะถือว่าการส่งครั้งนั้นล้มเหลว แล้วยิงเนื้อหาเดิมมาให้ใหม่ในไม่กี่วินาทีถัดมา นี่คือพฤติกรรมที่ถูกออกแบบมาเพื่อความน่าเชื่อถือ ไม่ใช่บั๊กของ LINE
ปัญหาคือ ทีมพัฒนาจำนวนมากออกแบบ endpoint รับ webhook แบบ ‘เชื่อทุกคำขอที่เข้ามา’ คือรับ payload แล้วประมวลผลทันทีโดยไม่เช็กว่าเคยเห็นคำขอนี้มาก่อนหรือยัง พอ traffic ปกติมันไม่มีปัญหา แต่พอวันไหนแคมเปญร้อนจนคนทักพร้อมกันเยอะ เซิร์ฟเวอร์ตอบช้าลง อัตราการยิงซ้ำก็จะพุ่งขึ้นตามไปด้วย และดันไปตรงกับวันที่ยอดขายเยอะที่สุดพอดี ซึ่งเป็นวันที่ตัวเลข conversion เพี้ยนแล้วส่งผลกระทบมากที่สุดเช่นกัน
event_id กับ idempotency key ไม่ใช่เรื่องเดียวกัน แม้จะฟังดูคล้าย
หลายทีมเข้าใจว่าตั้ง event_id ให้แต่ละ conversion แล้วจะปลอดภัยจากการนับซ้ำ ซึ่งถูกครึ่งเดียว event_id ช่วยให้ ระบบส่งข้อมูลกลับแบบ server-to-server ที่แพลตฟอร์มโฆษณาใช้จับคู่กับ pixel ฝั่งเบราว์เซอร์ ไม่ให้นับ conversion เดียวกันสองครั้งจากสองช่องทาง แต่นั่นคือการกันซ้ำ ‘หลัง’ จากที่ backend คุณตัดสินใจแล้วว่ามี event เกิดขึ้น
idempotency key คือกลไกที่ต้องอยู่ ‘ก่อน’ ขั้นตอนนั้น มันคือค่าที่บอกว่า คำขอที่เข้ามาตอนนี้ คือคำขอเดิมที่เคยประมวลผลไปแล้วหรือเปล่า ถ้าใช่ ให้ตอบ 200 กลับไปเฉย ๆ โดยไม่ต้องทำอะไรซ้ำอีกรอบ ไม่ต้องสร้าง event_id ใหม่ ไม่ต้องยิง CAPI ใหม่ ไม่ต้องแตะฐานข้อมูลออเดอร์ซ้ำ ถ้าไม่มีชั้นนี้ event_id ที่สร้างจากคำขอซ้ำแต่ละรอบจะเป็นคนละค่ากันเสมอ เพราะมันถูกสร้างจาก timestamp หรือ random ใหม่ทุกครั้งที่ฟังก์ชันถูกเรียก ซึ่งแปลว่ากลไก dedup ฝั่งแพลตฟอร์มช่วยอะไรไม่ได้เลย
ออกแบบ key ยังไงให้ ‘คำขอเดิม’ ให้ค่าเดิมเสมอ
หัวใจของ idempotency key ที่ดีคือ ต้องคำนวณได้ค่าเดียวกันทุกครั้งจากข้อมูลเดิม (deterministic) ไม่ใช่สุ่มใหม่ทุกรอบ วิธีที่ใช้ได้ผลจริงในระบบที่รับ webhook จาก LINE คือรวมรหัสที่ LINE ให้มากับ payload เช่น webhookEventId หรือ deliveryContext ผูกกับรหัสข้อความ แล้วเก็บไว้ในตารางแยกที่มี unique constraint
อีกวิธีที่ใช้ได้เมื่อ LINE ไม่ได้ให้รหัสที่นิ่งพอ คือประกอบ key เองจากฟิลด์ที่คงที่ของธุรกรรมนั้น เช่น รหัสลูกค้า + รหัสคำสั่งซื้อ + ประเภทเหตุการณ์ แล้วรวมกันด้วยการ hash เป็นค่าเดียว จุดสำคัญคือ ต้องเลือกฟิลด์ที่ไม่เปลี่ยนแม้คำขอจะมาซ้ำ ห้ามใช้ timestamp ตอนที่เซิร์ฟเวอร์รับคำขอเป็นส่วนหนึ่งของ key เพราะคำขอซ้ำแต่ละรอบจะมาถึงคนละเวลาเสมอ
เก็บ key ไว้ตรงไหน และเช็กยังไงไม่ให้เกิด race condition
| แนวทาง | ข้อดี | จุดที่ต้องระวัง |
|---|---|---|
| Unique constraint บนคอลัมน์ key ในตาราง | ฐานข้อมูลช่วยกันซ้ำให้เองแบบ atomic | ต้อง catch error ตอน insert ซ้ำแล้วตอบ 200 ไม่ใช่ error 500 |
| Redis SETNX พร้อม TTL | เร็ว เหมาะกับ traffic สูง | ต้องตั้ง TTL ให้นานพอ ไม่งั้น key หมดอายุก่อน LINE ยิงซ้ำรอบสุดท้าย |
| Queue ที่มี dedup ในตัว | ลดโค้ดที่ต้องเขียนเอง | บาง queue กันซ้ำแค่ในช่วงเวลาสั้น ๆ ต้องอ่าน spec ให้ละเอียด |
กรณีที่ซับซ้อนกว่านั้น: สองคำขอมาถึงพร้อมกันจริง ๆ
ในระบบที่มีหลาย worker ประมวลผลพร้อมกัน มีโอกาสที่คำขอซ้ำสองอันจะมาถึงในเสี้ยววินาทีเดียวกัน จนทั้งคู่เช็กฐานข้อมูลพร้อมกันแล้วเห็นว่า ‘ยังไม่มี key นี้’ ทั้งคู่ ก่อนที่ใครจะ insert ทัน นี่คือ race condition ที่ unique constraint ระดับฐานข้อมูลช่วยได้ดีกว่าการเช็กด้วยโค้ดแอปพลิเคชันเปล่า ๆ เพราะฐานข้อมูลจะปฏิเสธ insert ตัวที่สองโดยอัตโนมัติ แอปแค่ต้อง catch error แล้วรู้ว่า ‘นี่คือคำขอซ้ำ ไม่ใช่ข้อผิดพลาดจริง’
ทีมที่เคยแก้ปัญหานี้แล้วบอกผมว่า จุดที่ลืมกันบ่อยที่สุดคือ พอ catch error จากการ insert ซ้ำได้ ก็ยังลืมไม่ตอบ 200 กลับให้ LINE บางระบบเขียน error handler ให้ตอบ 500 แทน ซึ่งยิ่งไปกระตุ้นให้ LINE ยิงซ้ำเข้ามาอีก กลายเป็นวนลูป ต้องเช็กให้ชัดว่า ‘คำขอซ้ำที่ตั้งใจกัน’ กับ ‘ข้อผิดพลาดจริงที่ต้องแจ้งเตือน’ ต้องส่ง response คนละแบบกัน
ทดสอบว่า key ที่ออกแบบไว้ทนการยิงซ้ำจริงหรือเปล่า
- จำลองยิง payload เดิมเข้า endpoint สองครั้งติดกันแบบขนาน (ไม่ใช่ทีละครั้ง) แล้วดูว่าระบบทดสอบ CAPIของคุณสร้าง event เข้า CAPI กี่ครั้ง ถ้าออกแบบถูก ควรเห็นแค่ 1 ครั้งเสมอไม่ว่าจะรันกี่รอบ
- ปิด endpoint ชั่วคราวแล้วบังคับให้ LINE timeout จริง (ทำได้ด้วยการหน่วง response เกิน limit) เพื่อดูว่าเมื่อ LINE ยิงซ้ำมาจริง ระบบจัดการถูกต้องไหม ไม่ใช่แค่ทดสอบในสภาพแวดล้อมที่ทุกอย่างเร็วสมบูรณ์แบบ
- ตรวจสอบว่าเมื่อคำขอซ้ำถูกปฏิเสธ ตัวนับ metric อย่าง ‘คำขอซ้ำที่ถูกกัน’ ขยับขึ้นจริง เพื่อให้ทีมมองเห็นว่ากลไกนี้ทำงานอยู่ตลอดเวลา ไม่ใช่แค่ตอนทดสอบ
- รีวิวคิวและ retry ที่อยู่ชั้นถัดไปด้วย เพราะถ้าคิวของคุณเองก็ retry งานที่ล้มเหลว ต้องแน่ใจว่า idempotency key ทำงานสอดคล้องกันทั้งสองชั้น ไม่ใช่กันซ้ำแค่ชั้นเดียวแล้วเผลอปล่อยให้อีกชั้นสร้างซ้ำได้อยู่ดี
สรุป
ปัญหายอดซ้ำที่หลายทีมไล่หาสาเหตุอยู่นาน มักไม่ได้อยู่ที่การตั้งค่า CAPI ผิด แต่อยู่ที่ชั้นก่อนหน้านั้น คือ backend ไม่มีกลไกจำว่า ‘คำขอนี้เคยประมวลผลไปแล้ว’ การมี idempotency key ที่ออกแบบถูกต้องคือด่านแรกที่ป้องกันไม่ให้ข้อมูลเพี้ยนตั้งแต่ต้นทาง ก่อนที่มันจะไหลไปถึงขั้นตอนอื่นเลยด้วยซ้ำ
ถ้าทีมคุณยังไม่เคยทดสอบว่า endpoint รับ webhook ทนต่อการยิงซ้ำหรือเปล่า นี่คือจุดที่ควรกลับไปตรวจก่อนไปแก้เรื่องอื่น เพราะต่อให้ปลายทางทุกอย่างสมบูรณ์แบบแค่ไหน ถ้าต้นทางสร้างข้อมูลซ้ำตั้งแต่แรก ทุกอย่างที่ตามมาก็ผิดเพี้ยนไปด้วย
- LINE ยิง webhook ซ้ำเป็นพฤติกรรมปกติเมื่อเซิร์ฟเวอร์ตอบช้า ไม่ใช่บั๊ก
- event_id กันซ้ำที่แพลตฟอร์มโฆษณา ส่วน idempotency key ต้องกันซ้ำที่ backend คุณเองก่อนหน้านั้น
- ใช้ unique constraint หรือ Redis SETNX เพื่อจัดการ race condition แบบ atomic ไม่ใช่เช็กด้วยโค้ดแอปเปล่า ๆ
คำถามที่พบบ่อย
idempotency key ต้องเก็บไว้นานแค่ไหน
อย่างน้อยควรครอบคลุมช่วงเวลาที่ LINE อาจยิงซ้ำได้ (มักไม่เกินสองสามนาที) แต่แนะนำให้เก็บยาวกว่านั้นมาก เช่น 30-90 วัน เผื่อกรณีมีการ replay log หรือ debug ย้อนหลัง เพราะพื้นที่เก็บ key ถูกกว่าความเสียหายจากยอดซ้ำมาก
ถ้าใช้ event_id ที่แพลตฟอร์มโฆษณากันซ้ำอยู่แล้ว ยังต้องทำ idempotency key เองไหม
ยังต้องทำ เพราะ event_id ทำงานที่ชั้นแพลตฟอร์มปลายทาง ไม่ได้ป้องกันไม่ให้ backend ของคุณเองประมวลผลออเดอร์ซ้ำ เช่น หักสต๊อกซ้ำ หรือส่งอีเมลยืนยันซ้ำ ซึ่งเป็นปัญหาคนละชั้นกับการนับ conversion
ใช้ UUID สุ่มเป็น idempotency key ได้ไหม
ไม่ได้ เพราะ UUID สุ่มใหม่ทุกครั้งที่ฟังก์ชันถูกเรียก คำขอซ้ำจึงได้ key คนละค่าเสมอ ต้องใช้ค่าที่คำนวณจากข้อมูลในคำขอเอง หรือใช้รหัสที่ LINE แนบมาให้กับ payload นั้นโดยเฉพาะ
Redis กับฐานข้อมูลหลัก ควรเลือกอะไรเก็บ key
ถ้า traffic สูงมากและต้องการความเร็ว Redis ตอบโจทย์กว่า แต่ถ้าทีมเล็กและ traffic ไม่ได้สูงมาก unique constraint ในฐานข้อมูลหลักที่มีอยู่แล้วก็เพียงพอ และลดความซับซ้อนของระบบที่ต้องดูแล
ทำ idempotency key แล้ว ยังต้องสนใจเรื่อง Event Match Quality อยู่ไหม
ยังต้องสนใจ เพราะเป็นคนละเรื่องกัน idempotency key ป้องกันการนับซ้ำ ส่วน match quality เกี่ยวกับความแม่นของข้อมูลที่ส่งไปให้แพลตฟอร์มจับคู่กับผู้ใช้ ทั้งสองเรื่องต้องทำคู่กันในระบบที่สมบูรณ์
ระบบเล็ก ๆ ที่มีออเดอร์วันละไม่กี่สิบรายการ จำเป็นต้องทำขนาดนี้ไหม
จำเป็นในหลักการเดียวกัน แต่ implementation ทำได้เรียบง่ายกว่ามาก แค่ unique constraint บนคอลัมน์เดียวในตารางออเดอร์ก็เพียงพอแล้ว ไม่ต้องถึงกับใช้ Redis หรือ queue ระบบซับซ้อน สิ่งที่สำคัญคือหลักคิด ไม่ใช่ขนาดของ infrastructure
บทความที่เกี่ยวข้อง


