Redis làm khóa phân tán — đừng tưởng SETNX xong là xong

Phong Hy

Hai server chạy chung một job giảm giá, cùng lúc cả hai đọc được cái đơn order_123 còn "đang chờ", rồi cả hai đều trừ kho hai lần. Gặp rồi hả? Lúc đó bạn cần một khóa phân tán — cho một tiến trình vào, tụi còn lại đứng ngoài cửa.

Bài này em nói về cái khóa đơn giản nhất ai cũng nghĩ tới: đặt trong Redis bằng SETNX. Nghe tưởng một dòng là xong, nhưng thực chiến thì đầy bẫy. Đọc xong biết tại sao nên cẩn thận.

Vì sao SETNX một mình không đủ

SETNX lock:order_123 1

SETNX (set if not exists) chỉ trả về thành công khi key chưa tồn tại — nghe đúng ý "ai vô trước thì giữ". Nhưng nó thiếu ba thứ sinh tử:

  1. Không có TTL — process giữ khóa rồi crash giữa chừng, key sống đời đời, không ai vô được nữa. Đây là bug kinh điển nhất.
  2. Lệnh không atomic cho việc "đặt và đặt hạn" — đặt SETNX rồi EXPIRE riêng hai lệnh là bước hở: crash giữa hai lệnh là mất TTL.
  3. Unlock không phân biệt chủ — thằng nào cũng dám DEL cái key, dễ xoá nhầm lock của người ta.

Cách làm đúng là gom hết vô một lệnh atomic + so sánh value trước khi xoá.

SET NX PX — một lệnh, đủ giấy tờ

Redis từ 2.6.12 gộp mọi thứ vô một lệnh SET:

SET lock:order_123 tok-abc NX PX 30000
  • NX — chỉ đặt nếu chưa có (đúng nghĩa lock).
  • PX 30000 — tự hết hạn sau 30 giây, chống crash.
  • tok-abcvalue riêng đóng vai trò "chữ ký" để biết khóa này của ai.

Ví dụ bằng go-redis trong Go:

ok, err := rdb.SetNX(ctx, key, token, 30*time.Second).Result()
if err != nil {
    return err
}
if !ok {
    return ErrLockHeld // ai đó đang giữ, đứng ngoài chờ
}
// ... chạy critical section ...

Unlock phải so value — gom vô Lua cho atomic

Đừng ai DEL thẳng. Process A giữ khóa, bị chậm quá nên key hết hạn, process B vào chiếm. Lúc này A xong việc mà chạy DEL thì sẽ xoá nhầm khóa của B! Phải so lại chữ ký trước:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

KEYS[1] là tên key, ARGV[1] là token của mình. Chỉ xoá khi đúng chủ. Chạy trong Lua nên kiểm tra + xoá là một thao tác atomic, chẳng có cửa để lọt giữa.

Bài học thực chiến

  • TTL phải dài hơn critical section. Đặt 30s mà việc mất 1 phút là lock tự nhả giữa chừng, hai process chạy song song lại về y như không có khóa. Cách an toàn là cộng thêm một khoảng an toàn, hoặc tính trước thời gian tệ nhất của tác vụ.
  • Khóa đây là "mutex có giờ giấc", không phải an toàn tuyệt đối. Nếu critical section có thể lâu hơn TTL thì pattern này không đủ; bạn cần lease renewal (tự động gia hạn khi còn chạy) hoặc chuyển qua thuật toán mạnh hơn. Đừng cố tăng TTL lên vô hạn để né — crash thì khóa bị khoá nghẹt.
  • Không hỏi tới Redlock trước khi hiểu yêu cầu. Redlock là thuật toán khóa qua nhiều node Redis, nhưng từng bị giới chuyên môn chỉ ra vẫn có lỗ hổng lý thuyết. Với phần lớn service thật sự, một Redis + SET NX PX đã đủ. Chỉ cần bạn hiểu nó không đảm bảo tuyệt đối và sắp xếp tác vụ cho chấp nhận chạy hai lần là không sao (idempotency là lớp phòng hờ bên dưới).
  • Retry khi thua bằng backoff. Khóa bận thì đừng vô ráng chen ngay, đợi một chút bằng exponential backoff + jitter, đỡ tốn vòng lặp CPU rượt đuổi Redis.

Tóm lại

Khóa phân tán bằng Redis không khó, nhưng không phải là "SETNX một dòng xong việc". Muốn tránh bug quái thai thì nhớ ba chuyện: đặt một lệnh SET NX PX có TTL, unlock phải so token bằng Lua, và đừng bao giờ tin TTL đủ dài che hết mọi thứ — critical section lâu thì nghĩ tới lease renewal. Và nhớ: khóa chỉ giúp giảm xác suất chạy hai lần, lớp cuối cùng bảo vệ dữ liệu của bạn vẫn phải là idempotency.