Triển khai tính Idempotency trong Node.js REST API với Redis

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Ngăn chặn các hành động trùng lặp trước khi chúng tác động đến cơ sở dữ liệu

Một khách hàng nhấn “Thanh toán ngay”. Biểu tượng quay tròn treo trong ba giây. Trong cơn hoảng loạn, họ nhấn nút thêm bốn lần nữa. Nếu không có biện pháp bảo vệ, máy chủ của bạn có thể xử lý giao dịch đó nhiều lần, làm cạn kiệt tài khoản ngân hàng của họ chỉ cho một đơn hàng duy nhất. Đây là lúc idempotency trở thành tính năng backend quan trọng nhất của bạn.

Một thao tác idempotent đảm bảo rằng việc thực hiện cùng một hành động nhiều lần sẽ tạo ra cùng một kết quả. Trong REST API, điều này có nghĩa là nếu một client thử lại một yêu cầu do hết thời gian chờ (timeout), máy chủ sẽ nhận diện được, xử lý đúng một lần duy nhất và trả về phản hồi thành công ban đầu cho mọi nỗ lực tiếp theo.

Cái giá của việc sai sót

Tôi đã từng thấy các hệ thống không có lớp bảo vệ này tính phí người dùng 480 USD cho một gói đăng ký 120 USD chỉ vì một sự cố microservice ngắn hạn. Để giải quyết sai lầm đó, cần sáu giờ vá lỗi cơ sở dữ liệu thủ công và một lời xin lỗi chính thức từ trưởng nhóm hỗ trợ. Làm chủ idempotency không chỉ là sở thích kỹ thuật; đó là về việc bảo vệ tính toàn vẹn của dữ liệu và niềm tin của người dùng.

Một ví dụ triển khai tối giản

Bạn chỉ cần Node.js và một instance Redis để bắt đầu. Redis là tiêu chuẩn ngành ở đây vì nó tự động xử lý việc hết hạn key và hoạt động với độ trễ cực thấp (dưới một mili giây).

npm install express ioredis

Ví dụ sau đây sử dụng ioredis để kiểm tra một key duy nhất trước khi thực hiện bất kỳ logic nghiệp vụ nào:

const express = require('express');
const Redis = require('ioredis');
const redis = new Redis();
const app = express();
app.use(express.json());

app.post('/payments', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Yêu cầu header Idempotency-Key' });
  }

  // Kiểm tra phản hồi trong cache trước
  const cachedResponse = await redis.get(`idempotency:${idempotencyKey}`);
  if (cachedResponse) {
    return res.status(200).json(JSON.parse(cachedResponse));
  }

  // Xử lý logic thực tế (ví dụ: tính phí thẻ)
  const result = { success: true, txnId: 'TXN-9982', total: req.body.amount };
  
  // Lưu kết quả vào cache trong 24 giờ (86400 giây)
  await redis.set(`idempotency:${idempotencyKey}`, JSON.stringify(result), 'EX', 86400);

  res.status(201).json(result);
});

app.listen(3000, () => console.log('Máy chủ đang chạy tại cổng 3000'));

Cơ chế của một vòng đời yêu cầu mạnh mẽ

Mặc dù phương pháp kiểm tra-và-thiết lập (check-and-set) cơ bản hoạt động tốt cho các dự án cá nhân, nhưng các môi trường production có lưu lượng truy cập cao đòi hỏi khả năng phục hồi tốt hơn. Chúng ta dựa vào một thỏa thuận cụ thể giữa client và server: Idempotency-Key.

Trách nhiệm của Client

Frontend hoặc ứng dụng di động phải tạo một V4 UUID duy nhất cho mỗi giao dịch mới. Nếu mạng gặp lỗi và client thử lại, nó sẽ gửi chính xác cùng một UUID đó. Server sử dụng chuỗi này làm khóa tìm kiếm chính trong Redis.

Cách Server xử lý yêu cầu

  1. Trích xuất: Lấy Idempotency-Key từ header của yêu cầu.
  2. Xác thực: Kiểm tra Redis ngay lập tức. Nếu kết quả đã tồn tại, bỏ qua logic và trả về dữ liệu trong cache.
  3. Khóa (Locking): Đánh dấu key là “In Progress” (Đang xử lý) để ngăn chặn tình trạng race condition.
  4. Thực thi: Thực hiện các công việc nặng, chẳng hạn như gọi Stripe hoặc cập nhật các bảng SQL của bạn.
  5. Lưu trữ: Lưu kết quả JSON cuối cùng vào Redis.
  6. Dọn dẹp: Thiết lập TTL (Time to Live) để bộ nhớ Redis luôn gọn gàng.

Tôi khuyên bạn nên triển khai điều này dưới dạng middleware. Cách tiếp cận này giúp các controller của bạn tập trung vào logic nghiệp vụ trong khi bảo vệ mọi route POST hoặc PUT chỉ với một dòng code.

Đánh bại Race Condition bằng các thao tác nguyên tử

Logic tiêu chuẩn có một khiếm khuyết tiềm ẩn. Nếu hai yêu cầu giống hệt nhau gửi tới API của bạn cách nhau chỉ 5ms, cả hai có thể thấy cache Redis trống và kích hoạt việc tính phí gấp đôi. Đây là một lỗi race condition kinh điển.

Sử dụng Redis làm khóa phân tán (Distributed Lock)

Chúng ta giải quyết vấn đề này bằng cách sử dụng lệnh Redis SET với cờ NX (Set if Not eXists). Điều này cho phép chúng ta khóa key một cách nguyên tử ngay khi yêu cầu đầu tiên đến.

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

  const redisKey = `idempotency:${key}`;

  // Cố gắng lấy khóa trong 60 giây
  const lockAcquired = await redis.set(redisKey, 'STARTED', 'NX', 'EX', 60);

  if (!lockAcquired) {
    const status = await redis.get(redisKey);
    if (status === 'STARTED') {
      return res.status(409).json({ error: 'Đang xử lý. Vui lòng đợi.' });
    }
    return res.status(200).json(JSON.parse(status));
  }

  // Ghi đè res.json để tự động lưu kết quả cuối cùng vào cache
  const originalJson = res.json;
  res.json = (body) => {
    redis.set(redisKey, JSON.stringify(body), 'EX', 86400);
    return originalJson.call(res, body);
  };

  next();
}

Xử lý các nỗ lực thử lại đồng thời

Nếu một yêu cầu thử lại đến trong khi yêu cầu đầu tiên vẫn đang được xử lý, chúng ta sẽ trả về lỗi 409 Conflict. Điều này báo hiệu cho client tạm dừng trước khi thử lại. Đây là một giải pháp sạch hơn nhiều so với việc chạy logic hai lần một cách mù quáng và hy vọng điều tốt nhất sẽ đến.

Điều gì xảy ra nếu máy chủ của bạn bị sập trong quá trình thực thi? TTL 60 giây cho trạng thái ‘STARTED’ đảm bảo khóa cuối cùng sẽ hết hạn. Điều này cho phép client thử lại và thành công ngay cả sau khi hệ thống gặp sự cố.

Mẹo triển khai chiến lược

Code chỉ là một nửa cuộc chiến. Bạn phải tuân theo các quy ước đã thiết lập để đảm bảo API của bạn vẫn có thể dự đoán được đối với các nhà phát triển khác.

Khi nào cần sử dụng Idempotency

Hãy tập trung năng lượng vào các phương thức không có tính idempotent. Các yêu cầu GET mặc định là idempotent; việc tải lại trang hồ sơ 100 lần không bao giờ được thay đổi tên của người dùng. Hãy tập trung vào các yêu cầu POST nơi xảy ra các tác dụng phụ (side effects), chẳng hạn như tạo đơn hàng, kích hoạt hoàn tiền hoặc gửi email hàng loạt.

Chọn giá trị TTL

Bạn nên giữ các bản ghi này trong bao lâu? Đối với các giao dịch tài chính, 24 đến 48 giờ là “điểm vàng” trong ngành. Khoảng thời gian này đủ dài để client phục hồi sau một kết nối kém nhưng đủ ngắn để giữ cho instance Redis của bạn không bị phình to. Nếu bạn cần theo dõi các yêu cầu trùng lặp trong nhiều tháng, hãy chuyển các bản ghi từ Redis sang một bộ lưu trữ vĩnh viễn như PostgreSQL hoặc MongoDB sau 24 giờ đầu tiên.

Giám sát là yếu tố sống còn. Luôn ghi log các “hit” idempotency. Nếu bạn nhận thấy một sự gia tăng đột ngột của các key trùng lặp, điều đó thường cho thấy một bug trong logic thử lại của client hoặc vấn đề nghiêm trọng về độ trễ trong hạ tầng của bạn. Những bản ghi log này đóng vai trò như tín hiệu cảnh báo sớm cho sức khỏe tổng thể hệ thống của bạn.

Xây dựng những biện pháp bảo vệ này đòi hỏi nỗ lực thêm lúc ban đầu. Tuy nhiên, đó là sự khác biệt giữa một bản mẫu mong manh và một hệ thống tài chính chuyên nghiệp. Người dùng của bạn—và cả giấc ngủ của bạn—sẽ cảm ơn bạn vì điều đó.

Share: