Dù đã nêu nhiều điểm cần cân nhắc ở bài trước, mình vẫn thấy Redlock là thuật toán có giá trị. Nó vừa hữu ích trong thực tế, vừa là điểm bắt đầu để tìm hiểu cách tư duy về hệ thống phân tán.

Theo kinh nghiệm của mình, những hạn chế lý thuyết của Redlock hiếm khi gây thảm họa trong thực tế. Lý do là kỹ sư chọn Redis thường không đặt cược mọi thứ vào nó. Họ xem Redis là công cụ nhanh nhưng vẫn có thể lỗi, dùng làm cache hoặc rate limiter. Với cách nghĩ đó, họ sẽ bổ sung biện pháp bảo vệ như unique constraint ở database, idempotency key và thao tác an toàn khi retry. Những tình huống lỗi Kleppmann mô tả là có thật, nhưng hệ thống vẫn có thể chịu được nếu không giả định mọi thứ luôn hoàn hảo.

Nếu một tình huống hai client cùng giữ lock, dù hiếm gặp, đã có thể gây thiệt hại nghiêm trọng, có lẽ Redis không phải lựa chọn phù hợp ngay từ đầu. Nhưng với phần lớn trường hợp mà “gần như luôn đúng, rất nhanh” có lợi hơn “luôn đúng, chậm thấy rõ”, Redlock vẫn xứng đáng có trong bộ công cụ của bạn.

Trong bài này, chúng ta sẽ tìm hiểu cách cài đặt Redlock bằng Go và Redis.

Tại sao cần Redlock?⌗

Ở bài về một Redis instance, chúng ta đã tìm hiểu cách cài đặt distributed lock và xử lý tình huống client crash. Nhưng còn một câu hỏi quan trọng: nếu chính Redis gặp sự cố thì sao?

Antirez giới thiệu Redlock để giải quyết vấn đề đó. Thuật toán dùng nhiều Redis instance độc lập thay vì dựa vào một instance, nhằm tăng tính sẵn sàng thông qua đồng thuận dựa trên quorum. Redlock cũng không phụ thuộc vào loại lưu trữ. Bạn có thể cài đặt nó bằng MySQL, PostgreSQL, MongoDB hoặc bất kỳ hệ lưu trữ dữ liệu nào hỗ trợ ghi có điều kiện theo cách atomic.

Thuật toán coi server (các Redis instance) là bên “không cần thông minh”, còn client lấy và giải phóng lock là bên “thông minh”. Toàn bộ logic phối hợp nằm ở client.

Thuật toán⌗

Redlock có ba giai đoạn: acquire, validate và release. Các bước cụ thể như sau:

Giai đoạn acquire⌗

  1. Tạo ownership token: Client tạo một giá trị ngẫu nhiên duy nhất, chẳng hạn UUID, để nhận diện lần lấy lock này. Client dùng cùng token trên mọi instance để lệnh giải phóng chỉ tác động đến lock của lần lấy đó. Đây không phải fencing token tăng đơn điệu.

  2. Thử lấy lock trên tất cả instance: Client gửi SET key token NX PX ttl đến cả N Redis instance, tuần tự hoặc song song. Trong đó:

    • NX chỉ cho phép ghi key khi key chưa tồn tại.
    • PX ttl đặt thời gian hết hạn theo mili giây.
  3. Đo thời gian đã trôi qua: Client ghi lại thời điểm bắt đầu trước khi thử lấy lock. Việc lấy lock càng lâu thì thời gian hiệu lực còn lại càng ngắn, nên bước này rất quan trọng.

Giai đoạn validate⌗

  1. Kiểm tra quorum: Chỉ được coi là lấy lock thành công khi đáp ứng cả hai điều kiện:

    • Client lấy được lock trên ít nhất N/2 + 1 instance, tức hơn một nửa.
    • Tổng thời gian đã trôi qua nhỏ hơn TTL của lock.
  2. Tính thời gian hiệu lực còn lại: Thời gian này là TTL - elapsed_time. Nếu không còn đủ thời gian cho thao tác cần bảo vệ, client nên giải phóng lock ngay thay vì tiếp tục.

Giai đoạn release⌗

  1. Giải phóng lock trên tất cả instance: Client gửi lệnh giải phóng đến toàn bộ instance, kể cả những instance chưa lấy được lock. Lua script đảm bảo việc kiểm tra và xóa key là atomic:

    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    end
  2. Xử lý lỗi một phần: Nếu một số lệnh giải phóng thất bại do lỗi mạng hoặc node crash, các lock đó sẽ tự hết hạn theo TTL. Token ngăn client khác vô tình giải phóng chúng, nên việc này vẫn an toàn.

Lưu ý: Client BẮT BUỘC gửi lệnh giải phóng đến toàn bộ instance, kể cả nơi lấy lock thất bại, chứ không chỉ nơi lấy thành công.

Client set lock song song trên năm node Redis độc lập, đạt quorum 3 trong 5 dù hai node timeout, thực hiện công việc được bảo vệ, rồi release trên cả năm node

Vấn đề clock drift⌗

Ở giai đoạn acquire, chúng ta đo thời gian đã trôi qua. Nhưng có một vấn đề khó nhận ra: đồng hồ có thể đo sai.

Vấn đề⌗

Trong hệ thống phân tán, mỗi máy có đồng hồ riêng và các đồng hồ có thể lệch dần theo thời gian. Ngay cả khi đồng bộ bằng NTP, chúng vẫn có thể chênh nhau hàng chục hoặc hàng trăm mili giây. Điều này ảnh hưởng đến Redlock ở những điểm sau:

  1. Đồng hồ client và Redis: Khi client đo được 30 giây đã trôi qua, Redis có thể mới đo được 29,5 giây, hoặc đã đo được 30,5 giây.

  2. Đồng hồ giữa các Redis instance khác nhau: Mỗi instance dùng đồng hồ riêng để tính thời điểm TTL hết hạn. Nếu lấy lock trên 5 instance với TTL 30 giây, các lock sẽ không hết hạn đúng cùng một thời điểm.

  3. Tình huống nguy hiểm: Client A lấy lock với TTL 10 giây nhưng đồng hồ chạy chậm. A tưởng mới qua 8 giây và vẫn an toàn, trong khi thời gian thực đã là 10,5 giây. Lock trên Redis đã hết hạn nhưng A vẫn nghĩ mình đang giữ lock.

Sơ đồ dưới đây minh họa một tình huống cụ thể hơn. Client lấy lock trên Redis A rất nhanh, nhưng kết nối đến Redis B có độ trễ lớn. Khi B phản hồi, lock trên A đã hết hạn:

Client đạt quorum 2 trong 3, nhưng đã trôi qua 12 giây so với TTL 10 giây, nên lock mà nó tưởng mình đang giữ thì đã hết hạn trên Redis A

Giải pháp: drift factor⌗

Redlock xử lý bằng clock drift factor. Thay vì dùng toàn bộ TTL, thuật toán trừ thêm một khoảng dự phòng an toàn:

validity_time = TTL - elapsed_time - (TTL * CLOCK_DRIFT_FACTOR) - CLOCK_DRIFT_BUFFER

Trong đó:

  • CLOCK_DRIFT_FACTOR thường là 0.01, tức 1% TTL.
  • CLOCK_DRIFT_BUFFER là một hằng số nhỏ, chẳng hạn 2ms, để dự phòng dao động thời gian khứ hồi qua mạng.

Với lock có TTL 30 giây và mất 500ms để lấy lock:

validity = 30000 - 500 - (30000 * 0.01) - 2
         = 30000 - 500 - 300 - 2
         = 29198ms

Cách ước tính thận trọng này nhằm giúp client xử lý xong trong thời gian lock còn hiệu lực, với giả định sai lệch đồng hồ không vượt quá 1%.

Lưu ý: Clock drift là một trong những điểm Kleppmann phê bình chính ở Redlock. Drift factor chỉ là một heuristic cần điều chỉnh qua thực nghiệm, không phải đảm bảo tuyệt đối.

Cài đặt⌗

Tương tự bản dùng một instance, mình đã cài đặt Redlock bằng Go và chia sẻ code tại github.com/trviph/redlock. Thư viện cung cấp kiểu DistributedLock, cài đặt đầy đủ thuật toán với đồng thuận dựa trên quorum và kiểm tra clock drift.

Cách sử dụng như sau:

// 1. Tạo một client cho mỗi Redis instance
client1 := redis.NewClient(&redis.Options{Addr: "redis1:6379"})
client2 := redis.NewClient(&redis.Options{Addr: "redis2:6379"})
client3 := redis.NewClient(&redis.Options{Addr: "redis3:6379"})

// 2. Bọc chúng lại trong các Lock instance
locks := []*redlock.Lock{
    redlock.NewLock(client1),
    redlock.NewLock(client2),
    redlock.NewLock(client3),
}

// 3. Tạo DistributedLock
// Bạn có thể tùy chỉnh drift factor và cách retry qua các option
dl := redlock.NewDistributedLock(locks,
    redlock.WithClockDriftFactor(0.01), // drift factor 1%
)

// 4. Acquire lock
// Lệnh này chỉ thành công nếu N/2 + 1 instance được lock
// VÀ việc kiểm tra hiệu lực đạt yêu cầu (đã tính cả drift và thời gian mạng).
token, err := dl.Acquire(ctx, "my-critical-resource", 10*time.Second)
if err != nil {
    // Xử lý lỗi (ví dụ ErrLockAlreadyHeld, ErrValidityExpired)
    return
}
defer dl.Release(ctx, "my-critical-resource", token)

// Thực hiện công việc quan trọng...

API của DistributedLock được thiết kế để thay thế trực tiếp cho Lock dùng một instance. Nó hỗ trợ:

  • Acquire dựa trên quorum: Tự động retry cho đến khi lấy được lock trên đa số instance.
  • Kiểm tra drift: Tính đến sai lệch đồng hồ ước tính khi kiểm tra thời gian hiệu lực còn lại của lock.
  • Release an toàn: Giải phóng lock trên tất cả instance để không còn lock bị bỏ lại, nếu có thể.
  • Hỗ trợ watchdog: Dùng cùng pattern Watch để tự động gia hạn lock khi công việc đang chạy.

Xem repository để đọc tài liệu và ví dụ đầy đủ hơn.

Tham khảo⌗