การออกแบบการส่งซ้ำ・ลำดับ・idempotency

Webhook การส่งซ้ำ idempotency ลำดับ รีทราย
บทความนี้เหมาะสำหรับใคร
สำหรับนักพัฒนาที่อิมพลิเมนต์ฝั่งรับ Webhook บทความนี้อธิบายวิธีสร้างฝั่งรับที่ทำงานถูกต้องในสถานการณ์อย่าง "อีเวนต์เดียวกันมาถึงหลายครั้ง" หรือ "อีเวนต์เก่ามาถึงหลังอีเวนต์ใหม่"

Webhook เป็นกลไกที่สะดวก แต่เครือข่ายไม่เสถียร และเซิร์ฟเวอร์ก็มีโอกาสล่ม ReceiptRoller มีกลไกที่ส่งซ้ำอัตโนมัติเมื่อ "ส่งไม่ถึง" แต่ผลที่ตามมาก็คืออาจเกิดอีเวนต์เดียวกันมาถึง 2 ครั้งขึ้นไปและลำดับสลับหน้าหลังได้ ฝั่งรับควรออกแบบโดยตั้งสมมติฐานนี้ไว้ล่วงหน้า

นโยบายการส่งซ้ำ

หากฝั่งรับไม่คืน 2xx ReceiptRoller จะพยายามส่งซ้ำแบบ exponential backoff

ครั้งที่ จังหวะการส่ง
ครั้งที่ 1ทันที
ครั้งที่ 2หลัง 1 นาที
ครั้งที่ 3หลัง 5 นาที
ครั้งที่ 4หลัง 30 นาที
ครั้งที่ 5หลัง 2 ชั่วโมง
ครั้งที่ 6หลัง 6 ชั่วโมง
ครั้งที่ 7หลัง 24 ชั่วโมง

รวมแล้วส่งซ้ำสูงสุด 7 ครั้งเป็นระยะเวลาราว 32 ชั่วโมง หากยังไม่สำเร็จ จะถูกบันทึกเป็นdead letter และตรวจสอบได้ที่ "ประวัติการส่ง Webhook" ในพอร์ทัลนักพัฒนา

เรสปอนส์ที่เข้าเงื่อนไขการส่งซ้ำ

  • 5xx(เซิร์ฟเวอร์เออเรอร์)
  • 429(rate limit)
  • ไทม์เอาต์(ไม่ตอบสนองภายใน 10 วินาที)
  • เออเรอร์การเชื่อมต่อ(DNS / TLS / TCP)

เรสปอนส์ที่ไม่เข้าเงื่อนไขการส่งซ้ำ

  • 2xx:ถือว่าสำเร็จ ไม่ส่งซ้ำ
  • 3xx:ไม่ตามรีไดเรกต์อัตโนมัติ(ถือว่าล้มเหลวแต่ก็ไม่ส่งซ้ำ)
  • 4xx(รวม 401/410):ถือว่าเป็นการปฏิเสธถาวรของฝั่งรับ ไม่ส่งซ้ำ
ข้อควรระวัง:หากฝั่งรับ "ประมวลผลล้มเหลว แต่อยากให้ลองใหม่" โปรดคืน 5xx หากคืน 4xx จะไม่ถูกส่งซ้ำ

ลำดับไม่ถูกรับประกัน

Webhook ของ ReceiptRoller ไม่รับประกันลำดับการส่ง ตัวอย่างเช่นสถานการณ์ต่อไปนี้อาจเกิดขึ้นได้

  • receipt.refunded มาถึงก่อน receipt.issued
  • product.updated มาถึงก่อน product.created
  • การส่งครั้งที่ 1 ถูกส่งซ้ำ ทำให้อีเวนต์เก่ามาถึงหลังอีเวนต์ใหม่

ในงานที่ต้องคำนึงถึงลำดับ โปรดออกแบบให้ดู occurred_at(เวลาที่อีเวนต์เกิดขึ้น)ที่อยู่ในเพย์โหลด แล้วเรียงลำดับใหม่ที่ฝั่งรับ หรือดึงสถานะล่าสุดใหม่ผ่าน API การมอง Webhook เป็น "ทริกเกอร์ว่ามีการเปลี่ยนแปลง" แทนที่จะเป็น "การแจ้งสถานะล่าสุด" จะปลอดภัยกว่า

การอิมพลิเมนต์ idempotency

การทำให้ผลลัพธ์ไม่เปลี่ยนแม้อีเวนต์เดียวกันมาถึงหลายครั้ง เรียกว่าidempotency ReceiptRoller แนบ event_id ที่ไม่ซ้ำ(และเฮดเดอร์ X-RR-Event-Id)ให้แต่ละอีเวนต์ โปรดใช้สิ่งนี้เป็นคีย์เพื่อหลีกเลี่ยงการประมวลผลซ้ำ

แพตเทิร์น 1:บันทึก ID ที่ประมวลผลแล้ว

async function handleWebhook(event) {
  // หากประมวลผลไปแล้วให้เพิกเฉย
  const exists = await db.processedEvents.findOne({ event_id: event.event_id });
  if (exists) return;

  // การประมวลผลทางธุรกิจ
  await processEvent(event);

  // บันทึก(ในทรานแซกชันเดียวกับการประมวลผลทางธุรกิจจะดีที่สุด)
  await db.processedEvents.insert({
    event_id: event.event_id,
    received_at: new Date(),
  });
}

ตั้ง TTL ให้ตารางอีเวนต์ที่ประมวลผลแล้ว เพื่อลบเรกคอร์ดเก่าอัตโนมัติ จะช่วยป้องกันไม่ให้ข้อมูลบวมขึ้น(เมื่อคำนึงว่าช่วงส่งซ้ำคือ 32 ชั่วโมง ควรเก็บไว้อย่างน้อย 7 วัน)

แพตเทิร์น 2:เขียนด้วย UPSERT

หากการประมวลผลทางธุรกิจเป็นแบบ "ทำให้เรกคอร์ดหนึ่งกลายเป็นสถานะล่าสุด" การใช้ UPSERT(INSERT or UPDATE)จะทำให้เป็น idempotent โดยธรรมชาติ

// รับอีเวนต์สต็อก แล้ว UPSERT โดยใช้ product ID เป็นคีย์
await db.inventory.upsert({
  where: { product_id: event.data.product_id },
  update: { quantity: event.data.quantity, updated_at: event.occurred_at },
  create: { product_id: event.data.product_id, quantity: event.data.quantity },
});

ในแพตเทิร์นนี้ ต้องใส่การเปรียบเทียบ occurred_at เพื่อไม่ให้เขียนทับค่าล่าสุดในกรณีที่อีเวนต์เก่ามาถึงทีหลัง

แพตเทิร์น 3:ตัดสินด้วยคีย์ธรรมชาติ

หากรู้คีย์ธรรมชาติทางธุรกิจ(เช่น receipt_id)ก็สามารถตรวจจับการซ้ำด้วยคีย์นั้นได้อย่างเรียบง่าย การใช้ร่วมกับแพตเทิร์นที่ใช้ event_id จะทำให้ทนทานยิ่งขึ้น

การจัดการเมื่อลำดับสลับ

มาตรการทั่วไปกรณีที่หลายอีเวนต์ต่อเอนทิตีเดียวกันมาถึงสลับลำดับ

  • ตัดสินการเขียนทับด้วย occurred_at:หากเป็นอีเวนต์ที่เก่ากว่าเวลาอัปเดตล่าสุดที่ฝั่งรับถืออยู่ ก็ไม่นำมาใช้
  • หมายเลขเวอร์ชัน:หากในเพย์โหลดมี version ให้เลือกใช้เฉพาะค่าที่มากกว่า
  • ตรวจสอบการเปลี่ยนสถานะ:เพิกเฉยต่อการเปลี่ยนสถานะที่เป็นไปไม่ได้ทางธุรกิจ เช่น "refunded → issued"
  • ดึงข้อมูลใหม่ผ่าน API:เมื่อได้รับการแจ้ง ให้ดึงสถานะล่าสุดใหม่ผ่าน API(จบได้โดยไม่ต้องคำนึงถึงลำดับ)

การจัดการ dead letter

อีเวนต์ที่ส่งซ้ำ 7 ครั้งแล้วยังไม่สำเร็จ จะถูกบันทึกเป็น "dead letter" และตรวจสอบได้ที่ พอร์ทัลนักพัฒนา → เอนด์พอยต์ที่เกี่ยวข้อง → แท็บ "ประวัติการส่ง" แต่ละเรกคอร์ดจะมีข้อมูลต่อไปนี้

  • อีเวนต์ ID, ชนิด, ไทม์สแตมป์
  • เรสปอนส์โค้ดและบอดี ของแต่ละครั้งที่ลอง(1KB แรก)
  • ปุ่ม "ส่งซ้ำ"

หลังแก้ไขฝั่งรับแล้ว สามารถส่งซ้ำด้วยมือด้วยปุ่ม "ส่งซ้ำ" ได้ ระยะเวลาคือ 30 วันนับจากการส่ง

แอนตี้แพตเทิร์นที่พบบ่อย

การอิมพลิเมนต์ที่มักผิดพลาด ปัญหา
เพราะประมวลผลหนัก จึงรอจนประมวลผลเสร็จ ไม่คืน 2xx หลังโยนเข้าคิวแบบอะซิงโครนัส เข้าลูปส่งซ้ำเพราะไทม์เอาต์ 10 วินาที
คืน 200 ตอนที่การประมวลผลทางธุรกิจล้มเหลว ไม่ถูกส่งซ้ำ ทำให้อีเวนต์หลุดหาย
บวก/ลบสต็อกโดยไม่มี idempotency จำนวนสต็อกเพี้ยนเพราะการส่งซ้ำ
เขียนทับแฟล็กล่าสุดโดยไม่คำนึงถึงลำดับที่สลับ อีเวนต์เก่าไปเหยียบทับสถานะใหม่
ไม่ได้มอนิเตอร์ dead letter ผ่านไปหลายวันโดยไม่รู้ตัวว่าเกิดเหตุขัดข้อง

แพตเทิร์นการรับที่แนะนำ

  1. ตรวจสอบลายเซ็น・ไทม์สแตมป์
  2. ตรวจสอบ idempotency ด้วย event_id(หากประมวลผลแล้วคืน 200 ทันที)
  3. โยนเพย์โหลดเข้าคิวภายใน(SQS / Cloud Tasks เป็นต้น)
  4. คืน 200 ทันที(ตรงนี้เป้าหมายคือภายใน 1 วินาที)
  5. ประมวลผลทางธุรกิจที่คิวเวิร์กเกอร์(รีทรายทำภายใน)

ด้วยแพตเทิร์นนี้ เอนด์พอยต์รับจะตอบสนองได้เร็วอยู่เสมอ และความล้มเหลวของการประมวลผลทางธุรกิจจะไม่นำไปสู่ลูปส่งซ้ำ

คู่มือที่เกี่ยวข้อง

วันที่เผยแพร่: 2569-04-27 วันที่อัปเดต: 2569-07-05
แท็ก
API (22) OAuth (15) Android (10) iOS (9) Webhook (8) api (7) oauth (5) getting-started (4) การลงทะเบียนแอป (4) การแก้ปัญหา (4)
บทความที่เกี่ยวข้อง