Triển Khai Idempotent API Để Không Bị Trừ Tiền

5 min read

Trong thế giới của Microservices và các hệ thống xử lý bất đồng bộ (Message Queue như RabbitMQ, Kafka), việc kết nối mạng bị chập chờn hay Request bị Timeout là chuyện xảy ra như cơm bữa.

Một kịch bản ác mộng của mọi Backend Developer: Người dùng bấm nút “Thanh toán” gói nâng cấp 50$, hệ thống gửi request sang Stripe/VNPAY. Mạng bị lag, phía Client không nhận được phản hồi nên báo lỗi. Người dùng sốt ruột bấm lại lần nữa (hoặc Client tự động Retry). Kết quả: Tài khoản người dùng bị trừ 100$ cho 2 lần bấm, hệ thống sinh ra 2 đơn hàng trùng lặp.

Đây chính là lúc bạn cần đến Idempotency (Tính khả trùng) – một tiêu chuẩn bắt buộc phải có cho các API nghiệp vụ cốt lõi.

Bản Chất Của Vấn Đề: Idempotency Là Gì Và Tại Sao API Thường Thiếu Nó?

Định nghĩa đơn giản: Một API được gọi là Idempotent nếu bạn có gọi nó 1 lần hay 100 lần với cùng một bộ dữ liệu, kết quả cuối cùng trên hệ thống (Database, số dư tài khoản) vẫn hoàn toàn không thay đổi so với lần gọi đầu tiên.

Thông thường, các Request dạng GET, PUT, DELETE mặc nhiên có tính Idempotent. Tuy nhiên, POST (Tạo mới dữ liệu/Thanh toán) thì KHÔNG. Nếu không có cơ chế kiểm soát, mỗi lần POST là một lần Database bị ghi mới hoặc tài khoản bị trừ tiền.

Để giải quyết triệt để, chúng ta sử dụng chiến lược Idempotency Key (Khóa khả trùng) do phía Client sinh ra (thường là một chuỗi UUID ngẫu nhiên) và đính kèm vào Header của mỗi request.

Kịch Bản Vận Hành Của Cơ Chế Idempotent API

Hãy theo dõi luồng đi của Request có sử dụng Idempotency-Key kết hợp với Redis (lưu tạm) và Database (lưu chính) qua bảng dưới đây:

BướcPhía Client (Frontend/Third-party)Phía Server (Backend)Tác động hệ thống
1. Gửi lần đầuGửi request POST /api/checkout kèm Header Idempotency-Key: abc-123.Kiểm tra Redis xem key abc-123 đã tồn tại chưa $\rightarrow$ Chưa.Tiến hành trừ tiền, tạo đơn hàng trong DB. Lưu kết quả thành công vào Redis với key abc-123. Trả về 200 OK.
2. Sự cố mạngMạng bị ngắt giữa chừng, Client không nhận được phản hồi 200 OK nên quyết định Retry lại y hệt dữ liệu cũ.Nhận lại request với Header Idempotency-Key: abc-123.Kiểm tra Redis $\rightarrow$ Đã tồn tại. Backend lập tức “bốc” luôn kết quả cũ trong Redis trả về, không chạy lại code xử lý tiền bạc nữa.
3. Gian lậnKẻ xấu cố tình đổi Data bên trong Body (ví dụ: đổi giá tiền) nhưng vẫn giữ nguyên Idempotency-Key: abc-123.Kiểm tra Request Body so với Hash lưu trong Redis.Nếu thấy Data bị thay đổi mà Key giữ nguyên $\rightarrow$ Trả về lỗi 400 Bad Request vì gian lận dữ liệu.

Hướng Dẫn Viết Code Triển Khai Thực Chiến Với Node.js & Redis

Hãy cùng hiện thực hóa giải pháp này bằng một đoạn Middleware Node.js (Express) sử dụng Redis để chặn đứng các request trùng lặp.

Bước 1: Khởi tạo Middleware kiểm tra Idempotency Key

JavaScript

const redis = require('./redisClient'); // Giả định đã kết nối Redis
const crypto = require('crypto');

const idempotencyMiddleware = async (req, res, next) => {
    const idempotencyKey = req.headers['idempotency-key'];

    // Nếu không có key, bỏ qua (hoặc bắt buộc tùy nghiệp vụ)
    if (!idempotencyKey) return next();

    // Tạo mã băm từ Request Body để tránh trường hợp trùng Key nhưng đổi Data (Gian lận)
    const requestBodyHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex');
    const redisKey = `idempotency:${idempotencyKey}`;

    try {
        const cachedResponse = await redis.get(redisKey);

        if (cachedResponse) {
            const { hash, statusCode, body } = JSON.parse(cachedResponse);

            // Kiểm tra xem dữ liệu gửi lên có trùng khớp với lần đầu không
            if (hash !== requestBodyHash) {
                return res.status(400).json({ error: "Idempotency Key đã được dùng cho một dữ liệu khác!" });
            }

            // Trả về ngay kết quả đã lưu từ trước mà không chạy xuống Controller
            return res.status(statusCode).json(body);
        }

        // Nếu chưa có trong Redis, găm thông tin tạm thời vào req để Controller sử dụng
        req.idempotencyKey = redisKey;
        req.requestBodyHash = requestBodyHash;
        
        // Ghi đè hàm res.json để tự động lưu kết quả vào Redis trước khi trả về cho Client
        const originalJson = res.json;
        res.json = function (data) {
            redis.setex(redisKey, 86400, JSON.stringify({ // Lưu trong 24 giờ
                hash: requestBodyHash,
                statusCode: res.statusCode,
                body: data
            }));
            return originalJson.call(this, data);
        };

        next();
    } catch (error) {
        next(error);
    }
};

Bước 2: Áp dụng vào Router thanh toán

JavaScript

app.post('/api/v1/payments', idempotencyMiddleware, async (req, res) => {
    // Logic trừ tiền, xử lý đơn hàng phức tạp ở đây...
    // Nhờ có middleware ở trên, đoạn code này ĐẢM BẢO chỉ chạy ĐÚNG 1 LẦN duy nhất.
    
    const order = await createOrderInDatabase(req.body);
    res.status(201).json({ success: true, orderId: order.id });
});

Giải Pháp Nâng Cao: Xử Lý “Race Condition” Khi Bấm Liên Tục

Có một lỗ hổng nâng cao: Nếu người dùng double-click quá nhanh, Request thứ 2 đến Server chỉ sau Request thứ 1 khoảng vài mili-giây. Lúc này, Request thứ 1 chưa kịp xử lý xong để lưu kết quả vào Redis. Kết quả là cả 2 request đều thấy Redis trống và cùng lao vào trừ tiền Database!

Để khắc phục, ngay khi Request thứ 1 vừa chạm vào Middleware, bạn phải lập tức ghi vào Redis một trạng thái tạm thời: idempotency:abc-123 -> "PROCESSING" (Sử dụng cơ chế Distributed Lock / Atomic Set).

Nếu Request thứ 2 đến mà thấy trạng thái "PROCESSING", Server sẽ lập tức trả về mã lỗi 409 Conflict (Yêu cầu chờ request trước xử lý xong) thay vì tiếp tục thực thi.

Kết Luận và Takeaway

Hệ thống chạy đúng trong điều kiện lý tưởng chưa bao giờ là đủ. Một Senior Developer thực thụ luôn thiết kế hệ thống với tư duy “Phòng bệnh hơn chữa bệnh”, giả định rằng mạng luôn có thể đứt và Client luôn có thể gửi trùng dữ liệu. Triển khai Idempotency chính là tấm lá chắn bảo vệ sự toàn vẹn cho dữ liệu tài chính của công ty bạn.

Takeaway: Với mọi API làm thay đổi số dư, tạo giao dịch hoặc tạo dữ liệu quan trọng: Client bắt buộc phải sinh Idempotency-Key (UUID) $\rightarrow$ Server dùng Redis làm màng lọc để kiểm tra, chặn đứng trùng lặp và trả về kết quả cũ nếu bị trùng Key.

Avatar photo

Leave a Reply

Your email address will not be published. Required fields are marked *