Retry và queue "at-least-once" đảm bảo một điều phũ phàng: một thao tác sẽ bị gọi nhiều lần. Timeout ở phía bạn không có nghĩa là hệ thống ngoài chưa xử lý — có thể nó đã charge tiền xong nhưng phản hồi bị rớt. Nếu bạn retry, khách bị trừ hai lần. Idempotency là thuộc tính: gọi cùng một thao tác N lần cho ra đúng một kết quả như gọi một lần.
Idempotency key
Cơ chế phổ biến nhất: mỗi thao tác mang một idempotency key duy nhất, ổn định qua các lần retry. Hệ thống ngoài lưu key này; lần gọi thứ hai với cùng key sẽ trả về kết quả cũ thay vì thực hiện lại.
Key phải sinh từ thứ ổn định của nghiệp vụ, không phải từ thời gian hay số ngẫu nhiên mỗi lần gọi:
# ĐÚNG: key ổn định theo đơn + bước
idem_key = f"order:{order_id}:charge"
# SAI: mỗi lần retry ra key khác -> mất tác dụng
idem_key = str(uuid4()) # sinh lại mỗi lần gọi
Sinh key một lần, lưu vào step_data, và tái sử dụng ở mọi lần retry.
Phía nhận: bảng khử trùng lặp
Nếu bạn là người xây service nhận request (hoặc consumer của một queue), tự bảo vệ bằng bảng dedup:
CREATE TABLE processed_ops (
idem_key VARCHAR(128) PRIMARY KEY,
result JSON,
created_at TIMESTAMP DEFAULT now()
);
def charge(idem_key, amount):
existing = db.get("processed_ops", idem_key)
if existing:
return existing.result # trả kết quả cũ, KHÔNG charge lại
result = do_real_charge(amount)
db.insert("processed_ops", idem_key, result) # UNIQUE chặn đua
return result
Khóa PRIMARY KEY trên idem_key biến hai request song song thành một: request thứ hai đụng ràng buộc unique và phải đọc kết quả đã có.
Thao tác vốn dĩ idempotent
Một số thao tác tự nhiên đã idempotent, hãy tận dụng:
PUT status = 'PAID'(đặt giá trị) idempotent;balance = balance - 100(cộng dồn) thì không.- "Tạo nếu chưa có" (upsert theo natural key) idempotent hơn "tạo mới" (INSERT sinh ID mới mỗi lần).
Khung tư duy 3 lớp phòng thủ
- Key ổn định sinh từ nghiệp vụ, lưu bền.
- Phía gửi luôn gửi cùng key khi retry.
- Phía nhận khử trùng lặp bằng key (hoặc dùng natural key + upsert).
Các bước áp dụng
- Với mỗi bước có side-effect, xác định key nghiệp vụ ổn định (
order_id+tên bước). - Sinh key một lần khi tạo bước, lưu vào
step_data. - Truyền key đó vào adapter ở mọi lần gọi, kể cả retry.
- Nếu kiểm soát được phía nhận, thêm bảng
processed_opsvới PK là key. - Đổi các cập nhật "cộng dồn" thành "đặt giá trị" khi có thể.
- Test: gọi cùng thao tác hai lần, xác nhận chỉ một hiệu ứng.
Thời gian sống của idempotency key
Bảng processed_ops không thể phình mãi. Đặt chính sách dọn (TTL) hợp lý: key nên sống đủ lâu để bao phủ toàn bộ cửa sổ retry có thể xảy ra (ví dụ vài giờ tới vài ngày, tùy quy trình). Dọn quá sớm là nguy hiểm — nếu một retry rất trễ tới sau khi key đã bị xóa, bạn sẽ xử lý lại thao tác. Với các bước tiền bạc, hãy giữ key lâu hơn và chỉ dọn khi chắc chắn không còn retry nào bay tới.
Idempotency với event/message queue
Phần lớn message broker giao "at-least-once", nghĩa là consumer sẽ nhận message trùng. Đừng tin vào 'exactly-once' của broker; hãy tự khử trùng lặp ở consumer bằng một khóa nghiệp vụ. Một mẫu phổ biến: mỗi message mang message_id, consumer ghi message_id vào bảng dedup trong cùng transaction với thao tác nghiệp vụ. Nếu commit thành công, message được ack; nếu message trùng tới sau, ràng buộc unique chặn nó.
def handle(msg):
with db.transaction():
if db.exists("seen_messages", msg.id):
return # đã xử lý
apply_business_effect(msg)
db.insert("seen_messages", msg.id) # cùng transaction
Gói cả hai trong một transaction là mấu chốt: hiệu ứng nghiệp vụ và dấu "đã xử lý" phải thành công hoặc thất bại cùng nhau.
Sai lầm thường gặp
- Sinh key mới mỗi lần gọi (uuid trong vòng retry): idempotency vô hiệu.
- Key trùng giữa các bước: dùng chung một key cho "reserve" và "charge" khiến hệ thống nhầm là trùng lặp, bỏ qua bước thật.
- Chỉ chặn trùng bằng SELECT rồi INSERT không có ràng buộc unique: hai request song song lọt cả hai. Phải để DB ép unique.
- Tưởng mọi retry đều an toàn mà không kiểm tra thao tác có idempotent không — chuyển khoản cộng dồn là cạm bẫy kinh điển.