
API Idempotency: Xử Lý Retry An Toàn Chống Trùng Lặp
Tìm hiểu cách áp dụng Idempotency Key để xử lý retry an toàn trong API, ngăn chặn lỗi trừ tiền kép (double-charge) và xây dựng hệ thống backend bền bỉ.
Đã bao giờ bạn phải toát mồ hôi xử lý một bug report báo rằng người dùng bị trừ tiền hai lần cho cùng một đơn hàng, hoặc hai tài khoản y hệt nhau được tạo ra cách nhau vài mili-giây chưa? Nếu bạn đang làm backend, tôi cá là bạn đã từng trải qua cảm giác thót tim này.
Nguyên nhân sâu xa thường rất kinh điển: kết nối mạng chập chờn cộng với một người dùng thiếu kiên nhẫn (hoặc một cơ chế tự động gọi lại - retry - hơi quá đà). Khi client gửi một request đi và không nhận được phản hồi kịp lúc, nó thường mặc định là request đó đã thất bại và tự động gửi lại. Nhưng chuyện gì sẽ xảy ra nếu request ban đầu không hề thất bại, mà chỉ là gói tin phản hồi (response) từ server bị rớt dọc đường?
Chào mừng bạn đến với thực tế phũ phàng của hệ thống phân tán (distributed systems). Mạng luôn có thể đứt. Server luôn có lúc quá tải. Trong môi trường này, "retry" không phải là một tình huống hiếm gặp, mà là một yêu cầu bắt buộc. Thế nhưng, nếu API của bạn không được thiết kế để xử lý an toàn các request gọi lại này, bạn đang ôm một quả bom nổ chậm.
Đó là lúc khái niệm Idempotency (Tính luỹ đẳng) xuất hiện như một vị cứu tinh.
Rốt cuộc Idempotency là cái gì?
Trong toán học, một phép toán được gọi là "luỹ đẳng" nếu việc bạn áp dụng nó nhiều lần cũng chỉ đem lại kết quả giống như khi áp dụng đúng một lần. Ví dụ dễ hiểu nhất là nhân một số với 1, hoặc lấy giá trị tuyệt đối của một số.
Áp dụng vào thế giới REST API, một endpoint được xem là idempotent nếu việc client gửi đi gửi lại cùng một request sẽ không làm thay đổi trạng thái của server khác đi so với lần gọi thành công đầu tiên.
Theo chuẩn HTTP, một số method sinh ra đã mang tính chất này:
- GET: Chỉ đọc dữ liệu, không làm thay đổi trạng thái. Bạn gọi GET 1 lần hay 1000 lần thì database vẫn y nguyên.
- PUT: Cập nhật toàn bộ một resource. Nếu bạn gửi request set
status = 'active', thì dù gửi 10 lần, kết quả cuối cùng vẫn chỉ làactive. - DELETE: Xóa dữ liệu. Lần gọi đầu tiên sẽ xóa record; các lần sau có thể báo lỗi 404, nhưng trạng thái cuối cùng (dữ liệu không còn tồn tại) vẫn không đổi.
Vùng nguy hiểm thực sự nằm ở POST. Một request POST thường được dùng để tạo mới một resource hoặc kích hoạt một luồng nghiệp vụ quan trọng (chẳng hạn như chuyển tiền). Gọi POST hai lần đồng nghĩa với việc tạo ra hai resource, hoặc chuyển tiền hai lần. Nhiệm vụ của chúng ta là phải tìm cách làm cho các POST request này trở nên an toàn khi bị gọi lặp lại.
Giải pháp thực chiến: Idempotency Keys

Để cho phép client có thể tự tin retry các request POST mà không sợ hậu quả, chúng ta cần một cơ chế để server nhận biết được: đây là một request hoàn toàn mới, hay chỉ là "bản nháp" gọi lại của một request trước đó. Vũ khí đó mang tên Idempotency Key.
Luồng hoạt động thực tế thường diễn ra theo các bước sau:
- Client tạo Key: Trước khi gửi một request quan trọng (như thanh toán giỏ hàng), client sẽ tự generate một mã định danh duy nhất, phổ biến nhất là dùng UUID phiên bản 4.
- Đính kèm vào Header: Client nhét mã này vào HTTP header của request, thường dưới cái tên
Idempotency-Key: <uuid>. - Server kiểm tra: Khi server nhận được request, việc đầu tiên không phải là lao vào xử lý ngay, mà là mò vào database hoặc cache (như Redis) để xem mã key này đã từng xuất hiện chưa.
Dựa vào kết quả kiểm tra, server sẽ rẽ nhánh xử lý:
- Chưa từng thấy (Request mới): Server bắt tay vào xử lý logic nghiệp vụ. Sau khi xong, nó lưu lại kết quả (payload response và status code) cùng với key kia, rồi mới trả kết quả về cho client.
- Đã thấy và đã xử lý xong: Server nhận ra "À, ông này gọi rồi và tôi làm xong rồi". Nó bỏ qua toàn bộ phần xử lý logic nguy hiểm, chỉ việc lôi kết quả cũ trong cache ra và trả về.
- Đã thấy nhưng đang xử lý dở: Nếu request thứ hai bay tới trong lúc request đầu tiên vẫn đang chạy, server sẽ chặn lại ngay và trả về lỗi (thường là
409 Conflicthoặc429 Too Many Requests), báo cho client biết là "Từ từ, hệ thống đang làm rồi, lát quay lại kiểm tra sau".
Ví dụ thực tế với TypeScript và Express

Hãy cùng xem qua một đoạn code mô phỏng ý tưởng này bằng Node.js. Chú ý cách chúng ta chặn các luồng xử lý trùng lặp.
import express from 'express';
import { db, paymentGateway } from './services';
const app = express();
app.use(express.json());
app.post('/api/v1/payments', async (req, res) => {
const idempotencyKey = req.header('Idempotency-Key');
if (!idempotencyKey) {
return res.status(400).json({ error: 'Bắt buộc phải có header Idempotency-Key' });
}
// 1. Kiểm tra xem request này đã từng đến chưa
const existingRecord = await db.getIdempotencyRecord(idempotencyKey);
if (existingRecord) {
// Nếu đang xử lý dở, chặn ngay để tránh race condition
if (existingRecord.status === 'processing') {
return res.status(409).json({ error: 'Request đang được xử lý, vui lòng đợi' });
}
// Nếu đã xong từ trước, mừng quá, trả về luôn kết quả cũ! Không charge tiền 2 lần.
return res.status(existingRecord.statusCode).json(existingRecord.responseBody);
}
// 2. Đánh dấu là "đang xử lý" để chặn các request trùng lặp đến cùng lúc
await db.createIdempotencyRecord({
key: idempotencyKey,
status: 'processing',
});
try {
// 3. Thực hiện logic nghiệp vụ nguy hiểm (như gọi API trừ tiền)
const paymentResult = await paymentGateway.charge(req.body.amount, req.body.source);
// 4. Lưu lại kết quả thành công
await db.updateIdempotencyRecord(idempotencyKey, {
status: 'completed',
statusCode: 200,
responseBody: paymentResult,
});
return res.status(200).json(paymentResult);
} catch (error) {
// Nếu thất bại, ta có thể xóa key để client thử lại từ đầu,
// hoặc lưu trạng thái lỗi tùy vào yêu cầu nghiệp vụ thực tế.
await db.deleteIdempotencyRecord(idempotencyKey);
return res.status(500).json({ error: 'Thanh toán thất bại' });
}
});
Những "cái bẫy" cần tránh khi đưa lên Production
Ý tưởng thì đơn giản, nhưng khi code thực tế chạy trên môi trường production có hàng ngàn user, bạn sẽ cần để ý vài điểm mấu chốt sau:
1. Cái bẫy của Database Transaction
Trong đoạn code ví dụ phía trên, có một khoảng thời gian cực ngắn giữa lúc trừ tiền xong và lúc lưu kết quả vào database. Giả sử đúng khoảnh khắc đó server bị sập thì sao? Tiền của user thì bị trừ mất rồi, nhưng hệ thống của bạn thì vẫn ghi nhận là cái key đó đang "processing" hoặc chưa được lưu.
Để giải quyết triệt để, việc cập nhật trạng thái nghiệp vụ (vd: cập nhật số dư ví trong DB) và việc tạo record cho Idempotency Key bắt buộc phải nằm trong cùng một Database Transaction. Một tiến trình thất bại là cả hai cùng bị rollback lại (hoàn tác) ngay lập tức. Nếu bạn gọi API bên thứ ba (như Stripe), bạn phải dựa vào cơ chế idempotency của chính họ kết hợp với các kỹ thuật như two-phase commit.
2. Đừng tin client tuyệt đối (Payload Mismatches)
Chuyện gì xảy ra nếu một client ngớ ngẩn gửi cùng một cái Idempotency-Key cũ, nhưng body request thì lại chứa nội dung mới? (Ví dụ: Lần 1 đòi thanh toán 500k, lần 2 dùng lại key đó nhưng đòi thanh toán 1 triệu). Đây thường là lỗi bug ở phía frontend/client.
Để tự vệ, server của bạn nên băm (hash) cái request body nhận được ở lần đầu ra một chuỗi (ví dụ dùng SHA-256), và lưu kèm với key. Các lần sau gửi tới, cứ đem hash cái body mới so sánh với hash cũ. Lệch nhau một phát là từ chối bằng lỗi 400 Bad Request ngay.
3. Đừng giữ Key quá lâu (Time-to-Live)
Bạn không cần (và không nên) giữ lại các record idempotency mãi mãi. Việc retry thường chỉ diễn ra trong vòng vài giây đến vài phút sau request đầu tiên. Chuẩn chung của ngành là dùng các memory database có tốc độ đọc ghi nhanh như Redis và cài đặt thời gian sống (TTL) cho key tầm 24 đến 48 giờ. Hết thời gian này, dữ liệu rác sẽ tự bị dọn dẹp để tiết kiệm tài nguyên.
Lời kết
Viết API xịn không chỉ là viết sao cho nó chạy đúng ở kịch bản hoàn hảo (happy path), mà là phải lường trước hàng tá cách thức hệ thống có thể "hắt hơi sổ mũi". Timeout mạng và client retry là những hành vi hiển nhiên của thế giới phần mềm chứ không phải là bug hay trường hợp hiếm gặp.
Bằng cách bắt buộc áp dụng Idempotency Key cho các endpoint thay đổi trạng thái quan trọng, bạn đã biến "retry" từ một cơn ác mộng thành một hành vi an toàn, dễ đoán và dễ kiểm soát. Cả phía client, đội ngũ hỗ trợ khách hàng, và chính bản thân bạn sẽ có những giấc ngủ ngon hơn, vì không còn lo cảnh lỡ tay bấm 2 lần mà hệ thống lại trừ tiền 2 lần nữa.
Hãy rà soát lại các endpoint POST nhạy cảm trong kiến trúc hệ thống của bạn ngay hôm nay, và bắt đầu bảo vệ chúng bằng tính luỹ đẳng. Đây là một khoản đầu tư rất nhỏ về mặt logic nhưng đem lại lợi ích sống còn cho độ ổn định của toàn hệ thống.
viết bởi
Nguyên Tech
Phản hồi
Đang tải bình luận…