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ả

Ảnh chụp đoạn mã nền tối minh hoạ Stripe idempotency key thanh toán retry không bị tính tiền hai lần Idempotency-Key SET NX atomic trên Redis lưu kết quả lần đầu trả lại khi retry, một vấn đề mạng chập chờn client retry tính tiền hai lần client gửi charge 10 đô mạng rớt trước khi nhận phản hồi dù server đã tính tiền client retry nếu server không nhận ra cùng một thao tác thì tính tiền lần nữa giải client sinh Idempotency-Key một lần dùng lại cho mọi retry cùng thao tác, hai Lua atomic trên Redis claim hoặc trả lại kiểu Stripe 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 SET NX chỉ đặt nếu chưa tồn tại nguyên tử EX 86400 tự prune sau 24h như Stripe, ba 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ả Stripe còn so tham số request với lần đầu khác thì báo lỗi

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")

Ảnh chụp bảng kết quả chạy thật trên Redis 7.4 output thật, có idempotency 1 request cộng 4 retry cùng key mạng chập chờn 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 bằng 1 dù retry 5 lần, không idempotency 5 retry mỗi lần trừ tiền tiền đã trừ bằng 5 tính tiền 5 lần double charge khách nổi giận, retry trả đúng kết quả cũ không phải giá trị mới 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, Stripe làm gì nguồn Stripe API docs blog idempotency lưu lần đầu lưu status code cộng body của request đầu tiên theo Idempotency-Key trả lại request trùng key trả cùng kết quả kể cả lỗi 500 client sinh key 1 lần UUID v4 dùng lại cho mọi retry cùng thao tác prune key tự xoá sau 24h SET EX 86400 so tham số so tham số với lần đầu khác thì báo lỗi chống dùng nhầm key

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ề

  1. 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.
  2. 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 NX atomic + EX 86400), trừ tiền đúng 1 lần dù retry 5 lần, và REPLAY trả đúng kết quả gốc.
  3. 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

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ố.