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.

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:
- A giành lock (TTL 10s), nhưng A xử lý chậm quá 10 giây → lock A tự hết hạn.
- B thấy lock trống, B giành lock (token của B).
- A tỉnh dậy, chạy
DEL lock:res→ xoá nhầm lock của B! - 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

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ậnOK. Client B thửSET ... tokenB NXkhi 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ề
- 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.
- 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.
- 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
- Redis — Distributed Locks with Redis (Redlock): https://redis.io/docs/latest/develop/use/patterns/distributed-locks/
- Martin Kleppmann — How to do distributed locking: https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- Redis — SET command (NX, PX options): https://redis.io/commands/set/
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.