
Ngừng Bỏ Qua Idempotency: Xử Lý API Trùng Lặp An Toàn
Tìm hiểu cách xây dựng API an toàn, xử lý triệt để các request trùng lặp bằng Idempotency Key, Redis lock và DB constraints.
Xây dựng một API đáng tin cậy đòi hỏi bạn phải xử lý an toàn các request trùng lặp mà không làm sai lệch dữ liệu. Bài viết này sẽ hướng dẫn cách áp dụng Idempotency Key để ngăn chặn triệt để lỗi double-charge và race condition trong các hệ thống phân tán.
Sự Hỗn Loạn Của Các Request Trùng Lặp
Bạn đã bao giờ gặp cảnh này chưa: User bấm nút "Thanh toán", nhưng do mạng 3G đang chập chờn, giao diện bị treo vòng vòng. Sốt ruột và bực mình, họ bấm thêm nút "Thanh toán" một phát nữa. Bùm! Thẻ tín dụng của họ bị trừ tiền hai lần. Khách hàng nổi điên gọi điện mắng vốn, bộ phận kế toán phải hì hục làm lệnh hoàn tiền, còn team dev thì ngơ ngác gãi đầu.
Trong môi trường distributed systems (hệ thống phân tán), chuyện mạng chập chờn là chuyện cơm bữa. Khi một request bị timeout, client (app mobile hoặc web) hoàn toàn mù tịt, không biết là server đã xử lý xong chưa hay là request đã rớt dọc đường.
Phản xạ tự nhiên của mọi ứng dụng là tự động retry (thử lại). Nhưng nếu server đã trừ tiền rồi mà ta lại retry, hệ thống sẽ thực hiện thao tác đó lần thứ 2. Làm sao để xây dựng API an toàn, chấp nhận retry mà không gây lỗi double-charge (trừ tiền 2 lần)?
Chìa khóa để giải quyết bài toán này nằm ở một khái niệm: Idempotency.
Tính Luỹ Đẳng (Idempotency) Là Gì?

Về mặt toán học, một phép toán có tính luỹ đẳng (idempotent) nếu bạn thực hiện nó 1 lần hay n lần thì kết quả cuối cùng đối với hệ thống vẫn y hệt nhau.
Trong thiết kế API, một endpoint được gọi là idempotent khi việc gọi nó nhiều lần với cùng một thông số sẽ không làm thay đổi trạng thái hệ thống khác đi so với lần gọi đầu tiên.
Theo chuẩn HTTP, các method như GET, PUT, DELETE bản chất đã là idempotent. Bạn xóa user có ID 123 mười lần thì kết quả cuối cùng vẫn chỉ là: user đó đã bị xóa.
Nhưng POST thì không. Mỗi lần gọi POST /orders, server sẽ hiểu là bạn muốn tạo một đơn hàng mới toanh. Để biến một POST request thành idempotent, ta cần dạy cho server cách nhận diện retry. Server phải biết nhận định: "À, mình đã xử lý chính xác cái request này rồi, không làm lại nữa đâu nhé".
Giải Pháp: Sử Dụng Idempotency Key

Pattern phổ biến nhất hiện nay, được các ông lớn như Stripe áp dụng triệt để, là dùng một custom header mang tên Idempotency-Key.
Cách hoạt động theo từng bước như sau:
- Client khởi tạo: Client sinh ra một chuỗi định danh duy nhất (ví dụ UUIDv7) đại diện cho một hành động của user.
- Gửi Request lần 1: Client gắn chuỗi này vào header:
Idempotency-Key: <uuid>. - Server kiểm tra: Khi nhận request, khoan vội chạy logic. Server sẽ check trong một bộ nhớ đệm (như Redis) hoặc Database xem key này đã tồn tại chưa.
- Nếu Key đã tồn tại: Server biết đây là đồ cũ. Nó bỏ qua bước xử lý, lôi ngay kết quả (response payload) của lần chạy thành công trước đó ra và trả về cho client.
- Nếu Key chưa tồn tại: Server khóa key lại, chạy logic trừ tiền, lưu kết quả thành công vào cache (kèm với key), rồi trả response về.
Ví Dụ Thực Chiến Với Node.js
Hãy thử xem qua một đoạn pseudo-code viết bằng Node.js và Redis để hình dung rõ hơn. Dù đơn giản nhưng nó bao hàm đầy đủ tư tưởng:
async function processCheckout(req, res) {
const idempotencyKey = req.header('Idempotency-Key');
if (!idempotencyKey) {
return res.status(400).json({ error: 'Thiếu header Idempotency-Key rồi' });
}
// 1. Kiểm tra xem request này đã được xử lý xong từ trước chưa
const cachedResponse = await redis.get(`idemp:${idempotencyKey}`);
if (cachedResponse) {
// Trả y nguyên kết quả cũ, giả vờ như vừa xử lý xong
return res.status(200).json(JSON.parse(cachedResponse));
}
// 2. Chống race condition: chặn các request đến cùng 1 milisecond
const lock = await redis.set(`lock:${idempotencyKey}`, '1', 'NX', 'EX', 10);
if (!lock) {
// Request khác cầm cùng key đang chạy
return res.status(409).json({ error: 'Hệ thống đang xử lý, vui lòng đợi.' });
}
try {
// 3. Thực thi logic nghiệp vụ quan trọng (trừ tiền, tạo đơn)
const orderResult = await executePayment(req.body);
// 4. Lưu lại kết quả để xài cho những lần retry sau (ví dụ giữ 24h)
await redis.set(`idemp:${idempotencyKey}`, JSON.stringify(orderResult), 'EX', 86400);
return res.status(201).json(orderResult);
} finally {
// 5. Chạy xong thì nhớ mở khóa
await redis.del(`lock:${idempotencyKey}`);
}
}
Đoạn middleware này như một tấm khiên vững chắc, bảo vệ core logic của bạn khỏi vô số lỗi khó lường.
Những Sai Lầm Thường Gặp (Pitfalls)
Pattern này nhìn qua thì dễ, nhưng khi code thực tế, anh em dev rất hay mắc vài lỗi chí mạng.
1. Server Tự Sinh Idempotency Key
Đây là lỗi ngớ ngẩn nhất. Key bắt buộc phải do Client (Web/Mobile) tạo ra. Nếu server tự sinh key, mỗi khi client rớt mạng và gọi lại, client không hề có key cũ để gửi. Lúc này, server lại tự sinh ra một key mới toanh và coi đó là 2 request độc lập. Tốt nhất là Frontend nên tạo ra một UUID ngay khi user mở màn hình checkout, và sử dụng lại chính UUID đó cho tất cả các lần bấm thử lại.
2. Trả Về Lỗi Thay Vì Kết Quả Cũ
Giả sử request đầu tiên đã chạy xong trên server, nhưng đứt mạng nên client không nhận được response. Client bèn retry. Nếu lúc này server phát hiện trùng key và ném ra lỗi 400 Duplicate Request, client sẽ hoang mang và tưởng là giao dịch thất bại! Đúng ra, server phải lưu lại cái response thành công của lần 1, và trả y nguyên cục JSON đó về cho lần 2. Đối với client, lần retry đó vẫn là thành công trót lọt.
3. Không Xử Lý Race Condition
Nếu user click đúp chuột 2 lần liên tục trong chớp mắt, 2 request sẽ bay tới server gần như cùng lúc. Nếu code của bạn ngây thơ viết kiểu if (!redis.get(key)), cả 2 request sẽ đều thấy cache trống rỗng, và rủ nhau cùng trừ tiền user. Bạn bắt buộc phải dùng các Atomic Operations — ví dụ như Redis SET NX (chỉ set nếu key chưa tồn tại) hoặc Row-level Lock trong DB — để đảm bảo khe cửa hẹp chỉ lọt qua được đúng 1 thread.
Chốt Chặn Cuối Cùng: Database Constraints
Idempotency Key kết hợp với Redis là một giải pháp cực nhanh và xịn. Nhưng trong kỹ thuật phần mềm, đừng bao giờ tin tưởng tuyệt đối vào một layer duy nhất. Lỡ Redis bị sập, bị restart, hoặc tự động xóa key do đầy RAM thì sao?
Database mới là chốt chặn cuối cùng. Bạn phải luôn bảo vệ hệ thống bằng DB Constraints.
Ví dụ, trong bảng payments, hãy tạo một UNIQUE INDEX trên cột idempotency_key. Khi xui rủi xảy ra và Redis lỡ cho lọt request thứ 2, database sẽ từ chối thẳng thừng bằng một lỗi Unique Constraint Violation. Thà trả về một cái lỗi 500 xấu xí cho client còn hơn là âm thầm trừ tiền user 2 lần.
Lời Kết
Idempotency không phải là tính năng "thích thì làm, không thì thôi". Nó là yêu cầu bắt buộc đối với mọi API có thao tác thay đổi dữ liệu quan trọng như thanh toán, đặt vé, hay gửi email. Xây dựng API xịn là phải chấp nhận một sự thật phũ phàng: mạng kiểu gì cũng rớt và client kiểu gì cũng retry.
Lần tới, khi bạn ngồi gõ một endpoint POST, hãy dừng lại 3 giây và tự hỏi: "Nếu endpoint này bị réo tên 2 lần trong cùng một phần nghìn giây thì hệ thống có sập không?".
Hãy ngừng bỏ qua Idempotency. Trang bị Idempotency Key, gắn thêm Database Constraint, rồi bạn có thể tự tin vỗ ngực cho user click cháy chuột!
viết bởi
Nguyên Tech
Phản hồi
Đang tải bình luận…