Idempotency key — Chống trừ tiền 2 lần khi API bị retry
Hôm trước ngồi debug vụ đơn hàng bị trừ tiền 2 lần, mình mới thấm cái câu: "Distributed systems là chỗ mà mọi thứ có thể fail bất cứ lúc nào". Client bấm nút thanh toán, request tới server, server xử lý xong nhưng response bị rớt mạng — client tưởng thất bại nên retry. Kết quả: khách bị trừ tiền 2 lần, team mình nhận cả loạt ticket. Nguyên nhân gốc không phải code dở, mà là thiếu idempotency.
Ảnh: Anna Tarazevich — Pexels
Idempotency là gì?
Một operation gọi là idempotent nếu gọi N lần cũng cho kết quả y như gọi 1 lần. GET /users/1 là idempotent tự nhiên. Nhưng POST /orders thì không — mỗi lần gọi là tạo một đơn mới. Để biến nó thành an toàn khi retry, ta cần idempotency key: một chuỗi duy nhất do client sinh ra, gửi kèm trong request, và server dùng nó để nhận diện "request này tao từng xử lý rồi".
Cách làm chuẩn ngành là theo mô hình của Stripe: client gửi header Idempotency-Key, server lưu key + response lần đầu, lần sau gặp lại key cũ thì trả về y nguyên response đó mà không xử lý lại.
Code mẫu — unique constraint là vũ khí chính
Cách chắc ăn nhất để chống trùng là để database tự chặn, chứ đừng tin vào check-then-insert (luôn dính race condition):
CREATE TABLE payments (
id UUID PRIMARY KEY,
order_id UUID NOT NULL,
idempotency_key TEXT NOT NULL,
amount BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (order_id, idempotency_key)
);
Trong handler, cứ thử insert trước:
func (s *Server) CreatePayment(ctx context.Context, req PaymentRequest) (*Payment, error) {
p := Payment{
ID: uuid.New(),
OrderID: req.OrderID,
IdempotencyKey: req.IdempotencyKey,
Amount: req.Amount,
Status: "pending",
}
err := s.db.Insert(ctx, &p)
if err != nil {
// PostgreSQL trả unique violation nếu key đã tồn tại
if errors.Is(err, pgx.ErrUniqueViolation) {
existing, qErr := s.db.GetByIdempotencyKey(ctx, req.OrderID, req.IdempotencyKey)
if qErr != nil {
return nil, qErr
}
return existing, nil // trả kết quả lần đầu, không xử lý lại
}
return nil, err
}
// Chỉ tới đây khi insert thành công — tức là lần đầu tiên
if err := s.processPayment(ctx, &p); err != nil {
return nil, err
}
return &p, nil
}
Bí quyết nằm ở chỗ: insert là phép thử nguyên tử. Hai request cùng key chạy song song, chỉ một thằng insert được, thằng còn lại văng unique violation rồi đi đọc lại kết quả cũ. Không cần lock, không cần Redis, không cần phức tạp gì thêm.
Mấy cái bẫy mình gặp thực tế
- Key phải do client sinh, không phải server tự bịa. Vì nếu server sinh key, khi request retry client sẽ gửi key khác → vẫn trùng tiền. Key chuẩn thường là UUID do client tạo mỗi lần "ý định" thanh toán.
- Phạm vi key phải rõ ràng. Cùng một key dùng cho 2 thao tác khác nhau (tạo đơn vs hủy đơn) là tự chuốc họa. Nên kẹp key theo từng resource, ví dụ
UNIQUE (order_id, idempotency_key)như trên. - Retry không chỉ từ client. Service của mình gọi service khác (ví dụ cổng thanh toán) cũng phải tự sinh key khi gọi xuống — đừng mong thằng dưới tự biết.
- TTL cho key. Lưu key vĩnh viễn thì bảng phình to. Stripe giữ key 24h — sau đó client phải tự sinh key mới. Cân nhắc lịch dọn dẹp định kỳ.
- Response cũ phải trả y nguyên, kể cả status code. Nếu lần đầu lỗi 500, lần retry trả 200 là client sẽ rối.
Ảnh: Towfiqu barbhuiya — Pexels
Khi nào cần idempotency key?
Không phải API nào cũng cần. Nguyên tắc của mình: thao tác nào tạo tài nguyên mới hoặc đổi trạng thái tiền bạc thì bắt buộc có. Thanh toán, tạo đơn, gửi email, gọi webhook — đều nên có. Còn GET, hay update kiểu "set nguyên giá trị mới" (PUT) thì vốn đã idempotent rồi, khỏi thêm chi phí.
Ảnh: Towfiqu barbhuiya — Pexels
Kết
Idempotency key nghe có vẻ "thêm việc", nhưng thật ra là tấm áo chống đạn rẻ tiền nhất cho hệ thống có tiền bạc chạy qua. Một cột unique constraint, một đoạn xử lý conflict, là xong — đổi lại không bao giờ phải giải thích với khách hàng vì sao bị trừ tiền 2 lần. Có ai từng dính kiểu bug trừ tiền 2 lần chưa? Kể mình nghe với, để mình biết mình không cô đơn 😄
📋 Phụ lục thuật ngữ
- Idempotency — tính chất một thao tác gọi nhiều lần cho kết quả giống gọi một lần
- Idempotency key — chuỗi định danh duy nhất do client sinh ra, giúp server nhận diện request trùng
- Unique constraint — ràng buộc database đảm bảo một cột (hoặc tổ hợp cột) không có giá trị lặp lại
- Race condition — tình huống nhiều tiến trình cùng truy cập dữ liệu chung, kết quả phụ thuộc thứ tự thực thi
- Retry — gửi lại request sau khi lần gọi trước thất bại hoặc timeout