Độ tin cậy6 tháng 8, 20267 phút đọc

Trùng lịch hẹn: một lần thử lại webhook trông ra sao với người thật

Tranh luận về idempotency thường dừng ở một dòng database bị trùng. Webhook đặt lịch là nơi nó dừng ở cảnh hai người cùng đến cho lịch hẹn lúc 10:00.

MỘT LỊCH HẸN, ĐƯỢC GỬI HAI LẦNinvitee.created200 quá chậmhandler của bạnghi khung giờlịch10:00 đã được đặtthử lạicùng lịch hẹnlịch10:00 bị đặt hai lầnhai người,một khung giờxung đột không nằm trong database của bạn. nó nằm trong buổi sáng của ai đó.
Một lượt đặt lịch, hai lượt gửi, hai mục trên lịch. Bản trùng không nằm trong một bảng, mà nằm trong buổi sáng của ai đó.

Phần lớn các bài viết về idempotency (xử lý nhiều lần vẫn cho cùng một kết quả) của webhook, kể cả bài của chúng tôi, lập luận từ database đi ra. Bạn có một dòng bị trùng, một biên lai thứ hai, một bộ đếm bị nhân đôi. Những vấn đề có thật, nhưng trừu tượng. Người đọc gật gù rồi đọc tiếp chuyện khác.

Tích hợp đặt lịch là nơi lập luận đó thôi trừu tượng. Bản trùng không phải một dòng. Nó là hai người trong một cuộc gọi video lúc 10:00, cả hai đều nghĩ khung giờ đó là của mình, và một trong hai đã xin nghỉ buổi sáng để đến.

TL;DR

  • Webhook đặt lịch có timeout rộng rãi, nên tạo cảm giác an toàn
  • Hậu quả của một lần thử lại (retry) là một xung đột ngoài đời thực mà con người phải giải quyết, không phải một dòng dữ liệu bạn có thể dọn đi
  • Calendly ký bằng Calendly-Webhook-Signature, một cặp dấu thời gian (timestamp) và chữ ký, HMAC-SHA256 trên timestamp.body
  • Chống trùng lặp dựa trên URI hoặc UUID của chính lượt đặt lịch, không dựa trên nội dung payload, vì một lần dời lịch trông rất giống một lượt đặt lịch mới
  • Hủy lịch và dời lịch đến sai thứ tự mới là trường hợp thực sự làm hỏng lịch

Vì sao nhóm webhook này có vẻ an toàn nhưng thực ra không

Phần lớn những gì khiến các nhóm webhook khác đáng sợ đều không có ở đây. Không có thời hạn phản hồi năm giây như của Shopify. Không subscription nào bị xóa sau một buổi chiều tồi tệ. Lượng đặt lịch của hầu hết sản phẩm đủ thấp để bạn không bao giờ chạm giới hạn tốc độ (rate limit).

Vậy nên handler (đoạn code xử lý webhook trên server của bạn) được viết vội, test với một lượt đặt lịch rồi đưa lên production.

Rủi ro nằm ở phía bên kia. Khi một webhook thanh toán bị xử lý hai lần, bạn nhận ra trong dữ liệu của chính mình và sửa trước khi bất kỳ ai bên ngoài bị ảnh hưởng. Khi một webhook đặt lịch bị xử lý hai lần, bản thứ hai hiện ra ngay trước mắt khách hàng, và đến lúc bạn phát hiện thì thiệt hại đã là một vụ trùng lịch chứ không còn là dữ liệu không nhất quán.

MỘT LỊCH HẸN, ĐƯỢC GỬI HAI LẦNinvitee.created200 quá chậmhandler của bạnghi khung giờlịch10:00 đã được đặtthử lạicùng lịch hẹnlịch10:00 bị đặt hai lầnhai người,một khung giờxung đột không nằm trong database của bạn. nó nằm trong buổi sáng của ai đó.

Trình tự cụ thể

Một người được mời đặt lịch lúc 10:00. Calendly bắn invitee.created.

Handler của bạn ghi khung giờ, rồi gọi nhà cung cấp lịch để tạo sự kiện, rồi gửi email xác nhận. Sáng hôm đó lệnh gọi tới dịch vụ lịch chậm. Toàn bộ quá trình mất đủ lâu để lượt gửi bị ghi nhận là thất bại.

Calendly thử lại. Handler chạy lại. Không có gì trong nó biết lần chạy đầu đã xong, vì lần chạy đầu hoàn tất sau khi response đã bị mất. Hai mục trên lịch, hai email xác nhận, và một khung giờ mà logic kiểm tra lịch trống của bạn giờ tin là đã bị đặt hai lần.

Từng bước riêng lẻ đều chạy đúng. Handler không có bug nào mà unit test có thể tìm ra.

Chống trùng lặp dựa trên lượt đặt lịch, không dựa trên payload

Phản xạ hiển nhiên là băm payload và coi các giá trị băm trùng nhau là bản trùng.

Hãy dùng định danh mà nhà cung cấp gán cho chính lượt đặt lịch. Payload của Calendly mang một URI định danh duy nhất người được mời và sự kiện đã lên lịch. Cal.com và Acuity có thứ tương đương. Định danh đó giữ nguyên qua các lần thử lại của cùng một lượt gửi, và khác đi với một lượt đặt lịch thực sự khác.

CREATE TABLE processed_bookings (
  booking_uri TEXT NOT NULL,
  event_type  TEXT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (booking_uri, event_type)
);
const { rowCount } = await db.query(
  `INSERT INTO processed_bookings (booking_uri, event_type, occurred_at)
   VALUES ($1, $2, $3) ON CONFLICT (booking_uri, event_type) DO NOTHING`,
  [body.payload.uri, body.event, body.created_at],
);
if (rowCount === 0) return new Response(null, { status: 200 });

Để ý khóa tổ hợp trên (booking_uri, event_type) thay vì chỉ URI. Cùng một lượt đặt lịch hoàn toàn có thể sinh ra invitee.created rồi sau đó invitee.canceled, và bạn muốn cả hai, mỗi cái đúng một lần.

Hủy lịch đến sai thứ tự là bug tệ hơn

Bản trùng tạo ra xung đột mà ai đó nhận ra ngay. Lỗi thứ tự tạo ra một cuốn lịch sai một cách âm thầm.

Ai đó đặt lịch lúc 10:00 và hủy bốn phút sau. Lượt gửi invitee.created thất bại và được thử lại. Lượt gửi invitee.canceled thành công trước. Handler của bạn hủy một lượt đặt lịch chưa tồn tại, ghi một cảnh báo vào log rồi đi tiếp. Sau đó lần thử lại đến nơi và tạo lượt đặt lịch.

Khung giờ giờ bị chiếm bởi một cuộc họp không ai đến, và không có lỗi nào được báo ở đâu cả. Chống trùng lặp không liên quan: không sự kiện nào đến hai lần. Chúng đến sai thứ tự.

Cách sửa vẫn là điều kiện đơn điệu (monotonic) dùng cho subscription và bản ghi CRM, áp dụng trên dấu thời gian của chính lượt đặt lịch thay vì thời điểm sự kiện đến chỗ bạn:

UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
  AND updated_at < $2;

Với một state machine nhỏ như thế này, kiểm tra chuyển trạng thái một cách tường minh còn tốt hơn. Một lượt đặt lịch đã ở trạng thái canceled không được chuyển sang active khi một sự kiện tạo mới đến muộn, và việc từ chối đó phải được ghi log rõ ràng thay vì âm thầm. Phần bàn tổng quát nằm trong bài Bạn nhận cùng một webhook hai lần.

Xác minh chữ ký

Calendly gửi header Calendly-Webhook-Signature chứa một dấu thời gian và một chữ ký, cách nhau bởi dấu phẩy. Payload được ký gồm dấu thời gian, một dấu chấm và raw body của request, băm bằng HMAC-SHA256 với signing key bạn nhận được khi tạo subscription.

Nếu cấu trúc đó trông quen, thì đó chính là cách Stripe dùng (tài liệu tham khảo về các nhà cung cấp webhook cho thấy những nhà cung cấp nào dùng chung), và nó đi kèm đúng hai yêu cầu: băm các byte thô thay vì một object đã serialize lại, và so sánh trong thời gian hằng định (constant time). Chi tiết cho từng nhà cung cấp có trong bài Xác minh chữ ký webhook của Stripe, GitHub và Shopify.

Từ chối các request thiếu header chữ ký thay vì mặc định cho qua. Một endpoint đặt lịch chấp nhận POST không có chữ ký sẽ cho phép bất kỳ ai trên internet lấp đầy lịch của bạn.

Để chính cuốn lịch làm ràng buộc

Cách sửa bền vững nhất trong nhóm này hoàn toàn không nằm trong handler webhook. Nó là một ràng buộc duy nhất (uniqueness constraint) ở cấp tài nguyên:

CREATE UNIQUE INDEX one_booking_per_slot
  ON bookings (resource_id, starts_at)
  WHERE status = 'active';

Giờ một lượt gửi trùng không thể tạo ra lịch hẹn bị trùng ngay cả khi mọi lớp khác đều hỏng, vì database từ chối nó. Handler bắt lỗi vi phạm ràng buộc và coi đó là bản trùng chứ không phải lỗi.

Điều này đáng làm ngay cả khi idempotency đã đúng, vì nó chuyển sự đảm bảo từ “handler của chúng ta cẩn thận” sang “schema khiến điều đó không thể xảy ra”. Đó là hai mức độ chắc chắn khác nhau, và với thứ mà khách hàng trải nghiệm trực tiếp, bạn muốn mức thứ hai.

AnyHook giúp được gì

Thử lại là một hành vi ở tầng truyền tải, và lý do handler của bạn thấy sự kiện hai lần là lượt gửi đầu tiên không được xác nhận kịp thời.

Trỏ webhook subscription tới in.anyhook.net/you/scheduling. AnyHook lưu sự kiện tại edge (lớp máy chủ biên của Cloudflare) và xác nhận sau khoảng một giây, nên một API lịch chậm ở phía bạn không bao giờ gây ra lượt gửi thất bại, nghĩa là lần thử lại của nhà cung cấp không bao giờ được kích hoạt và bản trùng đó không bao giờ tới được handler của bạn.

  • Lượt gửi tới endpoint của bạn được thử lại theo exponential backoff (khoảng chờ tăng theo cấp số nhân), giãn cách để hệ thống đang hồi phục có khoảng thở thay vì bị dồn ép
  • Mọi sự kiện đặt lịch đều được lưu trước khi gửi, nên câu hỏi “chúng ta có nhận được lệnh hủy không” trả lời được từ nhật ký thay vì phải suy ra từ một cuốn lịch có thể đang sai
  • Phát lại (replay) theo khoảng thời gian chạy lại một quãng bị hỏng với handler đã sửa, mà với đặt lịch nghĩa là sửa được cuốn lịch thay vì phải email cho khách để đặt lại
  • Cảnh báo lỗi khi có 1, 5 và 20 lần thất bại liên tiếp, mà với nhóm này là khác biệt giữa việc biết chuyện từ hệ thống giám sát và biết chuyện từ người đã đến tận nơi

Handler của bạn vẫn cần khóa chống trùng lặp, điều kiện kiểm tra phiên bản, và lý tưởng nhất là cả unique index. Một dịch vụ trung chuyển webhook (webhook relay) loại bỏ lần thử lại của nhà cung cấp, chứ không loại bỏ yêu cầu phải xử lý đúng khi có lần thử lại xảy ra.

Điều cần nhớ

Đặt lịch là nhóm mà tính đúng đắn của webhook thôi là chuyện kỹ thuật và trở thành chuyện khách hàng nhìn thấy. Timeout đủ thoải mái để chẳng có gì buộc bạn phải nghĩ tới nó, cho đến đúng lúc hai người cùng ở một khung giờ.

Chống trùng lặp dựa trên cả định danh lượt đặt lịch lẫn loại sự kiện. Bảo vệ lệnh ghi bằng dấu thời gian của nhà cung cấp, không phải của bạn. Đặt unique index trên khung giờ để schema bắt được những gì handler bỏ sót. Và đối xử với một lệnh hủy đến muộn giống như một khoản hoàn tiền đến muộn, vì một cuốn lịch sai âm thầm tốn nhiều công sửa hơn một dòng dữ liệu sai.

Tất cả bài viết6 tháng 8, 2026 · 7 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.