Có những việc chỉ một tiến trình được làm tại một thời điểm, dù hệ thống chạy trên hàng chục máy: gửi email nhắc nợ (không gửi hai lần), chạy một job định kỳ (không trùng), cập nhật một tài nguyên dùng chung. Trên một máy, bạn dùng mutex. Trên nhiều máy, cần distributed lock — và Redis là công cụ phổ biến nhất cho việc này. Nghe đơn giản: set một key làm cờ "đang khoá". Nhưng đây là một trong những thứ dễ viết sai nhất trong lập trình phân tán — và lỗi thường nằm ở chỗ nhả lock, không phải giành lock. Bài này (phần 8/12) đo thật cách làm đúng và chỉ ra cạm bẫy chết người.

Giành lock: SET NX PX

Giành lock là một lệnh nguyên tử:

SET lock:res tokenA NX PX 10000
  • NX: chỉ set nếu key chưa tồn tại → chỉ một client giành được (loại trừ lẫn nhau).
  • PX 10000: TTL 10 giây → lock tự hết hạn nếu client giữ lock chết giữa chừng (không kẹt vĩnh viễn).
  • tokenA: một giá trị duy nhất của client này (UUID ngẫu nhiên) — cực kỳ quan trọng cho việc nhả lock an toàn.

Ảnh chụp đoạn mã nền tối minh hoạ distributed lock với Redis SET NX PX và vì sao nhả lock sai một chút là hỏng, lock để chỉ một tiến trình làm một việc tại một thời điểm trên nhiều máy dễ viết sai nguy hiểm. Giành lock SET NX PX nguyên tử SET lock:res tokenA NX PX 10000 NX chỉ set nếu key chưa tồn tại chỉ 1 client giành được PX 10000 TTL 10s lock tự hết hạn nếu client giữ lock chết không kẹt vĩnh viễn tokenA giá trị duy nhất của client này để nhả an toàn. Nhả lock sai DEL mù quáng xoá nhầm lock người khác kịch bản hỏng A giành lock TTL 10s A xử lý chậm quá 10s lock A tự hết hạn B giành lock giờ trống A tỉnh dậy DEL lock:res xoá nhầm lock của B B mất lock giữa chừng hai client cùng làm hỏng dữ liệu. Nhả lock đúng Lua kiểm token rồi mới DEL EVAL if redis.call GET KEYS 1 bằng ARGV 1 then return redis.call DEL KEYS 1 else return 0 end 1 lock:res tokenA chỉ xoá nếu token khớp đúng chủ lock GET cộng DEL nguyên tử trong Lua production dùng Redlock nhiều node cộng fencing token

Hình 1: Giành lock bằng SET NX PX (NX = chỉ một client thắng, PX = TTL tự hết hạn, token = định danh chủ lock). Nhả lock sai bằng DEL mù quáng có thể xoá nhầm lock người khác; nhả đúng dùng Lua kiểm token rồi mới DEL.

Cạm bẫy: nhả lock bằng DEL mù quáng

Đây là lỗi kinh điển. Nếu client A nhả lock bằng cách chỉ DEL lock:res, một kịch bản race xảy ra:

  1. A giành lock (TTL 10s), nhưng A xử lý chậm quá 10 giây → lock A tự hết hạn.
  2. B thấy lock trống, B giành lock (token của B).
  3. A tỉnh dậy, chạy DEL lock:res → xoá nhầm lock của B!
  4. Giờ B tưởng mình đang giữ lock, nhưng lock đã bị xoá → một client khác có thể giành → hai client cùng làm → hỏng dữ liệu.

Giải pháp: nhả lock phải kiểm token có khớp không (đúng chủ lock), và việc kiểm-rồi-xoá phải nguyên tử — dùng Lua (bài redis-04):

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

Đo thật: giành và nhả an toàn

Ảnh chụp bảng kết quả đo thật giành lock và nhả lock an toàn output thật redis:7 SET NX PX EVAL Lua kiểm token. Một giành lock chỉ một client thắng Client A SET lock tokenA NX PX 10000 OK giành được Client B SET lock tokenB NX PX 10000 khi A giữ nil thất bại GET lock tokenA NX đảm bảo chỉ một client set được key khi nó chưa tồn tại đây là cốt lõi của loại trừ lẫn nhau mutual exclusion. Hai nhả lock bằng Lua chỉ chủ lock mới xoá được B nhả với token sai tokenB trả 0 không xoá lock sau đó vẫn là tokenA còn nguyên A nhả với token đúng tokenA trả 1 đã xoá sau khi A nhả B giành lại OK nếu A chỉ DEL mù quáng A có thể xoá nhầm lock của B khi lock A đã hết hạn và B đã giành Lua kiểm token có khớp không rồi mới DEL nguyên tử token sai trả 0 không phá lock người khác đây là điểm mà rất nhiều lock tự viết làm sai

Hình 2: Đo thật. (1) Giành lock: A → OK, B → nil (thất bại khi A giữ), GET lock = tokenA. (2) Nhả bằng Lua: B nhả token sai → trả 0 (không xoá, lock còn nguyên tokenA); A nhả token đúng → trả 1 (đã xoá); sau đó B giành lại OK.

Kết quả thật:

  • ① Giành lock: SET lock:res tokenA NX PX 10000 → client A nhận OK. Client B thử SET ... tokenB NX khi A đang giữ → (nil) (thất bại). GET lock:res = tokenA. NX đảm bảo chỉ một client giành được — đây là loại trừ lẫn nhau.
  • ② Nhả lock bằng Lua: B cố nhả với token sai (tokenB) → Lua trả 0 (không xoá), lock vẫn là tokenA còn nguyên. A nhả với token đúng (tokenA) → Lua trả 1 (đã xoá). Sau khi A nhả, B giành lại → OK.

Điểm mấu chốt: token sai trả 0 → không phá lock của người khác. Nếu dùng DEL mù quáng, bước "B nhả" (hay "A nhả nhầm") sẽ xoá lock không phải của mình. Lua kiểm token + DEL nguyên tử đóng lỗ hổng đó. Đây chính là điểm mà rất nhiều lock tự viết làm sai.

Đánh đổi cần cân nhắc

TTL là con dao hai lưỡi — không có giá trị hoàn hảo. TTL quá ngắn: lock hết hạn trước khi client làm xong việc → client khác giành → hai client cùng làm (chính kịch bản hỏng ở trên, dù có token thì A cũng mất lock oan). TTL quá dài: nếu client giữ lock chết, mọi người phải chờ rất lâu mới lock được giải phóng. Không có giá trị đúng tuyệt đối — một kỹ thuật nâng cao là gia hạn lock (lock renewal / watchdog): client còn sống thì định kỳ kéo dài TTL. Nhưng điều này thêm phức tạp.

Lock Redis một node KHÔNG an toàn tuyệt đối — có Redlock và tranh cãi quanh nó. Lock trên một Redis có điểm chết đơn (single point of failure) và vẫn có khe hở lý thuyết (GC pause dài, clock drift). Redis đề xuất Redlock — giành lock trên nhiều node độc lập (quá bán) để tăng an toàn. Nhưng Redlock gây tranh luận lớn trong cộng đồng (Martin Kleppmann phản biện nổi tiếng): với dữ liệu tuyệt đối không được sai, cách an toàn nhất là fencing token — mỗi lần giành lock cấp một số tăng dần, và tài nguyên được bảo vệ từ chối thao tác mang token cũ hơn. Biết giới hạn: lock Redis tốt cho "tối ưu, hiếm khi trùng"; cho "tuyệt đối không được trùng" cần thêm fencing hoặc một hệ đồng thuận (ZooKeeper/etcd).

Lock là công cụ cuối cùng, không phải đầu tiên. Khoá làm giảm song song (chỉ một tiến trình làm việc) — nên trước khi dùng lock, hỏi: có thể thiết kế idempotent để chạy trùng cũng không sao không? Có thể dùng một thao tác nguyên tử sẵn có (INCR, SET NX cho chính dữ liệu, Lua — bài 4) thay vì lock bao quanh không? Lock đúng là khó; tránh được lock thường là thiết kế tốt hơn.

Ba ý mang về

  1. Giành lock bằng SET NX PX — một lệnh nguyên tử. Đo thật: A giành được (OK), B thất bại (nil) khi A giữ. NX cho loại trừ lẫn nhau, PX cho TTL tự hết hạn (không kẹt vĩnh viễn), token cho định danh chủ lock.
  2. Nhả lock PHẢI kiểm token bằng Lua, không DEL mù quáng. Đo thật: token sai → Lua trả 0 (không phá lock người khác), token đúng → trả 1. DEL mù quáng có thể xoá nhầm lock đã bị người khác giành (sau khi lock mình hết hạn) — lỗi mà nhiều lock tự viết mắc phải.
  3. Lock Redis có giới hạn — biết khi nào cần hơn. TTL không có giá trị hoàn hảo (ngắn thì mất lock, dài thì chờ lâu — cân nhắc renewal); lock một node không tuyệt đối an toàn (Redlock/fencing token cho dữ liệu quan trọng); và lock là công cụ cuối — ưu tiên thiết kế idempotent hoặc thao tác nguyên tử sẵn có.

Nguồn

Phần sau ta dùng Redis cho một bài toán backend rất hay gặp: rate limiting — giới hạn số request, so sánh fixed window, sliding window và token bucket, đo thật cách mỗi kiểu chặn.