Mục lục
- Mục Tiêu Bài Học
- Bài Toán Double Payment
- Idempotency Là Gì
- Idempotency Key — Ý Tưởng
- Pattern Cơ Bản: Redis SET NX
- 3 Trạng Thái Của Idempotency Key
- Lưu Response Để Replay
- TTL Cho Idempotency Key
- Atomic Với Lua Script
- Idempotency vs Distributed Lock
- Client-generated vs Server-generated Key
- Kết Hợp Redis + DB Unique Constraint
- Production Pattern Stripe-like
- Webhook Idempotency
- Anti-patterns
- Best Practice
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Giải thích bài toán double payment và các nguồn gốc gây ra duplicate request trong thực tế.
- Phân biệt operation idempotent tự nhiên (GET, PUT) và operation cần làm idempotent nhân tạo (POST, payment).
- Implement idempotency key pattern cơ bản với Redis
SET NX EX. - Hiểu 3 trạng thái của idempotency key: chưa tồn tại, processing, done.
- Viết Lua script atomic để check-and-set tránh race condition giữa 2 request đồng thời.
- Phân biệt idempotency key với distributed lock — khác mục đích, khác cách dùng.
- Implement production pattern kết hợp Redis fast-path + DB unique constraint làm source of truth.
- Nhận diện 6 anti-pattern phổ biến làm hỏng idempotency scheme.
Bài Toán Double Payment
Double payment xảy ra khi cùng một payment intent được thực thi nhiều hơn một lần. Các tình huống phổ biến trong production:
- User click đúp: button "Pay" không bị disable ngay sau click đầu, user click lại trước khi response về.
- Network timeout: request đầu đến server và đã charge thành công, nhưng response bị mất trước khi client nhận. Client không biết đã thành công, retry.
- Client retry logic: HTTP library có retry tự động (axios-retry, requests với
Retryadapter, gRPC retry policy). Nếu server trả 500 hoặc connection timeout giữa chừng, library gửi lại request — dù server đã xử lý xong. - Mobile app background/foreground: app vào background giữa chừng, OS kill process, user mở lại app, app retry từ trạng thái "chưa biết kết quả".
- Browser refresh: user nhấn F5 sau khi submit form chứa payment, browser hỏi "resend form data?" và user xác nhận.
Tất cả các tình huống trên đều có điểm chung: client gửi request nhiều lần nhưng chỉ muốn một lần charge. Vấn đề là server không phân biệt được "retry của cùng payment" với "payment mới hoàn toàn" — trừ khi có cơ chế phân biệt rõ ràng.
# Timeline của double payment điển hình
Client Network Server
| | |
|--- POST /charge -------->| |
| |--- (chuyển tiếp) ->|
| | |-- charge_card() OK
| |<-- 200 OK ---------|
| | |
| (response bị mất | |
| do network drop) | |
| | |
|--- POST /charge (retry)->| |
| |--- (chuyển tiếp) ->|
| | |-- charge_card() OK ← duplicate!
| |<-- 200 OK ---------|
|<-- 200 OK --------------| |
Idempotency Là Gì
Một operation được gọi là idempotent khi thực hiện N lần cho cùng input tạo ra cùng kết quả như thực hiện 1 lần. Trạng thái hệ thống sau N lần = trạng thái sau 1 lần.
Một số operation tự nhiên idempotent:
- GET: đọc 100 lần không thay đổi dữ liệu.
- PUT (full replace):
PUT /users/42 {"name": "Alice"}gọi 5 lần, record vẫn là{"name": "Alice"}. - DELETE: xóa record đã xóa không gây lỗi (nếu implement đúng — trả 204, không 404-as-error).
- Redis SET (không NX): set cùng key-value 10 lần, kết quả giống nhau.
Các operation không tự nhiên idempotent:
- POST (create): gọi 3 lần tạo 3 record.
- Payment / charge: gọi 3 lần charge 3 lần.
- Redis INCR: gọi 3 lần tăng 3 đơn vị.
- Email send: gọi 3 lần gửi 3 email.
Idempotency key là kỹ thuật thêm vào để làm các operation không-idempotent trở nên safe khi gửi nhiều lần: server nhận diện "đây là retry của request đã xử lý" và trả lại kết quả cũ thay vì xử lý lại.
Idempotency Key — Ý Tưởng
Ý tưởng cốt lõi rất đơn giản:
- Client tạo một unique key trước khi gửi request, gắn với payment intent cụ thể đó (ví dụ UUID v4).
- Client gửi key cùng request, thường qua HTTP header
Idempotency-Key: <uuid>. - Server nhận request: nếu key chưa thấy bao giờ → xử lý bình thường, lưu kết quả cùng với key. Nếu key đã xử lý xong → trả lại kết quả cũ, không xử lý lại.
- Khi client retry (cùng key), server nhận ra đây là duplicate và trả ngay kết quả đã lưu.
# Client side — tạo key một lần, dùng lại cho mọi retry
import uuid
payment_intent_key = str(uuid.uuid4()) # "a3f2c1d0-8e4b-4f7a-9c5e-1234567890ab"
# Lần đầu và mọi lần retry đều gửi cùng key này
headers = {"Idempotency-Key": payment_intent_key}
response = requests.post("/charge", json={"amount": 9900}, headers=headers)
# Nếu timeout hoặc error → retry với CÙNG key
if response.status_code in (500, 503) or timeout:
response = requests.post("/charge", json={"amount": 9900}, headers=headers)
# Server trả kết quả cũ nếu đã xử lý, hoặc xử lý mới nếu chưa
Key phải đủ unique để không xung đột giữa các payment khác nhau. UUID v4 (128 bit random) đáp ứng yêu cầu này. Một số hệ thống dùng ULID hoặc payment_intent_id do hệ thống tạo ra.
Pattern Cơ Bản: Redis SET NX
SET key value NX EX ttl set key chỉ khi key chưa tồn tại (NX = Not eXists). Đây là primitive cốt lõi của idempotency key trên Redis.
import redis
import json
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
class InProgress(Exception):
pass
def process_payment(idempotency_key: str, order_id: str, amount: int):
lock_key = f"idem:{idempotency_key}"
result_key = f"idem:result:{idempotency_key}"
TTL = 86400 # 24 giờ
# SET NX — chỉ request đầu tiên acquire được
acquired = redis_client.set(lock_key, "processing", nx=True, ex=TTL)
if not acquired:
# Key đã tồn tại → có thể đang processing hoặc đã done
state = redis_client.get(lock_key)
if state == "done":
# Đã xử lý xong → trả kết quả cũ (replay)
cached = redis_client.get(result_key)
if cached:
return json.loads(cached)
# state == "processing" → request khác đang xử lý
raise InProgress("Request đang được xử lý, vui lòng thử lại sau vài giây")
# Request đầu tiên — xử lý thật sự
try:
result = charge_card(order_id, amount) # gọi payment gateway
# Lưu kết quả trước khi đổi state
redis_client.setex(result_key, TTL, json.dumps(result))
redis_client.setex(lock_key, TTL, "done")
return result
except Exception:
# Thất bại → xóa lock để cho phép retry
redis_client.delete(lock_key)
raise
Luồng trên hoạt động nhưng có một race condition nhỏ: giữa lệnh get(lock_key) và lệnh tiếp theo, state có thể thay đổi. Section 9 giải quyết bằng Lua atomic script.
3 Trạng Thái Của Idempotency Key
Một idempotency key đi qua 3 trạng thái trong vòng đời của nó:
| Trạng thái | Lock key value | Hành động server |
|---|---|---|
| Chưa tồn tại | (không có key) | SET NX thành công → xử lý lần đầu |
| Processing | "processing" |
SET NX thất bại, get thấy "processing" → trả lỗi "đang xử lý, retry sau" |
| Done | "done" |
SET NX thất bại, get thấy "done" → đọc result key, trả kết quả cũ |
Transition hợp lệ:
- Chưa tồn tại → Processing: request đầu tiên acquire.
- Processing → Done: xử lý thành công, lưu result.
- Processing → Chưa tồn tại: xử lý thất bại, delete lock để cho phép retry.
Không có transition từ Done ngược lại. Một khi done, key tồn tại đến hết TTL và mọi request dùng key đó đều nhận kết quả cũ.
Lưu Response Để Replay
Anti-pattern phổ biến nhất: chỉ lưu flag "đã xử lý" mà không lưu kết quả. Kết quả là retry nhận được lỗi thay vì kết quả cũ.
# SAI — chỉ lưu flag, không lưu result
redis.set("idem:done:key123", "1", ex=86400)
# Retry → biết đã xử lý, nhưng không biết kết quả là gì → trả lỗi 409?
# ĐÚNG — lưu full response để replay
result = {
"status": "success",
"transaction_id": "txn_abc123",
"amount": 9900,
"currency": "VND",
"charged_at": "2026-05-28T10:30:00Z"
}
redis.setex("idem:result:key123", 86400, json.dumps(result))
# Retry → trả đúng kết quả client mong đợi
cached = redis.get("idem:result:key123")
return json.loads(cached) # {"status": "success", "transaction_id": "txn_abc123", ...}
Stripe lưu full response bao gồm HTTP status code, response body, và timestamp. Khi replay, client nhận response y hệt như lần đầu — không phân biệt được đây là retry hay request gốc. Đây là behavior đúng: client không cần biết (và không nên cần biết) đây là retry.
TTL Cho Idempotency Key
TTL phải đủ dài để bao phủ toàn bộ retry window của client:
- Quá ngắn: key expire trước khi client retry → server coi đây là request mới → double charge.
- Quá dài: tốn memory, key tích lũy nhiều trong Redis.
Quy tắc thực tế:
- 24 giờ là mức phổ biến nhất — Stripe dùng 24h cho idempotency key.
- TTL của lock key và result key nên giống nhau để tránh tình trạng lock key còn sống nhưng result key đã expire (hoặc ngược lại).
- Khi gia hạn TTL (SETEX lại sau khi done), set cùng TTL tính từ thời điểm hiện tại, không để result key expire trước lock key.
TTL_SECONDS = 86400 # 24 giờ
# Lưu result trước, đổi state sau — thứ tự quan trọng
redis.setex(result_key, TTL_SECONDS, json.dumps(result))
redis.setex(lock_key, TTL_SECONDS, "done")
# Nếu đổi lock_key thành "done" trước mà result_key chưa có
# → request khác thấy "done" nhưng GET result_key trả None → lỗi
Atomic Với Lua Script
Pattern ở Section 5 có một race condition: giữa SET NX thất bại và GET state tiếp theo, state có thể chuyển từ "processing" sang "done". Lua script loại bỏ race này bằng cách gộp check-and-return thành một atomic operation:
-- KEYS[1] = lock key (vd "idem:abc123")
-- KEYS[2] = result key (vd "idem:result:abc123")
-- ARGV[1] = TTL (86400)
-- Trả về: "NEW", "IN_PROGRESS", hoặc serialized result JSON
local existing = redis.call('GET', KEYS[1])
if existing == false then
-- Key chưa tồn tại → đây là request đầu tiên
redis.call('SET', KEYS[1], 'processing', 'EX', ARGV[1])
return 'NEW'
elseif existing == 'done' then
-- Đã xử lý xong → trả result
local result = redis.call('GET', KEYS[2])
if result then
return result
else
-- Trường hợp hiếm: lock_key "done" nhưng result_key expire
-- Reset để cho phép reprocess
redis.call('DEL', KEYS[1])
return 'NEW'
end
else
-- existing == 'processing'
return 'IN_PROGRESS'
end
import redis
import json
IDEM_CHECK_SCRIPT = """
local existing = redis.call('GET', KEYS[1])
if existing == false then
redis.call('SET', KEYS[1], 'processing', 'EX', ARGV[1])
return 'NEW'
elseif existing == 'done' then
local result = redis.call('GET', KEYS[2])
if result then return result end
redis.call('DEL', KEYS[1])
return 'NEW'
else
return 'IN_PROGRESS'
end
"""
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
idem_check = r.register_script(IDEM_CHECK_SCRIPT)
def process_payment_atomic(idem_key: str, order_id: str, amount: int):
lock_key = f"idem:{idem_key}"
result_key = f"idem:result:{idem_key}"
TTL = 86400
outcome = idem_check(keys=[lock_key, result_key], args=[TTL])
if outcome == "IN_PROGRESS":
raise InProgress("Đang xử lý, thử lại sau 1-2 giây")
if outcome != "NEW":
# outcome là JSON result đã lưu
return json.loads(outcome)
# NEW — xử lý lần đầu
try:
result = charge_card(order_id, amount)
r.setex(result_key, TTL, json.dumps(result))
r.setex(lock_key, TTL, "done")
return result
except Exception:
r.delete(lock_key)
raise
Lua script chạy atomic trên một Redis node — không có command nào từ client khác xen giữa các lệnh trong script. Đây là lý do Lua được dùng rộng rãi cho các pattern cần check-then-act trong Redis.
Idempotency vs Distributed Lock
Hai kỹ thuật này hay bị nhầm lẫn vì đều dùng Redis SET NX và đều liên quan đến "chỉ một lần":
| Chiều so sánh | Distributed Lock | Idempotency Key |
|---|---|---|
| Mục đích | Mutual exclusion: chỉ 1 process trong critical section cùng lúc | Duplicate suppression: cùng operation không chạy >1 lần |
| Release | Lock phải được release sau khi xong | Key giữ đến hết TTL (intentionally) |
| Retry | Retry acquire lock sau khi release | Retry nhận kết quả cũ, không xử lý lại |
| Kết quả lưu | Không lưu (lock không quan tâm result) | Bắt buộc lưu result để replay |
| Use case điển hình | Cron dedup, inventory update, leader election | Payment, create order, send notification |
Idempotency key thường đủ cho bài toán chống duplicate payment — không cần thêm distributed lock. Lock giải quyết coordination, idempotency giải quyết replay safety. Đây là hai công cụ cho hai bài toán khác nhau.
Client-generated vs Server-generated Key
Có hai cách key được tạo ra:
Client-generated (khuyến nghị cho payment):
- Client tạo UUID trước khi gửi request lần đầu.
- Client lưu key vào local storage hoặc biến.
- Mọi retry đều dùng cùng key đó.
- Nếu client crash và restart, key có thể bị mất → retry không có key → không bảo vệ được. Nhưng đây thường là acceptable: crash-restart thường coi là user action mới.
Server-generated:
- Client gửi request không có key → server tạo key, trả về cùng response.
- Client lưu key từ response → dùng cho retry tiếp theo.
- Nhược điểm lớn: request đầu tiên không được bảo vệ. Nếu response đầu bị mất (đúng scenario cần bảo vệ nhất), client không có key để retry.
Stripe, Braintree, và hầu hết payment provider đều dùng client-generated key. Client tạo key (thường là payment intent ID) trước khi gọi API, dùng lại cho toàn bộ retry window.
# Client-generated — cách đúng cho payment
payment_key = str(uuid.uuid4()) # tạo 1 lần
# Lần đầu
response = client.post("/v1/charge",
headers={"Idempotency-Key": payment_key},
json={"amount": 9900, "currency": "vnd"})
# Retry sau 3 giây nếu timeout — cùng key
if timed_out:
response = client.post("/v1/charge",
headers={"Idempotency-Key": payment_key},
json={"amount": 9900, "currency": "vnd"})
Kết Hợp Redis + DB Unique Constraint
Redis là fast-path nhưng không phải source of truth: key có thể bị evict nếu Redis dùng eviction policy (allkeys-lru), hoặc mất khi Redis restart mà không có persistence phù hợp. Nếu idempotency key bị mất khỏi Redis, request tiếp theo bị coi là mới → double charge.
Giải pháp production: dùng Redis làm fast-path, DB làm arbiter cuối cùng:
-- DB schema
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(255) UNIQUE NOT NULL, -- unique constraint
order_id VARCHAR(255) NOT NULL,
amount INTEGER NOT NULL,
status VARCHAR(50) NOT NULL,
response_json TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
def idempotent_charge_with_db(idem_key: str, order_id: str, amount: int):
result_key = f"idem:result:{idem_key}"
TTL = 86400
# 1. Fast-path: check Redis cache
cached = redis_client.get(result_key)
if cached:
return json.loads(cached)
# 2. Acquire processing lock
lock_key = f"idem:lock:{idem_key}"
if not redis_client.set(lock_key, "1", nx=True, ex=60):
raise InProgress("Đang xử lý, thử lại sau")
try:
# 3. Double-check DB (source of truth)
existing = db.query(
"SELECT response_json FROM payments WHERE idempotency_key = %s",
[idem_key]
).fetchone()
if existing:
result = json.loads(existing["response_json"])
else:
# 4. Xử lý thật sự
result = charge_card(order_id, amount)
# 5. Lưu vào DB trước (ON CONFLICT DO NOTHING bảo vệ khỏi race)
db.execute("""
INSERT INTO payments (idempotency_key, order_id, amount, status, response_json)
VALUES (%s, %s, %s, 'completed', %s)
ON CONFLICT (idempotency_key) DO NOTHING
""", [idem_key, order_id, amount, json.dumps(result)])
# 6. Cache vào Redis để fast-path lần sau
redis_client.setex(result_key, TTL, json.dumps(result))
return result
finally:
redis_client.delete(lock_key) # release processing lock dù thành công hay thất bại
DB unique constraint là lưới an toàn cuối cùng: dù có race condition và hai request đồng thời đều vượt qua Redis check, chỉ một INSERT thành công — cái còn lại bị ON CONFLICT DO NOTHING chặn lại. Không có double record trong DB, không có double charge.
Production Pattern Stripe-like
Kết hợp tất cả các thành phần trên vào một hàm production hoàn chỉnh:
import redis
import json
import uuid
from dataclasses import dataclass
from typing import Optional
@dataclass
class ChargeResult:
transaction_id: str
amount: int
status: str
idempotency_key: str
class ConcurrentRequestError(Exception):
def __init__(self, retry_after: int):
self.retry_after = retry_after
def idempotent_charge(idem_key: str, order_id: str, amount: int) -> ChargeResult:
"""
Charge user, bảo đảm không double charge dù gọi nhiều lần với cùng idem_key.
Pattern:
1. Redis fast-path check (sub-millisecond)
2. Processing lock (max 60s — đủ cho payment gateway timeout)
3. DB double-check (source of truth)
4. Charge + DB write (unique constraint bảo vệ)
5. Cache vào Redis
"""
result_key = f"idem:result:{idem_key}"
lock_key = f"idem:lock:{idem_key}"
TTL = 86400 # 24h — đủ dài cho mọi retry window hợp lệ
# 1. Fast-path — hầu hết retry đều kết thúc ở đây
cached = r.get(result_key)
if cached:
return ChargeResult(**json.loads(cached))
# 2. Acquire lock — ngăn concurrent processing của cùng key
# ex=60: nếu server crash giữa chừng, lock tự expire sau 60s
if not r.set(lock_key, "1", nx=True, ex=60):
raise ConcurrentRequestError(retry_after=2)
try:
# 3. DB double-check — bảo vệ khi Redis cache miss do eviction
row = db.fetchone(
"SELECT * FROM payments WHERE idempotency_key = %s", [idem_key]
)
if row:
result = ChargeResult(
transaction_id=row["transaction_id"],
amount=row["amount"],
status=row["status"],
idempotency_key=idem_key
)
else:
# 4. Gọi payment gateway — operation thật
txn = payment_gateway.charge(order_id=order_id, amount=amount)
# 5. Lưu DB với unique constraint
db.execute("""
INSERT INTO payments
(idempotency_key, order_id, amount, transaction_id, status)
VALUES (%s, %s, %s, %s, 'completed')
ON CONFLICT (idempotency_key) DO NOTHING
""", [idem_key, order_id, amount, txn.id])
result = ChargeResult(
transaction_id=txn.id,
amount=amount,
status="completed",
idempotency_key=idem_key
)
# 6. Cache để fast-path lần sau
r.setex(result_key, TTL, json.dumps(result.__dict__))
return result
except payment_gateway.CardDeclinedError:
# Payment thất bại do card — KHÔNG xóa lock key ở đây.
# Lưu kết quả "failed" để retry nhận lại cùng response.
failed_result = ChargeResult(
transaction_id="", amount=amount, status="declined",
idempotency_key=idem_key
)
r.setex(result_key, TTL, json.dumps(failed_result.__dict__))
r.setex(lock_key, TTL, "done")
return failed_result
except Exception:
# Lỗi infrastructure (DB down, gateway timeout) → xóa lock
# để cho phép retry xử lý lại
r.delete(lock_key)
raise
finally:
# Release processing lock (chỉ khi thành công — các nhánh lỗi
# đã tự xử lý lock ở trên)
r.delete(lock_key)
Lưu ý xử lý hai loại lỗi khác nhau: lỗi business (card declined) và lỗi infrastructure (DB timeout). Lỗi business có kết quả xác định — lưu lại, không cho retry xử lý khác. Lỗi infrastructure không có kết quả — xóa lock, để retry thử lại từ đầu.
Webhook Idempotency
Payment provider (Stripe, PayPal, VNPay) gửi webhook theo semantics at-least-once delivery: nếu không nhận được HTTP 200 trong thời gian timeout, provider gửi lại. Cùng event có thể đến 2-3 lần.
@app.route("/webhook/payment", methods=["POST"])
def payment_webhook():
payload = request.json
event_id = payload["id"] # "evt_1NqIVB2eZvKYlo2C8JfMbf9J"
event_type = payload["type"] # "payment_intent.succeeded"
# Dùng event_id làm idempotency key
idem_key = f"webhook:{event_id}"
# SET NX — chỉ xử lý lần đầu
if not redis_client.set(idem_key, "1", nx=True, ex=86400):
# Đã xử lý event này rồi → acknowledge để provider không retry
return {"status": "already_processed"}, 200
try:
# Xử lý event
if event_type == "payment_intent.succeeded":
order_id = payload["data"]["object"]["metadata"]["order_id"]
fulfill_order(order_id)
return {"status": "processed"}, 200
except Exception as e:
# Thất bại → xóa key để provider retry được nhận diện là mới
redis_client.delete(idem_key)
return {"status": "error"}, 500
Quan trọng: trả về HTTP 200 ngay cả khi đã xử lý rồi ("already_processed"). Nếu trả 4xx hoặc 5xx, provider tiếp tục retry. Sau khi xử lý thành công, luôn trả 200 để dừng retry loop.
Anti-patterns
- 1. Chỉ lưu flag, không lưu result
- Server biết key đã xử lý nhưng không biết kết quả là gì. Retry nhận lỗi 409 thay vì kết quả cũ. Client không thể phân biệt "operation thất bại" với "đây là duplicate".
- 2. TTL quá ngắn
- Client retry sau 30 phút, key đã expire, server coi là request mới. Double charge. TTL phải dài hơn retry window của mọi client, kể cả client chậm nhất.
- 3. Không atomic check-and-set
- Hai request đến đồng thời, cả hai đều GET thấy key chưa tồn tại trước khi SET. Cả hai đều "win" SET NX trong race condition nhỏ (hiếm nhưng xảy ra). Dùng Lua script để atomic.
- 4. Chỉ Redis, không có DB constraint
- Redis evict key do memory pressure (allkeys-lru). Key biến mất → request tiếp theo bị coi là mới → double charge. DB unique constraint là lưới an toàn cuối.
- 5. Server-generated key cho payment
- Scenario nguy hiểm nhất (response bị mất sau khi server đã xử lý) không được bảo vệ vì client không có key để gửi trong retry.
- 6. Xóa key khi success
redis.delete(lock_key)sau khi payment thành công. Key biến mất → retry đến coi là request mới → double charge. Chỉ xóa key khi xử lý FAIL (để cho phép retry). Khi success, giữ key đến hết TTL.
Best Practice
- Client-generated key cho payment: UUID v4 tạo phía client, lưu cùng payment intent, dùng lại cho mọi retry.
- Lưu full result: serialize toàn bộ response (status, body, timestamp), replay y hệt cho retry.
- TTL 24h: bao phủ mọi retry window thực tế, kể cả mobile app với session dài.
- Redis fast-path + DB unique constraint: Redis cho throughput, DB làm source of truth chống mất key.
- Lua script hoặc SET NX atomic: tránh race condition giữa check và set.
- Xóa lock key CHỈ khi fail infrastructure: khi success hoặc business error, giữ key đến hết TTL.
- Webhook: trả 200 khi đã xử lý: không để provider retry vô tận cho event đã handled.
- Phân biệt business error và infrastructure error: business error (card declined) → lưu kết quả, không retry. Infrastructure error (DB timeout) → xóa lock, retry từ đầu.
Tổng Kết & Quiz
- Double payment xảy ra khi client retry nhưng server không nhận ra đây là duplicate của request đã xử lý.
- Idempotency key: client tạo UUID một lần, gửi với mọi retry, server check-then-process hoặc replay kết quả cũ.
- 3 trạng thái: chưa tồn tại → processing → done. Chỉ xóa key khi fail (không phải khi success).
- Bắt buộc lưu full result để replay — không chỉ flag.
- Lua script đảm bảo check-and-set atomic, loại bỏ race condition.
- Redis là fast-path, DB unique constraint là source of truth — dùng cả hai.
- Idempotency key và distributed lock là hai công cụ cho hai bài toán khác nhau: lock cho mutual exclusion, idempotency cho duplicate suppression.
Quiz 5 câu
- Giải thích tại sao chỉ lưu flag
"done"mà không lưu result là anti-pattern. Hậu quả cụ thể với client là gì? - Hai request với cùng idempotency key đến đồng thời trong 1 millisecond. Code dùng
GETrồiSET NXriêng biệt (không Lua). Mô tả race condition có thể xảy ra. - Tại sao phải lưu result_key TRƯỚC rồi mới đổi lock_key sang "done"? Nếu đổi thứ tự, điều gì xảy ra?
- Redis dùng eviction policy
allkeys-lruvà đang gần hết memory. Idempotency key bị evict. Request retry tiếp theo sẽ bị xử lý như thế nào? Cơ chế nào ngăn double payment trong tình huống này? - Một user nhấn "Pay", app bị crash (out of memory). User mở lại app, nhấn "Pay" lần hai. App không còn payment intent key của lần trước. Đây có phải double payment không? Nên xử lý thế nào?
Đáp án gợi ý
- Khi chỉ lưu flag, server biết key đã xử lý nhưng không biết kết quả là gì. Retry nhận 409 Conflict (hoặc tương tự) thay vì response gốc. Client phải tự query trạng thái riêng thay vì nhận được idempotent response. Quan trọng hơn, client không thể phân biệt "lần đầu fail" với "đây là duplicate của lần đã thành công".
- Cả hai request cùng GET → cả hai thấy key chưa tồn tại → cả hai SET NX → đúng ra chỉ một thành công, nhưng nếu cả hai GET xảy ra trước khi bất kỳ SET nào chạy, race là giữa 2 SET NX: cái đến sau trả
nil(thất bại). Tuy nhiên với code GET-sau-đó-SET (non-atomic), khoảng gap giữa hai lệnh cho phép state thay đổi mà logic không xử lý đúng — ví dụ state chuyển processing→done giữa GET và lệnh tiếp theo của request B. - Nếu đổi lock_key sang "done" trước khi set result_key: request C đến ngay lúc đó, thấy "done", GET result_key → trả về
None. Code gặp edge case null, hoặc trả lỗi cho client thay vì result. Luôn lưu result trước, đổi state sau. - Redis evict key → request retry không tìm thấy → SET NX thành công → xử lý như request mới → potential double charge. DB unique constraint ngăn điều này: INSERT với idempotency_key bị ON CONFLICT DO NOTHING, không tạo thêm payment record. Redis chỉ là cache; DB là arbiter cuối cùng.
- Đây không phải double payment theo định nghĩa idempotency — user có intent mới sau crash, không có idempotency key của lần trước. Về mặt kỹ thuật, đây là hai payment separate. Cần xử lý ở tầng business logic: xem lần trước có payment nào trong 5 phút gần nhất cho cùng order chưa, nếu có → hỏi user xác nhận trước khi charge lại. Idempotency key bảo vệ retry, không bảo vệ user confusion.
Bài tiếp theo
Bài 49 triển khai leader election với Redis — đảm bảo chỉ một scheduler hoặc worker chạy tại một thời điểm trong cluster.
