Sáng thứ Hai, một team điều tra vì sao khách hàng bị charge 3 lần cho cùng một đơn hàng. Log cho thấy 3 request POST /payments đến trong vòng 800ms, cùng user_id, cùng order_id, cùng amount. Hệ thống xử lý cả 3 vì mỗi request có request_id khác nhau, middleware tự sinh UUID mới cho mỗi request. Client mobile mất mạng giây lát, SDK tự retry, mỗi lần retry sinh UUID mới, và server coi mỗi request là transaction mới.
Incident kiểu này cho thấy: “gọi API hai lần không được tạo hai đơn” nghe đơn giản, nhưng idempotency không phải thuộc tính của HTTP verb, nó là hợp đồng giữa client và server về side-effect. PUT không tự nhiên idempotent nếu handler có side-effect ngoài ý muốn. POST hoàn toàn có thể idempotent nếu bạn thiết kế dedupe đúng cách. Và quan trọng nhất: idempotency là vấn đề thiết kế hợp đồng, không phải vấn đề chọn HTTP method.
Bài này đi từ API đồng bộ cho đến message consumer, từ idempotency key + TTL cho đến xử lý partial failure trong pipeline nhiều bước.
Tham chiếu nội bộ: outbox pattern trong CDC và Outbox.
Idempotency không phải về HTTP verb
Hiểu sai phổ biến nhất: “GET, PUT, DELETE idempotent; POST không”. Câu này đúng theo RFC 7231 về kỳ vọng semantics, nhưng sai hoàn toàn khi áp vào implementation thực tế.
PUT /users/1/email với body {"email": "new@example.com"}, theo RFC thì idempotent vì gọi 10 lần cùng body, trạng thái cuối vẫn là email mới. Nhưng nếu handler bên trong gửi email thông báo “bạn đã đổi email” mỗi lần gọi, thì side-effect không idempotent, user nhận 10 email thông báo. Kỹ thuật thì trạng thái DB đúng, nhưng trải nghiệm user thì sai.
POST /payments theo RFC không idempotent. Nhưng nếu bạn thiết kế server chấp nhận Idempotency-Key header và lưu kết quả đã commit theo key đó, lần gọi thứ hai cùng key trả cùng response mà không charge thêm, thì endpoint này idempotent trong phạm vi hợp đồng mà bạn đã định nghĩa.
Vậy nên khi nói “endpoint X có idempotent không”, câu trả lời đúng phải bao gồm ba phần: idempotent với side-effect nào (DB write, email, charge, notification), trong cửa sổ thời gian nào (TTL của idempotency key), và với điều kiện gì (cùng key + cùng body, hay chỉ cùng key). Thiếu bất kỳ phần nào, “idempotent” chỉ là label marketing trên API doc.
Idempotency key, cơ chế cốt lõi
Cách hoạt động
Mô hình đơn giản nhất: client gửi request kèm header Idempotency-Key: <uuid> do client tự sinh. Server nhận request, kiểm tra key đã tồn tại trong bảng dedupe chưa. Nếu chưa, xử lý request, lưu kết quả (status + response body) vào bảng dedupe cùng transaction với business write, trả response cho client. Nếu đã tồn tại, đọc kết quả đã lưu, trả lại cho client mà không xử lý lại.
Điểm mấu chốt là bước INSERT idempotency và INSERT business data phải nằm trong cùng một transaction. Nếu tách ra, ví dụ insert business trước, commit, rồi mới insert idempotency record, thì crash giữa hai bước sẽ khiến business đã commit nhưng idempotency chưa ghi, lần retry tiếp theo sẽ tạo duplicate.
TTL và replay window
Idempotency key không thể sống mãi mãi. Storage bảng dedupe sẽ phình theo thời gian. GDPR có thể yêu cầu xoá data liên quan sau một khoảng. Và key cũ hàng tháng trước gần như chắc chắn không bao giờ được replay.
Chọn TTL là quyết định hợp đồng giữa client và server, và phải document rõ ràng. TTL quá ngắn (vài giây) thì client retry sau timeout vài giây sẽ tạo duplicate vì key đã hết hạn. TTL quá dài (vài tháng) thì bảng dedupe phình, query chậm, hot key trên index.
TTL 24 giờ thường phù hợp cho payment API, đủ dài để cover mọi retry scenario hợp lý (timeout, mất mạng, user double-click), đủ ngắn để bảng dedupe không quá lớn. Cho webhook từ partner, TTL 7 ngày vì partner có thể retry chậm. API doc phải ghi rõ: “Idempotency key hợp lệ trong 24 giờ kể từ request đầu tiên. Sau 24 giờ, cùng key sẽ được coi là request mới.”
Cùng key nhưng body khác
Client gửi POST /payments với key K và body amount 100. Server xử lý thành công. Client gửi lại key K nhưng body amount 200, chuyện gì xảy ra?
Đây là edge case gây incident nhiều nhất, vì nếu không xử lý rõ ràng, server có thể trả response cũ (amount 100) cho request mới (amount 200), client nghĩ đã charge 200 nhưng thực tế chỉ charge 100.
Có hai hướng thiết kế rõ ràng, phải chọn một và document. Hướng thứ nhất là first-write-wins: lần đầu body được chấp nhận, lần sau cùng key nhưng khác body trả 409 Conflict kèm message “idempotency key đã được sử dụng với body khác”. Client biết cần sinh key mới nếu muốn gửi request khác. Stripe dùng cách này, và đây thường là lựa chọn rõ ràng nhất.
Hướng thứ hai là key chỉ là định danh thao tác, body phải giống hệt qua hash compare. Khác thì từ chối. Nghe chặt hơn nhưng implement phức tạp hơn vì phải hash body và so sánh, body có timestamp hay random nonce thì hash sẽ khác dù ý nghĩa giống nhau.
Không chọn rõ ràng nghĩa là client và server hiểu khác nhau, và incident xảy ra khi edge case hit production.
Concurrency, hai request cùng key đến cùng lúc
User double-click nút “Thanh toán”, hai request cùng idempotency key đến server gần như đồng thời, chênh nhau vài millisecond. Request 1 bắt đầu xử lý, chưa commit. Request 2 đến, check bảng dedupe, chưa thấy key vì request 1 chưa commit. Request 2 cũng bắt đầu xử lý. Cả hai commit, duplicate.
Đây là race condition cổ điển, và giải pháp là unique constraint trên idempotency key trong database. Khi request 2 cố INSERT record với cùng key, unique constraint vi phạm, transaction 2 rollback, retry logic check lại bảng dedupe, thấy request 1 đã commit, trả response của request 1.
Pattern cụ thể: INSERT idempotency record với status “processing” làm bước đầu tiên trong transaction. Unique constraint trên key column. Chỉ request INSERT thành công mới tiếp tục pipeline xử lý. Request thua unique constraint đợi một khoảng ngắn rồi SELECT kết quả, hoặc trả 202 Accepted nếu request đầu chưa xong.
-- Bảng idempotency
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'processing',
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Request đến: cố INSERT
INSERT INTO idempotency_keys (key, expires_at)
VALUES ($1, now() + interval '24 hours')
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- Nếu INSERT thành công: tiếp tục xử lý
-- Nếu RETURNING rỗng (conflict): SELECT response WHERE key = $1
ON CONFLICT DO NOTHING kết hợp kiểm tra RETURNING là cách atomic và đơn giản nhất trên PostgreSQL. Không cần application-level lock, không cần distributed lock, database constraint là đủ.
At-least-once delivery và dedupe ở consumer
Với HTTP API, client biết mình gọi lại và có thể gửi cùng idempotency key. Nhưng với message queue (Kafka, RabbitMQ, SQS), bạn không kiểm soát được retry, broker tự retry khi consumer crash hoặc acknowledgement bị mất. Và hầu hết message broker đảm bảo at-least-once delivery, không phải exactly-once. Consumer sẽ nhận trùng message, thiết kế cho trường hợp đó thay vì hy vọng nó không xảy ra.
Idempotency ở đây cần dedupe key, và câu hỏi là dùng key gì.
messageId từ broker là lựa chọn đơn giản nhất: mỗi message có ID unique do broker sinh. Nhưng nếu bạn replay message thủ công (xoá consumer group rồi consume lại) hoặc đổi broker, messageId thay đổi nên message bị xử lý lại. Vẫn hữu ích cho dedupe tự nhiên khi consumer crash giữa chừng nhưng không cover mọi trường hợp.
Business key, orderId + eventType, hoặc paymentId + version, ổn định hơn về semantic: cùng business event luôn có cùng key dù bạn replay hay đổi broker. Nhưng phải đảm bảo business key thực sự unique, hai event khác nhau cho cùng order phải có key khác nhau.
Thường nên dùng business key cho dedupe chính, kết hợp messageId để log và debug. Bảng dedupe ở consumer (thường gọi là inbox table) lưu key đã xử lý, INSERT cùng transaction với business write. Pattern giống hệt idempotency key ở API nhưng ở tầng message.
-- Inbox table cho consumer
CREATE TABLE inbox (
message_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Consumer nhận message:
BEGIN;
INSERT INTO inbox (message_key)
VALUES ($1)
ON CONFLICT (message_key) DO NOTHING
RETURNING message_key;
-- Nếu INSERT thành công: xử lý business logic
-- Nếu conflict: message đã xử lý, skip
COMMIT;
Quan trọng: INSERT inbox và business write phải cùng transaction. Nếu tách ra, insert inbox trước, commit, rồi mới xử lý business, thì crash giữa hai bước nghĩa là message được đánh dấu “đã xử lý” nhưng business chưa xong. Message sẽ không được retry vì inbox đã có record. Data inconsistent và rất khó phát hiện.
Client timeout và “server đã xong nhưng client không biết”
Đây là kịch bản kinh điển nhất để hiểu vì sao idempotency key cần đi cùng response cache trên server.
Client gửi POST /payments với key K. Server xử lý thành công, commit DB, charge tiền thật. Nhưng response HTTP bị mất trên đường về, mobile mất mạng đúng lúc server gửi response, hoặc load balancer timeout trước khi response đến client. Client không nhận được response, không biết payment thành công hay thất bại.
Client retry cùng key K. Nếu server không lưu response của lần đầu, server sẽ phải xử lý lại, nhưng payment đã commit. Nếu server cố charge lần hai thì duplicate. Nếu server trả lỗi “payment đã tồn tại” nhưng không trả response gốc, client không biết trạng thái payment, phải gọi API khác để check, hoặc hiển thị “vui lòng liên hệ hỗ trợ”.
Giải pháp: server lưu response (status code + body) cùng idempotency key record khi commit lần đầu. Lần retry, server lookup key, tìm thấy response đã lưu, trả lại cho client. Client nhận response giống hệt lần đầu, không biết đây là replay, xử lý bình thường.
Một cách tiếp cận là trả 200 OK cho lần replay thay vì 201 Created lần đầu để client có thể phân biệt nếu cần. Nhưng body response phải giống hệt, vì client dùng body để cập nhật state. Stripe trả cùng status code cho cả lần đầu và replay, cũng hợp lý, miễn document rõ.
Điểm mấu chốt: idempotency không chỉ bảo vệ database khỏi duplicate. Nó bảo vệ mô hình mental của client về trạng thái giao dịch. Client cần biết “payment đã thành công” hoặc “payment thất bại”, không phải “có gì đó đã xảy ra nhưng tôi không chắc”.
Idempotency trong pipeline nhiều bước
Thực tế ít khi “xử lý payment” chỉ là một INSERT. Pipeline thường là: validate, reserve quota, call payment gateway, finalize order, send confirmation email. Mỗi bước có thể fail, và retry cần idempotent từng bước.
Nếu fail sau bước reserve quota: retry cùng key phải không reserve thêm. Giải pháp là reserve idempotent theo reservation_id được sinh từ idempotency key, cùng key luôn sinh cùng reservation_id, database constraint trên reservation_id ngăn duplicate.
Nếu payment gateway không idempotent (không phải mọi gateway đều hỗ trợ): bạn phải wrap bằng correlation id riêng gửi cho gateway. Nhiều gateway hỗ trợ client_reference_id hoặc tương tự, gửi idempotency key hoặc derivative làm reference, gateway sẽ dedupe phía họ. Nếu gateway không hỗ trợ gì, bạn phải chấp nhận reconciliation sau, check trạng thái payment ở gateway trước khi retry call.
Email confirmation phức tạp hơn vì email service thường không idempotent, gửi hai lần là user nhận hai email. Giải pháp: ghi “đã gửi email” vào bảng tracking cùng transaction với finalize order. Retry kiểm tra bảng tracking trước khi gửi. Hoặc chấp nhận “user nhận email trùng” là acceptable, tuỳ product decision.
Mỗi bước trong pipeline cần idempotency key riêng (derived từ key gốc) hoặc tracking state riêng. Đừng assume “retry từ đầu” sẽ an toàn, nếu bước 3 đã charge tiền nhưng bước 4 fail, retry từ bước 1 sẽ validate lại, reserve lại, call gateway lại. Nếu từng bước không tự idempotent, kết quả là double reserve và double charge. Mỗi bước phải tự bảo vệ mình.
DELETE, PATCH và câu chuyện RFC
HTTP semantics kỳ vọng PUT và DELETE idempotent ở tầng protocol. Nhưng implementation có thể vi phạm nếu handler làm side-effect thêm.
DELETE /orders/123 lần đầu xoá order, trả 200 OK. Lần hai order đã bị xoá, trả 404 Not Found. Kỹ thuật thì idempotent vì gọi bao nhiêu lần cũng ra cùng trạng thái cuối: order không tồn tại. Nhưng status code khác nhau giữa 200 và 404 có thể confuse client. Nên trả 200 OK hoặc 204 No Content cho cả hai trường hợp, “operation đã được thực hiện, kết quả như mong đợi”, thay vì 404 cho lần thứ hai. Nhưng nếu DELETE có side-effect như ghi audit log mỗi lần gọi hoặc gửi notification, thì side-effect đó cần check “đã xoá chưa” trước khi thực hiện.
PATCH phức tạp hơn vì phụ thuộc vào kiểu patch. JSON Merge Patch (RFC 7396) gán giá trị đích, “set name = X”, gọi nhiều lần ra cùng kết quả, dễ suy ra idempotent. Nhưng JSON Patch (RFC 6902) có operation kiểu add hoặc increment, “thêm 1 vào counter”, gọi hai lần tăng 2 thay vì 1, không idempotent. Đừng nhầm verb HTTP với invariant nghiệp vụ mà bạn cam kết trong API doc.
Khi nào cần idempotency key, khi nào dùng business constraint
Không phải mọi endpoint đều cần idempotency key phức tạp. Có những trường hợp unique business constraint tự nhiên là đủ.
Ví dụ: POST /users với email unique. Nếu client gửi hai lần cùng email, lần thứ hai sẽ bị unique constraint reject, trả 409 Conflict. Client biết user đã tạo, gọi GET /users?email=... để lấy resource. Không cần idempotency key riêng vì business key (email) đã là natural dedupe.
Nhưng nếu client là mobile app không tin cậy, clock sai, mạng flaky, SDK tự retry, và natural key không đủ mạnh (ví dụ POST /orders khi user có thể tạo nhiều order), thì idempotency key là cần thiết. Server clock làm tham chiếu thời gian, client chỉ cung cấp key.
Khi partner gửi webhook trùng: dedupe theo event_id của partner, đây là natural key mạnh vì partner đã sinh ID unique. Bảng inbox lưu event_id, INSERT trước khi xử lý, duplicate bị reject.
Đôi khi cần cả hai: unique business constraint ở tầng data + idempotency key ở tầng request. Ví dụ: POST /payments có business constraint order_id + amount unique, nhưng vẫn cần idempotency key vì cùng order có thể có nhiều payment attempt (lần 1 fail, lần 2 khác amount). Idempotency key phân biệt “retry cùng attempt” vs “attempt mới”.
Observability, log gì, không log gì
Mỗi request có idempotency key cần log đủ context để debug: idempotency_key, request_id, outcome (first_attempt, replay, conflict), tenant_id nếu multi-tenant. Khi incident xảy ra, bạn cần trả lời “key K đã được xử lý lần đầu lúc nào, bởi request nào, kết quả gì” trong vòng 2 phút.
Tuyệt đối không log PII: password, token, số thẻ, OTP. Log user_id (khoá nội bộ) thay vì email. Idempotency key bản thân nó là UUID, không chứa PII, nhưng nếu key là derivative của data nhạy cảm (hash email chẳng hạn), thì key đó cũng cần xem xét theo GDPR.
Bảng dedupe có thể chứa PII gián tiếp, response body lưu trong bảng dedupe có thể chứa email, tên user. Cần TTL ngắn (match với replay window) và chính sách purge rõ ràng. Đây là nợ compliance ít ai nghĩ tới khi implement idempotency, đến khi audit GDPR mới giật mình.
Retry middleware, nơi bug hay ẩn
Nhiều HTTP client library có retry middleware tự động: Axios interceptor, Go net/http RoundTripper, Java HttpClient retry handler. Bug phổ biến nhất: middleware “tốt bụng” sinh UUID mới cho mỗi retry, mỗi request có Idempotency-Key khác, server coi là request mới, duplicate xảy ra.
Retry middleware phải giữ nguyên header Idempotency-Key qua mọi retry attempt. Một số middleware reset header khi clone request, đọc kỹ source code hoặc test rõ ràng trước khi dùng trên production.
Retry cũng cần exponential backoff + jitter. Retry ngay lập tức khi server đang overload chỉ làm tình hình tệ hơn, hàng trăm client retry cùng lúc tạo thundering herd. Backoff + jitter giãn request retry ra, giảm áp lực lên server.
import time, random
def retry_with_backoff(fn, max_retries=3):
for attempt in range(max_retries):
try:
return fn()
except RetryableError:
if attempt == max_retries - 1:
raise
wait = min(2 ** attempt, 30) + random.random()
time.sleep(wait)
Khi nào không cần idempotency phức tạp
Không phải mọi API đều cần bảng dedupe và idempotency key. Một số trường hợp đơn giản hơn.
Endpoint chỉ đọc (GET, HEAD, OPTIONS) không có side-effect, tự nhiên idempotent, không cần key.
Endpoint có natural idempotency, PUT /users/1 set email thành X, gọi bao nhiêu lần cũng ra X. Không cần key riêng nếu handler không có side-effect ngoài DB write.
Endpoint nội bộ giữa service có exactly-once guarantee, một số message broker (Kafka Transactions, Pulsar) hỗ trợ exactly-once semantics ở mức producer-consumer. Nếu bạn trust guarantee này và hiểu giới hạn của nó, có thể bỏ qua dedupe ở consumer. Nhưng nên có inbox table, defense in depth, và guarantee của broker có thể break trong edge case.
Endpoint internal không có side-effect nặng (ghi log, update cache), duplicate ghi log hoặc update cache với cùng giá trị thường acceptable. Đừng over-engineer idempotency cho endpoint không cần.
Tóm tắt
Idempotency là hợp đồng giữa client và server, không phải thuộc tính tự nhiên của HTTP method. Hợp đồng phải nói rõ: idempotent với side-effect nào, trong replay window bao lâu, và hành vi khi cùng key khác body.
Idempotency key + unique constraint trong database là cơ chế cốt lõi. INSERT key và business write phải cùng transaction, tách ra là mời duplicate. Response phải được cache cùng key record để client retry nhận cùng response, bảo vệ mô hình mental của client về trạng thái giao dịch.
Consumer message cần inbox table dedupe theo business key, at-least-once delivery nghĩa là consumer sẽ nhận trùng, thiết kế cho trường hợp đó thay vì hy vọng nó không xảy ra.
Pipeline nhiều bước cần idempotency ở từng bước, mỗi bước tự bảo vệ mình bằng unique constraint hoặc tracking state. Retry middleware phải giữ nguyên idempotency key qua mọi attempt, kèm exponential backoff + jitter.
Và cuối cùng: không phải mọi endpoint đều cần idempotency key phức tạp. Natural key, unique business constraint, hoặc chấp nhận “duplicate acceptable” đều là lựa chọn hợp lệ, miễn document rõ ràng và client biết kỳ vọng gì khi gọi lại.