Idempotency trong REST API: Tuyệt chiêu chống “double charge” với Node.js và Redis

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Tại sao Idempotency lại quan trọng đến thế?

Thử đặt mình vào vị trí khách hàng: Bạn nhấn nút “Thanh toán” cho đơn hàng 1 triệu đồng. Mạng lag, vòng tròn xoay mãi không dừng. Bạn sốt ruột nhấn thêm 3 lần nữa. Đến khi nhận thông báo, tài khoản đã bay sạch 4 triệu đồng. Đây không phải lỗi của người dùng, mà là lỗ hổng nghiêm trọng của lập trình viên Backend.

Số liệu từ các hệ thống thanh toán lớn cho thấy tỉ lệ request bị lặp lại do retry (từ client hoặc network) có thể chiếm tới 0.5% – 2% tổng lưu lượng. Nếu không có cơ chế kiểm soát, database của bạn sẽ sớm trở thành một đống hỗn độn.

Hồi mình còn làm ở startup Fintech, một lỗi nghẽn mạng từng khiến hệ thống ghi nhận hàng trăm giao dịch trùng. Mình đã phải thức trắng đêm để rollback dữ liệu thủ công. Sau cú sốc đó, mình nhận ra Idempotency (tính nhất quán khi lặp lại) là thứ bắt buộc phải có cho mọi API nhạy cảm.

Cơ chế tự nhiên của các HTTP Method

Không phải phương thức nào cũng cần xử lý Idempotency. Hãy nhìn vào thiết kế của chúng:

  • GET, HEAD: Bản chất là chỉ đọc. Bạn gọi 1 lần hay 1.000 lần thì dữ liệu trên server vẫn y nguyên.
  • PUT: Dùng để ghi đè. Nếu bạn gửi cùng một payload nhiều lần, kết quả cuối cùng vẫn là trạng thái đó.
  • DELETE: Xóa một tài nguyên. Xóa xong rồi, các lần gọi sau chỉ đơn giản là xác nhận tài nguyên đó không còn tồn tại.
  • POST: Đây là nguồn cơn của mọi rắc rối. Mỗi lần gọi POST thường tạo ra một bản ghi mới (Order, Transaction). Chúng ta cần tập trung xử lý cho phương thức này.

Chiến lược Idempotency Key

Giải pháp tối ưu nhất là sử dụng một Idempotency Key gửi kèm trong Header của Request. Quy trình chuẩn sẽ như sau:

  1. Client sinh ra một mã UUID duy nhất cho mỗi hành động và gửi qua Header x-idempotency-key.
  2. Server nhận request và kiểm tra Key này trong Redis.
  3. Nếu tìm thấy Key: Server trả về ngay kết quả đã lưu trước đó mà không chạy lại logic nghiệp vụ.
  4. Nếu Key mới hoàn toàn: Server thực hiện xử lý, lưu kết quả vào Redis kèm TTL (Time To Live), rồi mới phản hồi cho Client.

Thực hành: Xây dựng Middleware với Node.js và Redis

Chúng ta sẽ dùng Express.js kết hợp với Redis để tạo ra một lớp bảo vệ vững chắc cho API.

1. Thiết lập môi trường

Cài đặt các thư viện cần thiết để bắt đầu:

npm install express redis uuid

2. Khởi tạo kết nối Redis

Redis là lựa chọn hoàn hảo vì tốc độ truy xuất cực nhanh (dưới 1ms) và khả năng tự hủy key sau một khoảng thời gian (TTL).

const express = require('express');
const redis = require('redis');
const { v4: uuidv4 } = require('uuid');

const app = express();
app.use(express.json());

const redisClient = redis.createClient();
redisClient.connect().then(() => console.log('✅ Redis đã sẵn sàng'));

const CACHE_TTL = 3600; // Cache kết quả trong 60 phút

3. Viết Middleware xử lý thông minh

Middleware này sẽ đóng vai trò người gác cổng, ngăn chặn các request trùng lặp trước khi chúng chạm đến Controller.

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

  try {
    const cached = await redisClient.get(`idempotency:${key}`);
    if (cached) {
      const { status, body } = JSON.parse(cached);
      return res.status(status).json(body);
    }

    // Ghi đè res.send để capture kết quả trả về
    const originalSend = res.send;
    res.send = function (body) {
      if (res.statusCode >= 200 && res.statusCode < 300) {
        redisClient.setEx(`idempotency:${key}`, CACHE_TTL, 
          JSON.stringify({ status: res.statusCode, body: JSON.parse(body) })
        );
      }
      return originalSend.call(this, body);
    };
    next();
  } catch (err) {
    next(err);
  }
};

Ba lưu ý sống còn khi triển khai thực tế

Đừng vội copy code vào production ngay, bạn cần xử lý thêm 3 vấn đề sau:

Tránh Race Condition: Nếu hai request cùng ập đến trong 1ms, Redis có thể chưa kịp lưu key đầu tiên. Hãy dùng lệnh SET NX để tạo một cái lock tạm thời ngay khi nhận request.

Phạm vi của Key: Đừng tin tưởng hoàn toàn vào UUID từ client. Hãy kết hợp nó với User ID (ví dụ: idempotency:user_99:key_abc) để tránh trường hợp đụng độ key giữa các tài khoản khác nhau.

Chỉ cache thành công: Tuyệt đối không cache lỗi 500 hoặc 503. Nếu hệ thống của bạn bị sập lúc xử lý, bạn phải cho phép client thử lại khi server đã hồi phục.

Kết luận

Triển khai Idempotency không chỉ là vấn đề kỹ thuật, mà là trách nhiệm với dữ liệu của khách hàng. Với Node.js và Redis, bạn có thể xây dựng một lớp bảo vệ cực kỳ hiệu quả chỉ với vài dòng code. Hãy áp dụng ngay cho các API thanh toán hoặc đặt hàng để hệ thống của bạn chuyên nghiệp và đáng tin cậy hơn.

Share: