Webhook CRM trông có vẻ dễ. Không có tiền chuyển đi, không subscription nào bị xóa nếu bạn chậm. Một contact thay đổi, bạn cập nhật bản sao của mình.
Rồi một khách hàng báo rằng lifecycle stage trong app của bạn ghi Lead còn trong HubSpot ghi Customer, nó đã sai suốt hai tuần, và không sự kiện liên quan nào bị xử lý hai lần.
Webhook về bản ghi nghiệp vụ hỏng theo kiểu khác webhook thanh toán. Lưu lượng chủ yếu là cập nhật chứ không phải sự kiện mới, cùng một object thay đổi nhiều lần trong thời gian ngắn, và đảm bảo về thứ tự yếu hơn những gì người đọc payload sẽ nghĩ.
TL;DR
- “HubSpot không đảm bảo bạn sẽ nhận các thông báo này theo đúng thứ tự chúng xảy ra”
- Thông báo đến dưới dạng mảng, không phải từng object riêng lẻ. Mỗi batch có thể chứa tới 100
- Thời hạn phản hồi là 5 giây, cho cả batch. Các bên gửi khác có trong tài liệu tham khảo về các nhà cung cấp webhook
- Thông báo thất bại được thử lại (retry) tối đa 10 lần trong 24 giờ, làm khoảng thời gian có thể đảo thứ tự rộng thêm
eventIdđược tài liệu ghi là “không được đảm bảo là duy nhất”, nên dùng nó làm idempotency key (xử lý nhiều lần vẫn cho cùng một kết quả) là sai- Sắp thứ tự theo
occurredAt. Chống trùng lặp bằng khóa tổ hợp. Đừng bao giờ sắp thứ tự theo thời điểm nhận
Trường trông như câu trả lời
Quen với Stripe, phản xạ của bạn là tìm ID sự kiện, đặt unique index lên nó rồi làm việc khác. Payload của HubSpot có ngay eventId ở đó.
Tài liệu viết: eventId là “ID của sự kiện đã kích hoạt thông báo này. Giá trị này không được đảm bảo là duy nhất”.
Đó là một lời cảnh báo thẳng thắn hiếm thấy, và rất dễ đọc lướt qua. Một unique index trên eventId sẽ trông đúng khi test, và đến một lúc nào đó trên production, nó sẽ hoặc loại nhầm một sự kiện bạn cần, hoặc nhận một sự kiện bạn đã xử lý rồi. Cả hai lỗi đều không tự lên tiếng.
Hai trường bạn có thể dựa vào là occurredAt, “thời điểm sự kiện này xảy ra, dưới dạng dấu thời gian (timestamp) tính bằng mili giây”, và attemptNumber, “bắt đầu từ 0, đây là lần thử thứ mấy để thông báo sự kiện này tới dịch vụ của bạn”.
attemptNumber là chỉ số chẩn đoán hữu ích. Bất cứ giá trị nào lớn hơn 0 nghĩa là đây là một lần thử lại, tức là endpoint của bạn đã thất bại trước đó, tức là các sự kiện giờ đang đến không theo thứ tự chúng đã xảy ra.
Batch làm thay đổi hình dạng của handler
HubSpot không gửi mỗi request một thông báo: “bạn nên chờ đợi nhận một mảng object trong một request. Kích thước batch có thể thay đổi, nhưng sẽ dưới 100 thông báo”.
Có ba hệ quả.
Handler (đoạn code xử lý webhook trên server của bạn) là một vòng lặp, không phải một hàm. Thời hạn 5 giây bao trùm cả mảng. Riêng một lượt gọi database mất 30 ms cho mỗi phần tử đã vượt thời hạn ở khoảng 160 phần tử, và nếu mỗi phần tử còn kèm việc khác thì có thể vượt trước khi mảng đạt tới 100.
HTTP không có cách nào diễn đạt thất bại một phần. Nếu phần tử thứ 40 trong 90 ném lỗi, bạn không thể báo HubSpot rằng “39 phần tử đầu đã xong”. Bạn trả một status duy nhất cho cả batch. Trả mã khác 2xx thì cả 90 sẽ quay lại trong lần thử lại; trả 200 thì 50 phần tử bạn chưa xử lý tới sẽ mất.
Điều đó dẫn tới cùng một kết luận như mọi nơi khác: lưu toàn bộ mảng trước, trả 200, xử lý sau. Ghi cả batch chỉ là một lệnh insert thay vì 90, nên cũng giải quyết luôn vấn đề thời gian.
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHubSpotSignature(raw, req.headers)) {
return new Response("unauthorized", { status: 401 });
}
const notifications = JSON.parse(raw); // always an array
await db.insertBatch(notifications); // one durable write
return new Response(null, { status: 200 }); // inside 5s regardless of size
}
Vì sao thứ tự bị phá vỡ, và vì sao chống trùng lặp không sửa được
Một nhân viên kinh doanh sửa một deal bốn lần trong chín mươi giây. Bốn thông báo. Endpoint của bạn trục trặc trong chốc lát và trả lỗi cho thông báo thứ hai. HubSpot thử lại nó “tối đa 10 lần”, với các lần thử lại “rải rác trong 24 giờ tiếp theo, với độ trễ khác nhau giữa các request”.
Vậy là thông báo thứ hai đến sau thông báo thứ ba và thứ tư. Handler của bạn áp dụng nó. Stage của deal giờ là giá trị ở lần sửa thứ hai, và sẽ giữ nguyên như vậy cho đến khi ai đó động vào bản ghi lần nữa.
Không thông báo nào bị xử lý hai lần. Chống trùng lặp hoạt động hoàn hảo. Dữ liệu vẫn sai.
Đây là sự phân biệt quan trọng: idempotency bảo vệ bạn khỏi việc xử lý cùng một sự kiện hai lần. Nó không hề bảo vệ bạn khỏi việc áp một trạng thái cũ hơn lên trên trạng thái mới hơn. Đó là hai lỗi khác nhau với hai cách sửa khác nhau, và luồng dữ liệu CRM gây ra lỗi thứ hai thường xuyên hơn hẳn luồng thanh toán, vì cùng một object được cập nhật liên tục.
Cách sửa là một điều kiện đơn điệu (monotonic) ngay trong lệnh ghi.
UPDATE contacts
SET lifecycle_stage = $1, hs_updated_at = $2
WHERE hubspot_id = $3
AND hs_updated_at < $2; -- refuse to apply stale state
Dùng occurredAt cho $2, không dùng NOW() của bạn. Đồng hồ của bạn ghi lại lúc bạn nhận được, mà thứ tự nhận chính là thứ không đáng tin. Phần bàn rộng hơn, gồm cả cách làm với state machine có invariant thực sự, nằm trong bài Bạn nhận cùng một webhook hai lần.
Một khóa chống trùng lặp thực sự đứng vững
Vì không dùng được eventId, hãy tạo một khóa tổ hợp từ các trường mà khi gộp lại sẽ xác định đúng một thay đổi:
CREATE TABLE processed_hubspot (
object_id BIGINT NOT NULL,
subscription_type TEXT NOT NULL,
occurred_at BIGINT NOT NULL,
PRIMARY KEY (object_id, subscription_type, occurred_at)
);
Một object, một loại thay đổi, một mili giây. Hai lần sửa thực sự khác nhau trên cùng một trường trong cùng một mili giây sẽ va chạm, một rủi ro có thật nhưng không đáng kể với các thao tác CRM do người thực hiện, và là rủi ro thật sự với import hàng loạt. Với subscription thay đổi thuộc tính, thêm propertyName vào khóa: subscriptionType giống nhau bất kể thuộc tính nào thay đổi, nên nếu thiếu nó, hai thuộc tính khác nhau thay đổi trong cùng một mili giây cũng sẽ va chạm.
Điều này cần nói thẳng, vì nó kém hơn những gì Stripe cho bạn. Với ID sự kiện duy nhất do nhà cung cấp cấp, chống trùng lặp là chính xác. Với khóa tổ hợp, nó là một heuristic rất tốt. Ghi lại lựa chọn này cạnh định nghĩa bảng để người tiếp theo biết đó là cố ý.
Import hàng loạt là bài kiểm tra sức chịu đựng
Một khách hàng import 50.000 contact sẽ tạo ra 50.000 thông báo trong thời gian ngắn, được gom thành các mảng. Đây là lúc mọi thứ ở trên đồng loạt thôi là lý thuyết: batch lớn nhất, database bận nhất, thời hạn 5 giây chặt nhất, và mọi lỗi đều sinh ra các lần thử lại đến muộn hàng giờ, đan xen với các thao tác sửa đang diễn ra.
Nếu bạn chỉ test bằng từng sự kiện tự tạo bằng tay, bạn chưa test trường hợp sẽ gây hỏng. Tự gửi cho mình một batch 100 thông báo với dấu thời gian cố tình xáo trộn, và kiểm tra trạng thái cuối cùng là của thông báo có occurredAt mới nhất, không phải của thông báo đến sau cùng.
AnyHook giúp được gì
Vấn đề thứ tự không nằm ở tầng truyền tải. Nếu HubSpot gửi thông báo thứ hai sau thông báo thứ tư, không dịch vụ trung chuyển webhook (webhook relay) nào có thể rút lại việc đó, và sản phẩm nào tuyên bố ngược lại là đang nói quá.
Thứ mà một relay thay đổi là mọi thứ xung quanh nó.
Trỏ HubSpot tới in.anyhook.net/you/hubspot và AnyHook trả 200 sau khoảng một giây bất kể kích thước batch, nên thời hạn 5 giây không còn ràng buộc cách bạn xử lý. Lượt gửi tới endpoint của bạn được thử lại theo lịch riêng chứ không theo lịch của HubSpot, nên một sự cố ngắn không nới rộng khoảng thời gian đảo thứ tự thêm 24 giờ.
Ngoài ra:
- Mọi batch đều được lưu kèm header và body trước khi thực hiện lần gửi nào, nên câu hỏi “chúng ta thực sự đã nhận được gì, và theo thứ tự nào” trả lời được từ nhật ký thay vì phải dựng lại từ trạng thái hiện tại của database
- Phát lại (replay) mọi sự kiện thất bại trong một khoảng thời gian giúp bạn chạy lại những gì một đợt import đã mất với handler đã sửa, thay vì nhờ khách hàng import lại
attemptNumberlớn hơn 0 không còn là manh mối duy nhất cho thấy có gì đó trục trặc, vì các lượt gửi thất bại được đếm và cảnh báo ở mốc 1, 5 và 20
Handler của bạn vẫn cần điều kiện kiểm tra phiên bản. Phần đó là việc của bạn.
Điều cần nhớ
Mảng CRM đòi hỏi những phản xạ khác với mảng thanh toán. Đọc tài liệu về các trường trước khi đánh index bất cứ thứ gì, vì HubSpot thẳng thắn hiếm thấy khi nói rằng ID sự kiện của họ không gánh nổi trọng trách bạn muốn đặt lên nó. Sắp thứ tự theo occurredAt, chống trùng lặp bằng một khóa tổ hợp bạn chọn có chủ đích, và bảo vệ mọi lệnh ghi khỏi trạng thái cũ.
Nếu handler của bạn hiện đang chạy UPDATE ... WHERE id = $1 mà không có điều kiện dấu thời gian, câu hỏi không phải là nó đã ghi dữ liệu cũ hay chưa. Câu hỏi là đã có ai nhận ra hay chưa.