บทความทั้งหมด
webhook designidempotencyretriesdistributed systems

การลองใหม่และ Idempotency ของ Webhook แบบไม่ต้องร้องไห้

ผู้ให้บริการ Webhook รายใหญ่ทุกราย — Stripe, GitHub, Shopify, Twilio, Slack — ส่งเหตุการณ์เดียวกันมากกว่าหนึ่งครั้งโดยตั้งใจ พวกเขาเรียกมันว่า การส่งอย่างน้อยหนึ่งครั้ง (at-least-once delivery) หาก handler ของคุณไม่ได้ออกแบบมาเพื่อสิ่งนี้ คุณจะได้ลูกค้าที่ถูกเรียกเก็บเงินซ้ำ ป้าย PR ของ GitHub ซ้ำ และการแจ้งเตือน Slack ที่ยิงสองครั้ง วิธีแก้นั้นเล็กและถูกมองข้าม: ออกแบบทุก handler ให้ปลอดภัยภายใต้การส่งซ้ำ

ทำไมจึงเกิดรายการซ้ำ

ผู้ให้บริการลองใหม่เมื่อไม่ได้รับ 2xx ที่สะอาดทันเวลา ช่วงเวลาลองใหม่แตกต่างกัน — Stripe: นานถึง 3 วันด้วย backoff แบบเอ็กซ์โพเนนเชียล, GitHub: 8 ครั้งภายใน ~8 ชั่วโมง, Shopify: 48 ชั่วโมง ส่วนใหญ่การส่งครั้งแรกสำเร็จจริง เครือข่ายแค่ทำการตอบกลับขากลับหาย ผู้ให้บริการไม่รู้ จึงลองใหม่ handler ของคุณจึงทำงานสองครั้ง

นี่ไม่ใช่บั๊กที่ต้องแก้ มันคือคุณสมบัติของระบบ จงสร้างเผื่อมันไว้

รูปแบบ idempotency ที่ใช้ได้จริง

ทุกเหตุการณ์จากผู้ให้บริการรายใหญ่มี ID เหตุการณ์ที่ไม่ซ้ำกัน Stripe: evt_xxx GitHub: header X-GitHub-Delivery (UUID) Shopify: X-Shopify-Webhook-Id เก็บ ID ไว้ในครั้งแรกที่คุณประมวลผลเหตุการณ์ เมื่อลองใหม่ ให้ค้นหามัน — หากเคยเห็นแล้ว ให้คืนค่า 200 โดยไม่รัน handler ใหม่

async function handleWebhook(event) {
  const eventId = event.id;

  // Atomic insert-if-not-exists
  const inserted = await db.processedEvents.insertIfNew({
    eventId,
    receivedAt: new Date(),
  });

  if (!inserted) {
    // We've handled this one. Be polite about it.
    return { status: 200, body: 'already processed' };
  }

  // First time we've seen it. Do the work.
  await actuallyHandleEvent(event);

  return { status: 200 };
}

รูปแบบที่สำคัญ: การ insert และตรรกะ handler ต้องเป็น atomic ร่วมกัน หรือ insert ต้องมาก่อน หากคุณเรียก handleEvent ก่อนแล้วค่อย insert ID การ crash กลางคัน handler จะไม่ทิ้งบันทึกไว้ และการลองใหม่จะประมวลผลซ้ำ

เก็บคีย์ dedup ไว้ที่ไหน

  • ทรานแซกชัน DB เดียวกับการเขียนเชิงธุรกิจ ดีที่สุด คีย์ dedup และผลข้างเคียง commit ทั้งคู่หรือไม่เลย ใช้ unique constraint บนคอลัมน์ ID เหตุการณ์
  • Redis พร้อม TTL ยาว เพียงพอสำหรับเหตุการณ์ความเสี่ยงต่ำ ปรับ TTL ให้ตรงกับช่วงลองใหม่ที่ยาวที่สุดของผู้ให้บริการใดที่คุณ integrate — เริ่มที่ 7 วัน
  • แคชในหน่วยความจำ อย่า โพรเซสรีสตาร์ท แคชหาย และการลองใหม่ครั้งถัดไปสำเร็จในการรัน handler ที่รันไปแล้วซ้ำ

"ไม่ idempotent" หน้าตาเป็นอย่างไรในทางปฏิบัติ

ตัวอย่างจริงที่เราเจอ: handler ของ Stripe invoice.payment_succeeded ที่เรียก account.credit(amount) โดยตรง การชำระเงินสำเร็จแต่ละครั้งเพิ่มยอดคงเหลือ ส่วนใหญ่หนึ่งครั้งพอดี บางครั้งสองครั้งเมื่อการลองใหม่จับเหตุการณ์เดียวกัน เราพบมันเพราะลูกค้าถามอย่างสุภาพทางอีเมลว่าทำไมใบแจ้งหนี้ 50 ดอลลาร์จึงเครดิตบัญชีเขา 100 ดอลลาร์

วิธีแก้ไม่ใช่การเปลี่ยนนโยบายลองใหม่ของ Stripe หรือสู้กับผู้ให้บริการ แต่เป็น unique index บน events_processed.stripe_event_id และ guard ที่ต้น handler แพตช์ทั้งหมดคือสิบเอ็ดบรรทัด

จังหวะเวลาของ handler ก็สำคัญ

ผู้ให้บริการมี timeout ที่เข้มงวด GitHub: 10 วินาที endpoint โต้ตอบของ Discord: 3 วินาที Stripe และ Shopify ผ่อนปรนกว่า (~30 วินาที) แต่คุณก็ยังถูกลองใหม่ถ้าช้า รูปแบบเหมือนกันไม่ว่ายังไง: ตอบเร็ว ทำงานแบบ async

async function handleWebhook(event) {
  if (await alreadyProcessed(event.id)) {
    return { status: 200 };
  }

  // Don't await the heavy work — queue it.
  await queue.enqueue('process-stripe-event', event);

  // 200 immediately. Worker picks up the event.
  return { status: 200 };
}

worker ของคิวจัดการงานช้า handler ของ Webhook โดยพื้นฐานคือตัว dedupe + ตัวผลิตคิว รูปแบบนี้ยังคงถูกต้องภายใต้การลองใหม่ เพราะตัวผลิตคิวเองก็ idempotent (insert-if-new บน ID เหตุการณ์) และการ enqueue ซ้ำใดๆ ถูกจับโดย guard เดียวกันใน worker

ทดสอบมันบนเครื่องโลคัลด้วยนะ

การทดสอบ idempotency คือสิ่งที่การรีเพลย์ของทันเนลถูกสร้างมาเพื่อมันโดยเฉพาะ จับการส่งเหตุการณ์หนึ่งครั้งใน ประวัติคำขอของทันเนล แล้วรีเพลย์มันสิบครั้งกับ handler โลคัล การเรียกครั้งแรกควรเขียนลงฐานข้อมูล อีกเก้าครั้งถัดมาควรคืนค่า 200 โดยไม่แตะมัน ดู log ของ DB เพื่อยืนยัน

นี่คือการทดสอบที่ทีมส่วนใหญ่ข้าม และที่ทีมส่วนใหญ่ไม่ควรข้าม

นโยบายการลองใหม่เฉพาะผู้ให้บริการ

  • Stripe: ลองใหม่ 3 วัน, backoff เอ็กซ์โพเนนเชียล ข้ามหลัง 16 ครั้ง
  • GitHub: 8 ครั้ง เสร็จภายใน ~8 ชั่วโมง หลังจากนั้นทำเครื่องหมายการส่งว่าล้มเหลว
  • Shopify: 48 ชั่วโมง, backoff เอ็กซ์โพเนนเชียล ปิดการสมัคร Webhook หลังล้มเหลวติดต่อกัน 19 ครั้งใน 48 ชั่วโมง
  • Twilio: ลองใหม่หนึ่งครั้งโดยค่าเริ่มต้น ตั้งได้ถึง 3 ครั้ง
  • Slack: ลองใหม่ 3 ครั้งพร้อม backoff (1 วินาที, 30 วินาที, 5 นาที)

ตั้ง TTL ของ dedup ให้เท่ากับช่วงที่ยาวที่สุดที่คุณสนใจ บวกบัฟเฟอร์

สรุป

การส่งซ้ำคือสัญญา ไม่ใช่ข้อบกพร่อง หากคุณปล่อย handler ของ Webhook ที่เรียก increment, send_email หรือ charge_card โดยไม่มีคีย์ dedup คุณปล่อยบั๊กไปแล้ว การแพตช์มันเล็กน้อย การจับได้หลังลูกค้าร้องเรียนนั้นแพง

เพิ่มเติมเรื่องการทดสอบนี้แบบ end-to-end ดู คู่มือดีบักด้วยการรีเพลย์ การตรวจสอบลายเซ็นอยู่ก่อนหน้านี้ — คู่มือลายเซ็น ครอบคลุมไว้ เข้าร่วม waitlist ของ PortPreview สำหรับทันเนล + การรีเพลย์ที่สร้างขึ้นรอบเวิร์กโฟลว์นี้พอดี

คำถามที่พบบ่อย

ทำไมผู้ให้บริการ Webhook จึงส่งเหตุการณ์เดียวกันสองครั้ง?
ผู้ให้บริการใช้การส่งอย่างน้อยหนึ่งครั้ง หากไม่ได้รับการตอบกลับ 2xx ทันเวลา พวกเขาจะลองใหม่ แม้ว่า handler เดิมจะสำเร็จจริงแต่การตอบกลับหายไป จงมองการส่งซ้ำเป็นคุณสมบัติที่รับประกันของระบบ ไม่ใช่กรณีพิเศษ
จะทำให้ handler ของ Webhook idempotent ได้อย่างไร?
ใช้ ID เหตุการณ์ที่ผู้ให้บริการส่งมา (Stripe evt_xxx, header X-GitHub-Delivery ของ GitHub, X-Shopify-Webhook-Id ของ Shopify) เป็นคีย์ dedup ใส่มันด้วย unique constraint ที่ต้น handler หาก insert ล้มเหลวเพราะมีอยู่แล้ว ให้คืนค่า 200 โดยไม่รันตรรกะธุรกิจซ้ำ
ควรเก็บคีย์ dedup ไว้นานแค่ไหน?
อย่างน้อยเท่ากับช่วงลองใหม่ที่ยาวที่สุดของผู้ให้บริการ Stripe ลองใหม่ 3 วัน Shopify 48 ชั่วโมง TTL 7 วันครอบคลุมผู้ให้บริการรายใหญ่ทั้งหมดพร้อมเผื่อ หากมีพื้นที่เก็บ ให้เก็บไว้ตลอดไป — มันมีประโยชน์ต่อการตรวจสอบด้วย