Tất cả bài viết
webhook designidempotencyretriesdistributed systems

Retry và idempotency của webhook không nước mắt

Mọi nhà cung cấp webhook lớn — Stripe, GitHub, Shopify, Twilio, Slack — đều cố ý gửi cùng một sự kiện nhiều hơn một lần. Họ gọi đó là gửi ít nhất một lần (at-least-once delivery). Nếu handler của bạn không được thiết kế cho điều đó, bạn sẽ có khách bị tính phí đôi, nhãn PR GitHub trùng, và thông báo Slack bắn hai lần. Cách sửa thì nhỏ và bị đánh giá thấp: thiết kế mọi handler an toàn khi bị gửi lặp lại.

Vì sao có bản trùng

Nhà cung cấp retry khi không nhận được một 2xx sạch đúng hạn. Cửa sổ retry khác nhau — Stripe: tới 3 ngày với backoff lũy thừa, GitHub: 8 lần thử trong ~8 giờ, Shopify: 48 giờ. Phần lớn lần gửi gốc thực ra đã thành công; mạng chỉ làm mất phản hồi trên đường về. Nhà cung cấp không biết điều đó. Nên nó retry. Nên handler của bạn chạy hai lần.

Đây không phải bug cần sửa; đó là một thuộc tính của hệ thống. Hãy xây dựng cho nó.

Mẫu idempotency thực sự hiệu quả

Mỗi sự kiện từ một nhà cung cấp lớn đều có một ID sự kiện duy nhất. Stripe: evt_xxx. GitHub: header X-GitHub-Delivery (một UUID). Shopify: X-Shopify-Webhook-Id. Lưu ID lần đầu bạn xử lý sự kiện. Khi retry, tra cứu nó — nếu đã thấy, trả về 200 mà không chạy lại 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 };
}

Hình thức quan trọng: insert và logic handler phải atomic cùng nhau, hoặc insert phải đến trước. Nếu bạn gọi handleEvent trước rồi mới insert ID, một crash giữa chừng handler sẽ không để lại bản ghi và lần retry sẽ xử lý lại.

Lưu khóa dedup ở đâu

  • Cùng transaction DB với ghi nghiệp vụ. Tốt nhất. Khóa dedup và side-effect hoặc commit cả hai hoặc không cái nào. Dùng ràng buộc unique trên cột ID sự kiện.
  • Redis với TTL dài. Ổn cho sự kiện ít rủi ro. Khớp TTL với cửa sổ retry dài nhất của bất kỳ nhà cung cấp nào bạn tích hợp — bắt đầu từ 7 ngày.
  • Cache trong bộ nhớ. Đừng. Tiến trình khởi động lại, cache biến mất, và lần retry kế tiếp chạy lại thành công một handler đã chạy rồi.

"Không idempotent" trông như thế nào trong thực tế

Một ví dụ thật chúng tôi gặp: một handler Stripe invoice.payment_succeeded gọi account.credit(amount) trực tiếp. Mỗi thanh toán thành công cộng số dư. Phần lớn đúng một lần. Thi thoảng hai lần khi một retry bắt cùng một sự kiện. Chúng tôi phát hiện vì một khách lịch sự gửi email hỏi vì sao hóa đơn 50 đô của anh ấy lại cộng 100 đô vào tài khoản.

Cách sửa không phải đổi chính sách retry của Stripe hay đấu với nhà cung cấp. Đó là một index unique trên events_processed.stripe_event_id và một guard ở đầu handler. Toàn bộ bản vá là mười một dòng.

Thời gian của handler cũng quan trọng

Nhà cung cấp có timeout cứng. GitHub: 10 giây. Endpoint tương tác của Discord: 3 giây. Stripe và Shopify khoan dung hơn (~30 giây) nhưng bạn vẫn bị retry nếu chậm. Mẫu thì giống nhau bất kể: phản hồi nhanh, làm việc nặng 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 của queue lo phần chậm. Handler webhook về cơ bản là một bộ khử trùng + một producer của queue. Mẫu này vẫn đúng khi retry vì bản thân producer của queue là idempotent (insert-if-new theo ID sự kiện), và mọi lần enqueue trùng đều bị cùng một guard trong worker bắt.

Hãy kiểm thử cục bộ đi

Kiểm thử idempotency chính là điều mà replay qua tunnel được tạo ra để làm. Bắt một lần gửi sự kiện trong lịch sử request của tunnel và replay nó mười lần với handler cục bộ. Lần gọi đầu nên ghi vào cơ sở dữ liệu. Chín lần sau nên trả về 200 mà không đụng tới. Xem log DB để xác nhận.

Đây là bài kiểm thử mà phần lớn nhóm bỏ qua, và phần lớn nhóm không nên bỏ qua.

Chính sách retry theo từng nhà cung cấp

  • Stripe: retry 3 ngày, backoff lũy thừa. Bỏ sau 16 lần thử.
  • GitHub: 8 lần thử, hoàn tất trong ~8 giờ. Sau đó đánh dấu lần gửi là thất bại.
  • Shopify: 48 giờ, backoff lũy thừa. Vô hiệu hóa đăng ký webhook sau 19 lần thất bại liên tiếp trong 48 giờ.
  • Twilio: một lần retry mặc định, cấu hình được tới 3.
  • Slack: 3 lần retry với backoff (1 giây, 30 giây, 5 phút).

Đặt TTL dedup bằng cửa sổ dài nhất mà bạn quan tâm, cộng một khoảng đệm.

Điểm mấu chốt

Gửi trùng là một hợp đồng, không phải lỗi. Nếu bạn đã ship một handler webhook gọi increment, send_email hay charge_card mà không có khóa dedup, bạn đã ship một bug. Vá nó thì nhỏ. Phát hiện sau khi khách phàn nàn thì đắt.

Để biết thêm về kiểm thử đầu-cuối điều này, hướng dẫn gỡ lỗi bằng replay bàn về quy trình. Xác minh chữ ký nằm trước bước này — hướng dẫn chữ ký bàn về nó. Tham gia danh sách chờ PortPreview để có tunnel + replay được xây quanh đúng quy trình này.

Câu hỏi thường gặp

Vì sao nhà cung cấp webhook gửi cùng một sự kiện hai lần?
Nhà cung cấp dùng cơ chế gửi ít nhất một lần. Nếu không nhận được phản hồi 2xx đúng hạn, họ retry — kể cả khi handler gốc thực sự thành công nhưng phản hồi bị mất. Hãy coi gửi trùng là tính năng được bảo đảm của hệ thống, không phải trường hợp biên.
Làm sao để handler webhook trở nên idempotent?
Dùng ID sự kiện mà nhà cung cấp gửi (Stripe evt_xxx, header X-GitHub-Delivery của GitHub, X-Shopify-Webhook-Id của Shopify) làm khóa khử trùng. Insert nó với ràng buộc unique ở đầu handler; nếu insert thất bại vì đã tồn tại, trả về 200 mà không chạy lại logic nghiệp vụ.
Nên giữ khóa dedup bao lâu?
Ít nhất bằng cửa sổ retry dài nhất của nhà cung cấp. Stripe retry 3 ngày, Shopify 48 giờ. TTL 7 ngày bao phủ mọi nhà cung cấp lớn có dư. Nếu có dung lượng, giữ mãi mãi — nó cũng hữu ích cho việc audit.