Client gửi lệnh tạo đơn, server lưu thành công, nhưng kết nối đứt trước khi response về máy. Người dùng nhấn lại hoặc ứng dụng retry. Nếu lần gửi tiếp theo bị hiểu thành một nghiệp vụ mới, hệ thống có thể tạo hai đơn cho cùng một ý định mua hàng.
Tắt nút sau lần nhấn đầu giúp giao diện dễ dùng hơn, nhưng không giải quyết request lặp từ mạng, worker hay nhiều thiết bị. Bài viết trình bày một thiết kế minh họa với PostgreSQL 17 và HTTP, đối chiếu tài liệu ngày 22/10/2026. Đây chưa phải endpoint hoàn chỉnh đã kiểm thử trên hệ thống của bạn.
Xác định “cùng một yêu cầu”
Client tạo một khóa ngẫu nhiên cho một ý định tạo đơn. Khi retry vì chưa biết kết quả, client giữ nguyên khóa và payload. Khi người dùng bắt đầu một ý định mới, client dùng khóa mới. Nếu mỗi lần retry lại sinh UUID mới thì backend không có dấu hiệu để nhận biết chúng thuộc cùng một thao tác.
Ví dụ hợp đồng:
POST /orders
Idempotency-Key: 7fe3fcb0-4f6b-4eac-b780-35432afc2a17
Content-Type: application/json
{"items":[{"sku":"BOOK-01","quantity":1}]}
Header này chỉ có tác dụng khi server triển khai hợp đồng tương ứng. Một API tự nhận header nhưng không kiểm tra hoặc lưu gì vẫn có thể tạo trùng. Theo HTTP, tính idempotent nói về hiệu ứng dự định của các request giống nhau; một lệnh POST tạo đơn không tự có đặc tính đó chỉ vì được gửi qua HTTP.
Phạm vi khóa quan trọng hơn vẻ ngẫu nhiên
Đừng dùng khóa làm bằng chứng xác thực. Hãy tính scope từ tài khoản hoặc tenant đã xác thực và thao tác đang thực hiện. Ví dụ, khóa của tenant A tại POST /orders không nên tra ra response của tenant B.
Sau validation, tạo fingerprint từ dữ liệu nghiệp vụ đã chuẩn hóa. Nếu cùng scope và khóa nhưng fingerprint khác, trả lỗi theo hợp đồng, chẳng hạn 409 Conflict, thay vì âm thầm dùng đơn cũ. Cần quy định rõ cách xử lý thứ tự phần tử, giá trị mặc định và những trường do server tính; băm chuỗi JSON tùy ý dễ tạo ra các trường hợp khó giải thích.
Một bảng lưu kết quả tối giản:
CREATE TABLE idempotency_results (
scope text NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
response_status integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (scope, key)
);
Đây là schema minh họa. Khi triển khai cần bổ sung giới hạn độ dài, thời gian lưu, giám sát dung lượng và chính sách bảo vệ dữ liệu trong response. Không đưa email hay thông tin định danh nhạy cảm vào chính khóa để tiện tra cứu.
Gắn bản ghi nghiệp vụ với kết quả trong cùng transaction
Với nghiệp vụ chỉ ghi trong một database, có thể đặt việc nhận khóa, tạo đơn và lưu response vào cùng transaction. Bước nhận khóa có dạng:
INSERT INTO idempotency_results(scope, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (scope, key) DO NOTHING
RETURNING key;
Nếu có dòng trả về, transaction này đã nhận khóa. Nó tạo đơn, lưu status/body sẽ trả cho client rồi commit. Không commit bản ghi khóa đang dang dở trước khi tạo đơn nếu thiết kế chưa có cơ chế quản lý trạng thái processing và phục hồi tương ứng.
Nếu không có dòng trả về, đọc kết quả của cùng scope và khóa. So fingerprint trước khi replay status/body. Unique constraint là điểm phối hợp giữa các worker; thao tác “SELECT xem chưa có, rồi INSERT” tách rời không tự chặn được hai request đồng thời.
Trong mức cô lập mặc định Read Committed, xung đột khóa có thể khiến INSERT chờ transaction trước hoàn tất. Hãy dùng câu truy vấn tiếp theo để đọc kết quả đã commit, đặt timeout và xử lý lỗi transaction rõ ràng. Đừng giả định mọi response trùng sẽ trả ngay lập tức.
Khoảng trống khi có dịch vụ bên ngoài
Một transaction PostgreSQL không thể rollback email đã gửi hoặc khoản thu đã được nhà cung cấp thanh toán chấp nhận. Vì thế không nên nhét thao tác gọi mạng vào giữa rồi xem rollback database là cơ chế hoàn tác toàn bộ.
Với tác vụ nền, cân nhắc ghi một sự kiện outbox cùng transaction tạo đơn, rồi để worker xử lý sau. Worker và dịch vụ nhận sự kiện vẫn cần cơ chế chống xử lý trùng phù hợp. Nếu provider có idempotency riêng, hãy đọc hợp đồng của đúng API và lưu mối liên hệ với nghiệp vụ nội bộ.
Stripe là một ví dụ cụ thể, không phải tiêu chuẩn chung cho mọi backend. Tài liệu hiện phân biệt API v1 và API v2; với v1, kết quả đã lưu có thể được trả lại cả khi status là 500. Không áp dụng nguyên xi quy tắc đó cho API của bạn mà không giải thích cho client.
Các tình huống cần kiểm thử
- Cùng khóa và payload gửi lần lượt: chỉ có một đơn, kết quả được replay.
- Cùng khóa nhưng payload khác: lỗi rõ ràng, không thay đổi đơn cũ.
- Hai request cùng đến: unique constraint phối hợp được, không có hai đơn.
- Mất response sau commit: retry tìm lại kết quả đã lưu.
- Tiến trình chết trước commit: transaction không để lại đơn đã commit mà thiếu kết quả tương ứng.
- Khóa hết hạn: client biết rằng dùng lại khóa sau thời gian lưu có thể trở thành thao tác mới.
Các trường hợp trên là kế hoạch kiểm thử đề xuất, không phải báo cáo benchmark hay kết quả thử tải. Khi cần đánh giá thực tế, quan sát cả số bản ghi nghiệp vụ và phản hồi API; chỉ so hai response giống nhau chưa đủ chứng minh không có tác động phụ lặp.
Kết luận
Idempotency là một hợp đồng giữa ý định của client, phạm vi phân quyền và nơi backend lưu kết quả. Hãy bắt đầu từ transaction và tình huống mất response, rồi mở rộng sang worker hoặc provider khi đã xác định rõ ranh giới của từng hệ thống.


