Bạn bấm "Thanh toán". Server nhận request, tính tiền thành công — nhưng ngay trước khi phản hồi về tới điện thoại bạn, mạng rớt. App thấy timeout, tự động thử lại. Nếu server không nhận ra "đây là cùng một thao tác", nó tính tiền lần nữa. Đây là một trong những bài toán khó nhất của hệ thống thanh toán, và Stripe giải bằng một cơ chế đơn giản mà mạnh: idempotency key. Bài này mổ xẻ nó và dựng thật trên Redis.
Bài toán: retry an toàn cho thao tác không idempotent
Đọc dữ liệu thì retry vô hại (đọc mấy lần cũng ra cùng kết quả — idempotent). Nhưng "trừ 10$" thì không idempotent: chạy hai lần là trừ 20$. Mà mạng thì luôn chập chờn — client phải retry để chịu lỗi. Mâu thuẫn: cần retry để đáng tin, nhưng retry lại gây tính tiền trùng.
Cách giải của Stripe: client sinh một Idempotency-Key (UUID v4) một lần cho mỗi thao tác logic, và dùng lại đúng key đó cho mọi lần retry. Server lưu kết quả của request đầu tiên theo key; mọi request sau cùng key trả lại đúng kết quả cũ mà không thực thi lại.
Cách giải: SET NX atomic trên Redis
Điểm mấu chốt khi làm thật: phải nguyên tử — hai retry đến gần như đồng thời không được cùng "tưởng mình là lần đầu". Redis SET key value NX (chỉ đặt nếu chưa tồn tại) làm đúng việc claim nguyên tử đó. Gói trong Lua để trả cả trạng thái lẫn kết quả:
-- KEYS[1]=idempotency key; ARGV[1]=kết quả nếu là lần đầu
local lanDau = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", 86400)
if lanDau then
return {"NEW", ARGV[1]} -- ta sở hữu key -> bên gọi THỰC THI
else
return {"REPLAY", redis.call("GET", KEYS[1])} -- đã có -> trả kết quả cũ, KHÔNG thực thi lại
end
NX đảm bảo chỉ một request thắng cuộc claim; EX 86400 cho key tự hết hạn sau 24h — đúng như Stripe prune key sau ≥24h. Luồng xử lý chỉ trừ tiền khi trạng thái là NEW:
func thanhToan(idemKey):
st, kq := EVALSHA(idem.lua, idemKey, "charge_ok:"+txnID)
if st == "NEW": truTienThat() # chỉ chạy 1 lần cho mỗi key
return kq # NEW hay REPLAY đều trả CÙNG kết quả

Hình 1: Idempotency bằng Lua atomic trên Redis — SET NX claim nguyên tử (chỉ một request thắng), EX 86400 tự prune sau 24h; chỉ trừ tiền khi NEW, REPLAY trả lại kết quả cũ.
Đo THẬT trên Redis
Ta dựng một Redis thật và mô phỏng thanh toán, trong đó mỗi lần NEW mới thực sự trừ tiền (INCR một bộ đếm).
CÓ idempotency — 1 request + 4 retry cùng key:
lần 1: NEW (thực thi -> trừ tiền)
lần 2: REPLAY (trả kết quả cũ, KHÔNG trừ tiền)
lần 3: REPLAY
lần 4: REPLAY
lần 5: REPLAY
=> tiền_đã_trừ (số lần tính tiền THẬT) = 1 (dù retry 5 lần)
KHÔNG idempotency — 5 retry đều trừ tiền:
=> tiền_đã_trừ = 5 <- tính tiền 5 LẦN (double charge)
Và retry trả về đúng kết quả lần đầu, không phải giá trị mới truyền vào:
EVALSHA idem.lua 1 "idem:order-777" "gia_tri_moi_khac"
-> REPLAY charge_ok:txn_order-777 (kết quả LẦN ĐẦU, không phải "gia_tri_moi_khac")

Hình 2: Chạy thật trên Redis — có idempotency trừ tiền đúng 1 lần dù retry 5 lần (1 NEW + 4 REPLAY); không có thì trừ 5 lần (double charge); REPLAY trả lại đúng kết quả lần đầu. Kèm cách Stripe làm.
Những chi tiết Stripe làm mà demo tối giản chưa có
Theo tài liệu Stripe, một idempotency layer đầy đủ còn cần:
- Lưu cả status code + body, kể cả lỗi. Nếu lần đầu trả 500, retry cũng trả 500 — để client thấy cùng kết quả, không "may rủi" lần sau thành công tạo ra trạng thái mập mờ.
- So tham số với request gốc. Nếu ai đó dùng lại một key cũ nhưng với tham số khác (vô tình), Stripe báo lỗi thay vì trả nhầm kết quả cũ — chống dùng sai key.
- Xử lý request đang bay (in-flight). Nếu retry đến khi request đầu chưa xong, phải trả về "đang xử lý" (409) thay vì chạy song song. Demo trên dùng một giá trị "IN_PROGRESS" tạm rồi ghi đè bằng kết quả thật khi xong.
Đánh đổi cần cân nhắc
Client phải sinh key đúng cách. Idempotency chỉ hoạt động nếu client sinh key một lần trước lần thử đầu và dùng lại cho mọi retry của cùng thao tác logic — không phải sinh key mới mỗi HTTP call. Sinh key sai chỗ là mất toàn bộ bảo vệ. Đây là hợp đồng giữa client và server, không chỉ là code server.
Lưu kết quả tốn chỗ, nên phải prune. Mỗi key giữ một bản kết quả; giữ mãi thì kho phình. Stripe prune sau 24h — đủ lâu cho mọi retry hợp lý, đủ ngắn để không tích tụ. Chọn TTL theo cửa sổ retry thực tế của bạn.
Idempotency ≠ transaction. Nó ngăn lặp một thao tác, không đảm bảo tính nguyên tử bên trong thao tác. Trừ tiền + ghi sổ vẫn cần transaction riêng. Idempotency là lớp chống trùng, đặt trên (không thay thế) tính đúng đắn giao dịch.
Ba ý mang về
- Retry là bắt buộc để chịu lỗi mạng, nhưng với thao tác không-idempotent (thanh toán) retry ngây thơ = tính tiền trùng — đo thật: 5 retry không idempotency trừ tiền 5 lần.
- Idempotency key giải gọn: lưu kết quả lần đầu theo key, request trùng trả lại kết quả cũ mà không thực thi lại — đo thật trên Redis (
SET NXatomic +EX 86400), trừ tiền đúng 1 lần dù retry 5 lần, và REPLAY trả đúng kết quả gốc. - Làm đúng cần hợp đồng client-server + chi tiết: client sinh key một lần dùng lại; server lưu cả status+body (kể cả lỗi), so tham số chống dùng nhầm, xử lý in-flight, và prune theo TTL.
Nguồn
- Stripe API Reference — Idempotent requests: https://docs.stripe.com/api/idempotent_requests
- Stripe Blog — Designing robust and predictable APIs with idempotency: https://stripe.com/blog/idempotency
Phần sau ta xét cách Instagram sinh ID duy nhất, sắp theo thời gian mà không cần điểm trung tâm — dựng thật trong PostgreSQL đúng scheme họ công bố.