Redis Distributed Lock: Khóa phân tán an toàn với Redlock

2 phút đọc

Bạn có biết?

Hai request đồng thời cùng xử lý một đơn hàng, cả hai đều trừ tiền thành công? Hay một cron job chạy lại trên 2 server, gửi email trùng lặp cho khách hàng? Trong hệ thống phân tán, race condition là kẻ thù số một — và Distributed Lock là vũ khí để chống lại nó.

Với một server duy nhất, bạn chỉ cần synchronized hay mutex. Nhưng khi có nhiều server, nhiều instance cùng xử lý chung dữ liệu, lock trong bộ nhớ của từng máy không còn ý nghĩa — bạn cần một lock dùng chung, nằm ngoài mọi instance. Và Redis chính là nơi lý tưởng để đặt nó.

Vấn đề: race condition trong hệ thống phân tán

Hãy nhìn một ví dụ kinh điển — xử lý đơn hàng:

// Giả sử chạy trên 3 server (load balancer phân phối request)
async function processOrder(orderId) {
  const order = await db.get(orderId);
  if (order.status === 'PAID') return;      // chống trùng?

  await payment.charge(order.userId, order.amount);
  await db.update(orderId, { status: 'PAID' });
}

Hai request cùng lúc cho cùng một đơn hàng đi vào 2 server khác nhau: cả hai đều đọc status = PENDING (vì chưa server nào kịp update), cả hai đều charge tiền. if kiểm tra trong code không cứu được bạn vì nó không atomic.

Giải pháp đúng: trước khi xử lý, mọi server phải giành được lock từ một nơi trung lập — Redis. Server nào giành được lock mới được xử lý, server khác bỏ qua.

SET NX EX: lock cơ bản

Redis cung cấp lệnh SET với 2 option quan trọng:

  • NX (Not eXists) — chỉ set nếu key chưa tồn tại
  • EX — đặt TTL (expiration) cho key
# Giành lock: chỉ thành công nếu key chưa tồn tại, tự hết hạn sau 10s
SET lock:order:1001 "server-A" NX EX 10
# (OK)       → giành được lock
# (nil)      → lock đã có người giữ, bỏ qua

# Giải phóng lock khi xong việc
DEL lock:order:1001

Lưu ý quan trọng: SETNX + EXPIRE tách rời là sai lầm kinh điển — nếu server chết giữa 2 lệnh, lock không bao giờ hết hạn và hệ thống kẹt vĩnh viễn. Luôn dùng SET ... NX EX trong một lệnh duy nhất, vì nó atomic.

Giải phóng lock an toàn: chỉ xóa khi còn là của mình

Nếu chỉ DEL đơn thuần, một server có thể xóa nhầm lock của server khác (khi lock cũ hết hạn và server B giành được, rồi server A chậm chạp quay lại DEL). Phải kiểm tra giá trị trước khi xóa — và việc đó cần atomic, nên dùng Lua Script:

-- Lua: chỉ xóa key nếu value khớp (đúng owner)
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

Mỗi client gửi kèm một token ngẫu nhiên (UUID) làm giá trị của key. Khi giải phóng, Lua so sánh token — khớp mới xóa. Như vậy server A không bao giờ xóa được lock của server B.

Redlock: lock phân tán cho nhiều Redis node

SET NX EX trên một Redis node có một điểm yếu: nếu node đó chết, toàn bộ lock mất. Để chịu lỗi tốt hơn, Antirez (tác giả Redis) đề xuất Redlock — thuật toán khóa phân tán trên N node độc lập (thường 5 node, tối thiểu 3):

  1. Client lấy timestamp hiện tại (milliseconds)
  2. Lần lượt SET NX EX lên tất cả N node, mỗi node với TTL ngắn (vd 10s) và cùng token
  3. Tính tổng thời gian giành lock. Lock được coi là thành công nếu giành được trên đa số node (N/2 + 1) và tổng thời gian nhỏ hơn TTL
  4. Thời gian giữ lock thực tế = TTL − thời gian giành lock
  5. Thất bại → release trên tất cả node đã lock thành công

Vì lock tồn tại trên đa số node, việc một vài node chết không làm mất lock — các node còn lại vẫn đủ đa số để hệ thống hoạt động.

Cài đặt với Node.js (ioredis + redlock)

npm install ioredis redlock
import Redis from "ioredis";
import Redlock from "redlock";

// 3 node Redis độc lập
const nodes = [new Redis({ host: "redis-1" }), new Redis({ host: "redis-2" }), new Redis({ host: "redis-3" })];
const redlock = new Redlock(nodes, {
  driftFactor: 0.01,        // sai số đồng hồ
  retryCount: 10,
  retryDelay: 200,
  retryJitter: 100,
});

async function processOrder(orderId) {
  const lock = await redlock.acquire(`lock:order:${orderId}`, 10_000);

  try {
    // Chỉ server giữ lock mới vào đây
    const order = await db.get(orderId);
    if (order.status === "PENDING") {
      await payment.charge(order.userId, order.amount);
      await db.update(orderId, { status: "PAID" });
    }
  } finally {
    await lock.release();   // luôn giải phóng
  }
}

Những cạm bẫy phải biết

  • Lock hết hạn giữa chừng — nếu nghiệp vụ chạy lâu hơn TTL, lock tự nhả và server khác giành được. Cân nhắc gia hạn lock (watchdog) hoặc tăng TTL an toàn
  • Clock drift — Redlock giả định đồng hồ các máy không lệch nhiều. NTP lệch giờ có thể phá vỡ tính an toàn
  • Không dùng lock để bảo vệ ghi dữ liệu dài — lock chỉ nên giữ trong thời gian ngắn (milliseconds → vài giây)
  • Fencing token — với hệ thống cần độ an toàn tuyệt đối (thanh toán), dùng version/token tăng dần để storage từ chối ghi cũ (xem bài TransactionsLua Scripts)
  • Không lock trên nhiều key rải rác — mỗi nghiệp vụ nên lock trên ĐÚNG MỘT key, đặt tên chuẩn lock:<resource>

Khi nào KHÔNG cần distributed lock

Distributed lock không phải viên đạn bạc. Trước khi dùng, hãy xem có giải pháp đơn giản hơn:

  • Lệnh atomic của RedisINCR, SADD, SETNX đã atomic sẵn, không cần lock bên ngoài
  • Unique constraint của database — chống trùng lặp bằng primary key / unique index
  • Optimistic locking — dùng version column, update chỉ thành công nếu version khớp
  • Message queue đảm bảo exactly-once — idempotency key lưu trong DB (xem bài Message Queue)

Tổng kết

Distributed Lock với Redis là kỹ thuật nền tảng của mọi hệ thống phân tán: SET NX EX cho lock đơn giản, Lua Script để giải phóng an toàn, Redlock cho độ tin cậy cao trên nhiều node. Nắm được nó, bạn xử lý được cả lớp bài toán race condition mà testing khó lòng phát hiện.

Bước tiếp theo: tìm hiểu Redis Cluster — sharding và scale ngang để biết cách Redis mở rộng khi dữ liệu vượt quá một node, hoặc xem Replication & High Availability để hiểu cách hệ thống chịu lỗi.

← Bài trướcRedis Security, Backup va Production Deployment