Khách trả tiền một lần nhưng nhận hai email. Hoặc một đơn hàng được giao hai lần, hoặc hai người cùng đến cho một lịch hẹn lúc 10:00. Bạn lục log và thấy cùng một event.id được xử lý lúc 14:02:07 rồi lại lúc 14:02:41, và phản xạ đầu tiên là nghĩ nhà cung cấp bị lỗi.
Nhà cung cấp không hề lỗi. Nó đang làm đúng những gì đã cam kết. Stripe, Shopify, Twilio và hầu hết các bên gửi webhook đáng kể khác đều đảm bảo gửi ít nhất một lần (at-least-once), và “ít nhất một lần” là một thuật ngữ chính xác: nó có nghĩa bản trùng là một phần của cam kết. Xây hệ thống như thể việc gửi là đúng một lần (exactly-once) mới là bug thật sự, và đó là loại bug chỉ lộ ra trên production, thường vào đúng giờ bận rộn nhất của bạn.
TL;DR
- Ít nhất một lần nghĩa là sớm muộn gì bản trùng cũng chắc chắn xảy ra, không phải hiếm khi
- Gửi đúng một lần là thứ không mua được với bất kỳ giá nào; xử lý đúng một lần thì có, và bạn tự xây nó ở phía mình
- Một unique index trên ID sự kiện của nhà cung cấp giải quyết 90% vấn đề chỉ với một dòng SQL
- Các side effect không nằm trong transaction (email, API bên thứ ba) cần cơ chế claim-then-commit
- Sự kiện đến sai thứ tự là vấn đề anh em mà không ai tính trước, và nó cần kiểm tra phiên bản, không phải chống trùng lặp
Vì sao không ai bán cơ chế đúng một lần
Hãy đặt mình vào vị trí bên gửi. Nó POST sự kiện của bạn, và kết nối TCP đứt trước khi response quay về. Có thể server của bạn đã xử lý xong và ACK bị mất, cũng có thể server đã chết trước khi kịp đụng vào nó. Nhìn từ bên ngoài, hai trạng thái đó giống hệt nhau.
Bên gửi có đúng hai lựa chọn. Thử lại (retry), và nếu bạn đã xử lý rồi thì giờ bạn có một bản trùng. Hoặc không thử lại, và nếu bạn chưa từng xử lý thì sự kiện mất vĩnh viễn.
Thế khó chỉ có vậy, và không có cánh cửa thứ ba. Hầu hết nhà cung cấp chọn thử lại, vì một thông báo thanh toán bị trùng là phiền toái bạn có thể xử lý bằng kỹ thuật, còn một thông báo thanh toán bị mất là doanh thu thất thoát âm thầm mà bạn không phát hiện được. Họ đã chọn đúng. Chỉ có điều, việc chống trùng lặp phải nằm ở đâu đó, và nơi duy nhất có đủ ngữ cảnh là database của bạn.
Nguồn gốc của bản trùng trong thực tế, xếp gần đúng theo mức độ chúng tôi thường gặp:
- Response của bạn chậm và bên gửi bị timeout, nhưng handler (đoạn code xử lý webhook trên server của bạn) vẫn chạy xong
- Một load balancer hoặc proxy đứng trước app trả 502 sau khi app của bạn đã trả 200
- Handler chạy thành công, rồi ném lỗi lúc dọn dẹp, và framework biến nó thành 500
- Một lần deploy kill process sau khi ghi nhưng trước khi trả response
- Bạn bấm “resend” trong một dashboard rồi quên mất
Để ý rằng bốn trong năm trường hợp trên là công việc đã xong nhưng xác nhận bị mất. Đó là trường hợp bình thường, không phải ngoại lệ hiếm gặp.
Cách 1: Unique index trên ID sự kiện
Nếu chỉ làm một việc, hãy làm việc này.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
[event.id, "stripe"],
);
if (rowCount === 0) return new Response(null, { status: 200 }); // already handled
await doTheWork(event);
Có hai chi tiết mọi người hay làm sai.
Trả 200 cho bản trùng, không trả 409. Một bản trùng mà bạn bỏ qua đúng cách là một lần thành công từ góc nhìn của bên gửi. Trả lỗi sẽ khiến bên gửi thử lại đúng thứ bạn vừa bảo là đã có, và bạn đã tự tạo ra một vòng lặp.
ID phải là của nhà cung cấp, không phải của bạn: event.id của Stripe, X-GitHub-Delivery của GitHub, X-Shopify-Webhook-Id của Shopify. Băm body của payload là một cách thay thế hấp dẫn nhưng là cái bẫy: khi bên gửi không đặt ID trong body, hai sự kiện thực sự khác nhau có thể có body giống nhau đến từng byte. Hai đơn hàng cùng số tiền, cùng khách hàng, cách nhau một giây, là hai đơn hàng thật. Chống trùng lặp bằng giá trị băm của body, và bạn vừa từ chối thực hiện một đơn hàng mà khách đã trả tiền.
Khi nhà cung cấp không đưa ID nào, bạn phải tự tạo một khóa tổng hợp từ những trường nghiệp vụ thực sự duy nhất. Đó là một vị trí tệ hơn, và đáng để lại một ghi chú trong code giải thích lựa chọn đó.
Cách 2: Ghi dấu đã xử lý trong cùng transaction với công việc
Cách 1 có một lỗ hổng. Nếu lệnh insert commit rồi doTheWork ném lỗi, bạn đã đánh dấu sự kiện là đã xử lý mà không hề xử lý nó. Lần thử lại bị bỏ qua và sự kiện mất âm thầm, một kiểu lỗi tệ hơn hẳn bản trùng mà bạn đang cố ngăn.
Khi công việc là thao tác database, hãy bịt lỗ hổng bằng một transaction:
await db.transaction(async (tx) => {
const { rowCount } = await tx.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[event.id, "stripe"],
);
if (rowCount === 0) return; // duplicate, commit an empty tx
await tx.query(`UPDATE orders SET status = 'paid' WHERE id = $1`, [orderId]);
});
Hoặc cả hai cùng được ghi, hoặc không cái nào. Đây là cấu trúc đúng và bạn nên dùng nó làm mặc định.
Cách 3: Claim-then-commit cho side effect không rollback được
Transaction không kéo dài tới Resend, Salesforce hay một API vận chuyển. Email đã gửi là đã gửi. Ở đây, cách làm trung thực là một bản ghi đánh dấu hai pha:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'processing' | 'done'
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
Giành quyền xử lý (claim) sự kiện trước khi làm bất cứ việc gì. Làm công việc. Đánh dấu là xong. Một bản trùng đồng thời đến giữa chừng sẽ thấy processing và lùi lại thay vì chạy đua với bạn.
Phần cần phải quyết định: những dòng bị kẹt ở processing vì process chết giữa claim và commit thì sao? Bạn không thể biết email đã gửi đi hay chưa. Cả hai lựa chọn đều có lý, và bạn nên chọn một cách có chủ đích:
// Rows stuck in 'processing' >15min are re-claimed. Biases toward
// a possible duplicate email over a definitely-missing one. Flip this for
// side effects where duplication costs money (charges, shipments).
const STALE_CLAIM_MS = 15 * 60 * 1000;
Với biên lai và thông báo, claim lại là đúng, vì thêm một email chẳng tốn bao nhiêu. Với bất cứ thứ gì chuyển tiền hoặc hàng hóa, cứ để nó kẹt và báo cho một người xử lý. Ghi rõ bạn đã chọn cách nào, và vì sao, trong một comment cạnh hằng số sẽ giúp người tiếp theo khỏi mất một giờ rối rắm.
Cách 4: Kiểm tra phiên bản cho sự kiện đến sai thứ tự
Chống trùng lặp không giúp được gì ở đây, và đây là lỗi thực sự làm hỏng dữ liệu.
Thử lại nghĩa là sự kiện #2 có thể vượt lên trước sự kiện #1, và chính lịch backoff tạo ra khoảng hở đó: một lần gửi phải chờ năm phút sẽ đến sau mọi thứ được gửi trong năm phút đó. Một subscription được cập nhật thành active lúc 14:00:01 và thành cancelled lúc 14:00:03. Lần gửi đầu tiên thất bại và được thử lại năm phút sau. Database của bạn giờ ghi active, và khách hàng vẫn giữ quyền truy cập vào thứ họ đã hủy. Không sự kiện nào bị xử lý hai lần. Kết quả vẫn sai.
Idempotency (xử lý nhiều lần vẫn cho cùng một kết quả) không giúp được gì ở đây. Thứ bạn cần là một điều kiện đơn điệu (monotonic) trong mệnh đề WHERE:
UPDATE subscriptions
SET status = $1, updated_at = $2
WHERE id = $3
AND updated_at < $2; -- refuse to apply stale state
Dùng bất kỳ trường đơn điệu nào nhà cung cấp đưa cho bạn: một trường version, một updated_at lấy từ chính đối tượng. Đừng dùng NOW() của bạn, vì nó phản ánh lúc bạn nhận được sự kiện, và đó chính là thứ tự bị sai.
Một số nhà cung cấp không đưa cho bạn cả ID duy nhất lẫn thứ tự. Tài liệu của HubSpot ghi rõ eventId không được đảm bảo là duy nhất, và HubSpot gom sự kiện thành batch mà không cam kết thứ tự; nên chống trùng lặp dựa trên gì là một vấn đề riêng.
Với các state machine có invariant thực sự, hãy đi xa hơn và từ chối thẳng các chuyển trạng thái không hợp lệ thay vì dựa vào dấu thời gian (timestamp). Chuyển từ expired sang active phải ném lỗi, không được âm thầm áp dụng.
Đôi lời về việc kiểm thử
Bạn không thể tìm ra những bug này bằng cách gửi một webhook rồi kiểm tra kết quả. Những test đáng viết là:
- Gửi cùng một sự kiện hai lần liên tiếp. Assert chỉ có một side effect
- Gửi cùng một sự kiện hai lần đồng thời. Đây là test tìm ra unique index bị thiếu, còn test tuần tự sẽ không bao giờ fail
- Gửi hai sự kiện sai thứ tự. Assert trạng thái mới hơn thắng
- Kill process giữa lúc ghi bản ghi đánh dấu và lúc thực hiện side effect. Assert đúng điều bạn đã quyết định ở cách 3
Test đồng thời mới là test có giá trị. Phần lớn code idempotency được viết với giả định sự kiện đến tuần tự, và sụp đổ ngay lần đầu tiên hai lần thử lại rơi vào hai instance trong cùng một mili giây. Chúng tôi đã viết thêm về khoảng cách giữa test webhook chạy qua và code webhook chạy đúng trong bài Test chữ ký webhook của bạn chẳng chứng minh được gì.
AnyHook giúp được gì
AnyHook không xóa bỏ nhu cầu idempotency ở phía bạn, và bất kỳ dịch vụ nào tuyên bố làm được điều đó đều đang nói quá. Bản trùng bắt nguồn từ bên gửi và có thể phát sinh trên đường mạng giữa chúng tôi và bạn, một lớp mà không ai loại bỏ được.
Điều nó thay đổi là khả năng bạn nhìn thấy vấn đề:
- Mọi sự kiện đều được ghi nhật ký kèm toàn bộ request, số lần gửi và response gần nhất, nên câu hỏi “sự kiện này được bắn hai lần, hay handler của tôi chạy hai lần trên một lượt gửi” trả lời được ngay trong dashboard, thay vì phải đối chiếu ba nguồn log lúc 2 giờ sáng
- Số thứ tự lần gửi được ghi rõ ở mỗi lượt gửi, nên phân biệt được lần thử lại với một sự kiện mới
- Header
X-AnyHook-Deliverymang cùng một ID sự kiện ở mọi lần thử lại của một sự kiện, cho bạn một khóa chống trùng lặp cho những bản trùng phát sinh giữa chúng tôi và bạn, kể cả khi nhà cung cấp upstream keo kiệt không đưa khóa nào - Phát lại (replay) là hành động có chủ đích và được đánh dấu. Sự kiện phát lại mang cờ
is_replay, nên một đợt lấy bù (backfill) sẽ không trông như một đợt bản trùng tăng vọt bí ẩn một tuần sau đó - Nhật ký có tính tamper-evident (mọi chỉnh sửa đều để lại dấu vết). Khi câu hỏi là “chính xác thì cái gì đã đến, và dòng này có bị sửa không”, một database sửa được chỉ là lời khẳng định chứ không phải bằng chứng: Nhật ký webhook tamper-evident
Điều cần nhớ
Bản trùng không phải khiếm khuyết của nhà cung cấp, mà là cái giá của việc không bao giờ mất sự kiện, và đó là một sự đánh đổi đúng đắn. Thêm unique index trên ID sự kiện của nhà cung cấp trong hôm nay. Chuyển bản ghi đánh dấu vào transaction vào lần tới bạn động đến đoạn code đó. Và nếu bạn xử lý bất cứ thứ gì có vòng đời (subscription, đơn hàng, deployment), hãy thêm điều kiện kiểm tra phiên bản trước khi một sự kiện đến sai thứ tự ghi đè một trạng thái cũ mà phải một tháng sau bạn mới nhận ra.