0

[Series ioredis - Bài 5] Giải Quyết Tận Gốc "Race Condition" Với Lua Script & Distributed Lock

Chào các bạn! Chúng ta đã đi qua một chặng đường dài với ioredis, từ những thao tác CRUD cơ bản, tối ưu Pipeline, cho đến kiến trúc Cluster phân tán. Hôm nay, chúng ta sẽ đối mặt với một "con quái vật" thực sự trong thế giới Backend: Race Condition (Trạng thái tranh chấp).

Khi hệ thống của bạn có hàng nghìn request đổ vào cùng một lúc, những đoạn code tưởng chừng như hoàn hảo khi test ở local lại bắt đầu sinh ra dữ liệu sai lệch. Làm sao để đảm bảo tính toàn vẹn của dữ liệu? Hãy cùng tìm hiểu hai vũ khí tối thượng trong Redis: Lua Script và Distributed Lock.

1. Vấn đề: "Race Condition" là gì?

Hãy lấy một ví dụ rất thực tế trong các hệ thống trừ tiền ví điện tử hoặc thẻ vé tàu điện (AFC). Giả sử một thẻ đang có số dư 50.000đ. Giá vé cho một lượt đi là 15.000đ.

Quy trình bình thường (khi có 1 request):

  1. GET số dư hiện tại (50.000).
  2. Kiểm tra (50.000 >= 15.000).
  3. Trừ tiền (50.000 - 15.000 = 35.000).
  4. SET số dư mới (35.000).

Nhưng nếu có 2 request đến CÙNG MỘT LÚC (do lỗi chạm thẻ 2 lần hoặc network retry) thì sao?

  • Request A và Request B cùng GET được số dư là 50.000.
  • Cả hai cùng vượt qua bước kiểm tra.
  • Cả hai cùng thực hiện phép trừ và SET số dư mới là 35.000.

Kết quả: Thẻ bị trừ tiền 2 lần trên hệ thống log, nhưng số dư trong Redis chỉ bị trừ 1 lần. Công ty bạn vừa bị thất thoát tiền!

2. Giải pháp 1: Sức mạnh tuyệt đối của Lua Script

Redis hoạt động theo cơ chế Single-Threaded (đơn luồng) khi xử lý các lệnh. Nếu chúng ta có thể gom cả 4 bước (GET, Kiểm tra, Trừ tiền, SET) thành MỘT lệnh duy nhất, thì Race Condition sẽ biến mất. Đó chính là lúc Lua Script tỏa sáng.

Khi một Lua Script được gửi lên Redis, Redis sẽ khóa toàn bộ các thao tác khác lại, thực thi xong script đó từ đầu đến cuối một cách nguyên tử (Atomicity) rồi mới xử lý request tiếp theo.

Cách ioredis hỗ trợ Lua Script cực kỳ thanh lịch: Thay vì phải dùng hàm eval dài dòng, ioredis cho phép bạn định nghĩa (define) script thành một hàm (method) có thể gọi trực tiếp.

const Redis = require("ioredis");
const redis = new Redis();

// Định nghĩa một command mới tên là "deductFare"
redis.defineCommand("deductFare", {
  numberOfKeys: 1,
  lua: `
    -- Lấy số dư hiện tại (KEYS[1])
    local balance = tonumber(redis.call('GET', KEYS[1]) or "0")
    -- Giá vé cần trừ (ARGV[1])
    local fare = tonumber(ARGV[1])
    
    if balance >= fare then
      -- Đủ tiền -> Thực hiện trừ bằng lệnh DECRBY
      redis.call('DECRBY', KEYS[1], fare)
      return balance - fare -- Trả về số dư mới
    else
      -- Không đủ tiền
      return -1 
    end
  `,
});

async function processPayment(cardId, fareAmount) {
  const cardKey = `card:${cardId}:balance`;
  
  // Gọi hàm deductFare như một hàm native của ioredis
  const result = await redis.deductFare(cardKey, fareAmount);
  
  if (result === -1) {
    console.log("❌ Thẻ không đủ số dư!");
  } else {
    console.log(`✅ Thanh toán thành công. Số dư còn lại: ${result}`);
  }
}

// Dù gọi đồng thời 100 lần, số dư vẫn sẽ được trừ chính xác từng đồng một!

3. Giải pháp 2: Distributed Lock (Khóa phân tán)

Lua Script rất tuyệt khi toàn bộ logic của bạn chỉ thao tác với dữ liệu nằm SẴN trong Redis. Nhưng nếu trong lúc xử lý, bạn phải gọi API sang một bên thứ 3 (như ngân hàng) hoặc query một database khác (như MySQL) thì sao? Bạn không thể viết API call trong Lua Script được.

Lúc này, bạn cần một Distributed Lock. Cơ chế cơ bản nhất là dùng lệnh SET với tham số NX (Not eXists) và PX (thời gian hết hạn - TTL để tránh Deadlock).

async function acquireLock(lockKey, lockValue, ttlSeconds) {
  // SET key value EX ttl NX
  // Trả về 'OK' nếu set thành công (lấy được lock)
  // Trả về null nếu key đã tồn tại (không lấy được lock)
  const result = await redis.set(lockKey, lockValue, "EX", ttlSeconds, "NX");
  return result === "OK";
}

async function releaseLock(lockKey, lockValue) {
  // Chỉ xóa lock nếu value khớp (đảm bảo không xóa nhầm lock của request khác)
  const luaScript = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
  `;
  await redis.eval(luaScript, 1, lockKey, lockValue);
}

// --- Ứng dụng vào thực tế ---
async function handleCriticalTask(userId) {
  const lockKey = `lock:user:${userId}`;
  const lockValue = Math.random().toString(36); // Unique ID cho request này
  
  // Cố gắng lấy lock trong 10 giây
  const isLocked = await acquireLock(lockKey, lockValue, 10);
  
  if (!isLocked) {
    console.log("Hệ thống đang bận xử lý request khác cho user này. Vui lòng thử lại!");
    return;
  }
  
  try {
    console.log("Đã lấy được lock, bắt đầu xử lý logic phức tạp (DB, API)...");
    // Khối code an toàn khỏi Race Condition
    // await callExternalAPI();
    // await updateMySQL();
  } catch (error) {
    console.error("Lỗi xử lý:", error);
  } finally {
    // LUÔN LUÔN nhả lock sau khi xong việc
    await releaseLock(lockKey, lockValue);
  }
}

Tổng kết Series

Trải qua 5 bài viết, chúng ta đã cùng nhau "giải phẫu" ioredis từ những kết nối sơ khai, tối ưu hiệu năng với Pipeline, giao tiếp qua Pub/Sub, cho đến việc đảm bảo tính sẵn sàng cao với Cluster/Sentinel và cuối cùng là bảo vệ toàn vẹn dữ liệu với Lua Script/Distributed Lock.

Backend là một thế giới rộng lớn và đầy thách thức. Hy vọng series này đã trang bị cho các bạn những kiến thức thực chiến vững chắc nhất để tự tin đưa Redis và Node.js vào các dự án Production.

Cảm ơn các bạn đã theo dõi! Nếu có bất kỳ thắc mắc hay chủ đề nào muốn thảo luận thêm, hãy để lại bình luận phía dưới nhé.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí