Gọi nhiều lần nhưng chỉ thực hiện một lầnCalled many times, applied once
Idempotency filter đứng trước Payment Service như một proxy: ghi key vào bảng idempotency_keys trước, chỉ request giành được key mới đi tiếp. Chọn “Retry cùng key” để xem response được phát lại mà không gọi nhà cung cấp, và “Hai request đồng thời” để xem unique constraint xử lý race.An idempotency filter sits in front of the Payment Service as a proxy: it writes the key to idempotency_keys first, and only the request that wins the key goes through. Pick “Retry with the same key” to see the response replayed without calling the provider, and “Concurrent duplicate” to see the unique constraint settle the race.
1Client tạo key ngẫu nhiên (UUID v4) cho lần thanh toán này và gửi kèm header.The client generates a random key (UUID v4) for this payment and sends it in the header.
Timeout không cho biết server đã làm hay chưa. Client gửi Idempotency-Key; filter đứng trước service như một proxy, trả lại kết quả cũ cho key đã COMPLETED.A timeout does not say whether the server did the work. The client sends an Idempotency-Key; a filter in front of the service acts as a proxy and replays the result for a COMPLETED key.
Kiểm tra rồi mới ghi (SELECT rồi INSERT) có race. Ghi trước bằng INSERT … ON CONFLICT DO NOTHING trên unique(user_id, key); bên thua trả 409 hoặc chờ có giới hạn.Check-then-insert races. Insert first with INSERT … ON CONFLICT DO NOTHING on unique(user_id, key); the loser returns 409 or waits for a bounded time.
Lưu SHA-256 của body chuẩn hoá. Hash lệch thì trả 422 thay vì phát lại response của một request khác.Store a SHA-256 of the canonical body. A different hash returns 422 instead of replaying the response of another request.
Lỗi 5xx hoặc exception thì xoá dòng key để client retry được. Dòng IN_PROGRESS quá hạn (ví dụ 60 s) được coi là bỏ dở và cho phép giành lại.On a 5xx or an exception, delete the key row so the client can retry. An IN_PROGRESS row older than a limit (say 60s) is treated as abandoned and can be reclaimed.
Không gửi Kafka hay email trực tiếp trong request. Ghi outbox cùng transaction với payment; relay gửi sau, consumer dedup bằng message id.Do not publish to Kafka or send email directly in the request. Write an outbox row in the payment transaction; a relay sends it later and consumers dedupe by message id.
Broker giao at-least-once. Consumer INSERT message_id vào processed_messages trong cùng transaction với việc xử lý; trùng khoá thì bỏ qua.Brokers deliver at least once. The consumer inserts the message_id into processed_messages in the same transaction as the work; a duplicate key means skip.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
idempotency_keys | user_id, key, request_hash, status, response_code, response_body, locked_at, expires_at | PRIMARY KEY (user_id, key); status IN_PROGRESS → COMPLETED; index expires_at để dọnPRIMARY KEY (user_id, key); status IN_PROGRESS → COMPLETED; index on expires_at for cleanup |
payments | id, order_id, amount, currency, status, provider_ref, created_at | unique(order_id) là lớp chống trùng thứ hai ở tầng nghiệp vụunique(order_id) is a second, business-level guard against duplicates |
outbox | id, topic, key, payload, created_at, published_at | Ghi cùng transaction với payments; id dùng làm message idWritten in the payments transaction; id doubles as the message id |
processed_messages | consumer, message_id, processed_at | PRIMARY KEY (consumer, message_id); giữ lâu hơn thời gian broker có thể giao lạiPRIMARY KEY (consumer, message_id); kept longer than the broker's redelivery window |
Filter ghi key trước, đọc lại khi xung đột để phát lại hoặc từ chối, và chỉ lưu response khi request thành công.The filter claims the key first, reads it back on conflict to replay or reject, and stores the response only when the request succeeds.
@Component
@RequiredArgsConstructor
public class IdempotencyFilter extends OncePerRequestFilter {
private final JdbcTemplate jdbc;
@Override
protected boolean shouldNotFilter(HttpServletRequest req) {
return !"POST".equals(req.getMethod()) || req.getHeader("Idempotency-Key") == null;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String key = req.getHeader("Idempotency-Key");
String user = req.getUserPrincipal().getName();
CachedBodyRequest body = new CachedBodyRequest(req); // body read once, re-readable
String hash = DigestUtils.sha256Hex(body.bytes());
int claimed = jdbc.update("""
INSERT INTO idempotency_keys(user_id, key, request_hash, status, locked_at, expires_at)
VALUES (?, ?, ?, 'IN_PROGRESS', now(), now() + interval '24 hours')
ON CONFLICT (user_id, key) DO NOTHING""", user, key, hash);
if (claimed == 0) {
StoredKey k = jdbc.queryForObject("""
SELECT request_hash, status, response_code, response_body
FROM idempotency_keys WHERE user_id = ? AND key = ?""", StoredKey.MAPPER, user, key);
if (!k.requestHash().equals(hash)) { res.sendError(422, "Key reused with another body"); return; }
if (k.inProgress()) { res.setHeader("Retry-After", "1"); res.sendError(409); return; }
res.setStatus(k.responseCode());
res.setHeader("Idempotent-Replayed", "true");
res.getOutputStream().write(k.responseBody());
return;
}
ContentCachingResponseWrapper out = new ContentCachingResponseWrapper(res);
try {
chain.doFilter(body, out);
} catch (ServletException | IOException | RuntimeException e) {
release(user, key);
throw e;
}
if (out.getStatus() >= 500) release(user, key);
else jdbc.update("""
UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
WHERE user_id = ? AND key = ?""", out.getStatus(), out.getContentAsByteArray(), user, key);
out.copyBodyToResponse();
}
private void release(String user, String key) {
jdbc.update("DELETE FROM idempotency_keys WHERE user_id = ? AND key = ?", user, key);
}
}type recorder struct {
http.ResponseWriter
status int
buf bytes.Buffer
}
func (r *recorder) WriteHeader(c int) { r.status = c; r.ResponseWriter.WriteHeader(c) }
func (r *recorder) Write(b []byte) (int, error) { r.buf.Write(b); return r.ResponseWriter.Write(b) }
func Idempotency(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if r.Method != http.MethodPost || key == "" {
next.ServeHTTP(w, r)
return
}
ctx, user := r.Context(), auth.UserID(r.Context())
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
sum := sha256.Sum256(body)
hash := hex.EncodeToString(sum[:])
res, err := db.ExecContext(ctx, `INSERT INTO idempotency_keys
(user_id, key, request_hash, status, locked_at, expires_at)
VALUES ($1, $2, $3, 'IN_PROGRESS', now(), now() + interval '24 hours')
ON CONFLICT (user_id, key) DO NOTHING`, user, key, hash)
if err != nil {
http.Error(w, "store unavailable", http.StatusServiceUnavailable)
return
}
if n, _ := res.RowsAffected(); n == 0 {
replay(ctx, db, w, user, key, hash) // 422, 409 or the stored response
return
}
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if rec.status >= 500 {
db.ExecContext(ctx, `DELETE FROM idempotency_keys WHERE user_id = $1 AND key = $2`, user, key)
return
}
db.ExecContext(ctx, `UPDATE idempotency_keys
SET status = 'COMPLETED', response_code = $1, response_body = $2
WHERE user_id = $3 AND key = $4`, rec.status, rec.buf.Bytes(), user, key)
})
}| PostgreSQLPostgreSQL | RedisRedis | |
|---|---|---|
| Tính đúngCorrectness | Ghi key cùng transaction với payment được, bền khi restartThe key can share a transaction with the payment and survives restarts | SET NX nhanh, nhưng failover có thể mất key vừa ghiSET NX is fast, but a failover can lose a key just written |
| Hết hạnExpiry | Cột expires_at + job xoá theo lôAn expires_at column plus a batch delete job | TTL có sẵn, không cần job dọnBuilt-in TTL, no cleanup job |
| Độ trễLatency | 1–3 ms mỗi lần ghi, thêm tải cho DB chính1–3 ms per write, extra load on the primary DB | Dưới 1 ms, tách tải khỏi DBUnder 1 ms, load kept off the DB |
| Chọn khiPick it when | Tiền, đơn hàng: cần chắc chắn tuyệt đốiMoney and orders, where certainty is required | API lưu lượng lớn, chấp nhận rủi ro nhỏ khi failoverHigh-volume APIs that accept a small risk on failover |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Hai request có thể cùng thấy “chưa có” rồi cùng chạy. Phải ghi trước và để unique constraint phân xử; bên insert 0 dòng mới đọc lại trạng thái.Two requests can both see “not there” and both run. Insert first and let the unique constraint decide; only the side that inserted 0 rows reads the state back.
Nếu không xử lý, client nhận 409 mãi. Lưu locked_at; quá ngưỡng (ví dụ 60 s) thì cho request mới giành lại key bằng UPDATE có điều kiện. Thao tác phía sau phải tự idempotent, ví dụ truyền key sang nhà cung cấp, vì lần trước có thể đã trừ tiền.Left alone, the client gets 409 forever. Keep locked_at; past a threshold (say 60s) a new request may reclaim the key with a conditional UPDATE. The downstream call must be idempotent itself, for instance by forwarding the key to the provider, because the earlier attempt may already have charged.
GET, PUT thay toàn bộ tài nguyên, DELETE, hay lệnh dạng SET status = 'PAID' vốn idempotent tự nhiên. Key cần cho POST tạo mới và thao tác cộng dồn như “trừ 100k”.GET, PUT that replaces a whole resource, DELETE, or commands like SET status = 'PAID' are naturally idempotent. Keys matter for POSTs that create things and for incremental operations such as “deduct 100k”.
Có. Relay outbox và broker đều giao at-least-once. Consumer ghi message_id vào processed_messages trong cùng transaction với việc xử lý, trùng thì bỏ qua.Yes. Both the outbox relay and the broker deliver at least once. The consumer writes the message_id to processed_messages in the same transaction as its work and skips duplicates.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.