Bỏ qua để đến nội dung
Knowledge Hub

Distributed lock với Redis: lock hết hạn khi chủ còn chạy thì sao? Nâng cao

Thời gian đọc ước tính: 8 phút

Job đồng bộ tồn kho với nhà cung cấp chạy trên 3 pod, đọc số liệu nhà cung cấp rồi ghi qty vào bảng inventory. Chỉ một pod được chạy mỗi lúc; hai pod cùng ghi thì số cũ có thể đè số mới.

Cách hay gặp là một key lock Redis có TTL. Nhưng job đôi khi bị GC pause hoặc treo lâu hơn TTL.

Lock Redis bảo đảm được gì khi chủ lock bị dừng quá hạn, và cần thêm gì để dữ liệu vẫn đúng?

Tình huống Chọn Điều kiện
Tránh làm trùng việc, hiếm khi trùng vẫn chấp nhận SET key token NX PX trên một Redis, nhả bằng Lua Job idempotent
Cần đúng tuyệt đối Fencing token kiểm tra ở tài nguyên đích, hệ đồng thuận (etcd, ZooKeeper), hoặc ràng buộc database Đích từ chối token cũ
Đích là PostgreSQL Advisory lock hoặc row lock Kèm ghi có điều kiện

Kleppmann gọi hai nhu cầu đầu là lock cho hiệu quả và cho tính đúng; nhầm hai loại là lỗi chính.

Lock Redis thực chất là một lease: tự hết khi TTL về 0 dù chủ còn chạy. Advisory lock thì ngược lại: không có hạn, mất khi connection đứt.

Redis SET NX PX PostgreSQL advisory lock
Mất lock khi TTL hết, failover Connection đứt
Chủ biết mình mất lock? Không Khi query kế tiếp báo lỗi connection
Fencing token Không Không

Lấy lock: vì sao không dùng SETNX rồi EXPIRE

Phần tiêu đề “Lấy lock: vì sao không dùng SETNX rồi EXPIRE”

Value là token ngẫu nhiên mới cho mỗi lần lấy. SETNX rồi EXPIRE là hai lệnh: chết ở giữa thì key không có hạn (TTL trả -1), lock kẹt mãi. SETNX deprecated từ 2.6.12; dùng một lệnh SET key token NX PX 30000 (OK, hoặc nil nếu key đã có).

DEL thẳng xoá bất kỳ lock nào ở key, kể cả lock pod khác vừa lấy sau khi lock của mình hết hạn. Script chạy nguyên tử nên không có khe giữa GET và DEL (xem Lua script):

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

Redis 8.4 thêm DELEX key IFEQ value làm đúng việc này bằng một lệnh; Redis 7.x vẫn dùng script.

Không cần Redis lỗi: chỉ cần Pod A dừng lâu hơn TTL. Hình đi theo lần chạy demo:

Pod A pause quá TTL, Pod B lấy lock với token 34; PostgreSQL chặn lần ghi muộn mang token 33
Pod A pause quá TTL, Pod B lấy lock với token 34; PostgreSQL chặn lần ghi muộn mang token 33Bước 1/6: Pod A lấy lock TTL 300 ms và INCR được fencing token 33.thời gianlock của A …token 33SET NX PX, INCR→ OK, token 33lock = token Afence = 33Pod Ajob đồng bộRedislock + INCRPod Bjob đồng bộPostgreSQLinventory
Bước 1/6

Pod A lấy lock TTL 300 ms và INCR được fencing token 33.

Bước Redis giữ lock của Pod tin mình là chủ inventory
1–3 A, rồi không ai A qty = 100, fence_token = 0
4 B A và B không đổi
5 B A và B qty = 95, fence_token = 34
6 B A và B A bị từ chối

Bỏ điều kiện fence_token < $token thì A ghi qty = 120 đè số mới của B. Lock không ngăn được; chỉ tài nguyên đích ngăn được.

Watchdog (có trong vài thư viện như Redisson) là timer gia hạn TTL định kỳ, chạy trong cùng process với job: GC pause làm timer đứng theo. Demo: watchdog trả 0 khi A tỉnh, đã muộn.

Kiểm tra lock ngay trước khi ghi cũng không đủ: pause có thể xảy ra ngay sau đó.

Replication của Redis là bất đồng bộ; Sentinel hay Cluster đều failover sang replica:

Đang tải diagram…

Redlock (redis.io) dùng N master không replication, ví dụ N = 5 trên 5 máy:

  1. Ghi thời điểm bắt đầu, gửi SET key token NX PX ttl tới cả 5 node song song, timeout mỗi node nhỏ so với TTL.
  2. Thành công khi lấy được quá bán (≥ 3) và thời gian đã trôi nhỏ hơn TTL.
  3. Thời gian hiệu lực = TTL − thời gian đã trôi − bù clock drift.
  4. Thất bại thì nhả trên mọi node, thử lại sau khoảng ngẫu nhiên.

Node restart mất key (xem RDB và AOF) có thể cho hai client cùng quá bán; tài liệu đề xuất trì hoãn restart quá TTL lớn nhất.

Tranh luận năm 2016 quanh Redlock, redis.io dẫn cả hai phía:

Điểm Martin Kleppmann antirez (tác giả Redis)
Fencing Redlock không sinh được số tăng dần Đích kiểm tra được token thì đã tự loại trừ được; có thể check-and-set bằng token ngẫu nhiên
Thời gian Dựa vào giả định về trễ mạng, pause, đồng hồ Chỉ cần đếm thời gian trôi với sai số giới hạn
Pause sau khi lấy lock Gây hai chủ Ảnh hưởng mọi lock tự hết hạn, không riêng Redlock
Khuyến nghị Hiệu quả: một Redis. Đúng: hệ đồng thuận + fencing Redlock dùng được khi cần loại trừ

redis.io hiện khuyên triển khai fencing token và ghi rõ TTL không dùng monotonic clock: chỉnh giờ hệ thống có thể cấp lock cho nhiều process.

Fencing token cần nguồn tăng dần bền. INCR trên Redis có replica có thể lặp token sau failover. Nguồn chắc hơn: database đích (version, sequence) hoặc hệ đồng thuận (revision của etcd, zxid của ZooKeeper).

Demo examples/src/redis-distributed-lock/demo.ts (Redis 7.4.9, PostgreSQL 17): Pod A chạy trong worker thread; GC pause giả lập bằng Atomics.wait 900 ms, chặn cả event loop của thread. TTL 300 ms, watchdog mỗi 100 ms.

Output thật, rút gọn (mốc ms đổi theo lần chạy)
2. A nhả lock bằng DEL thẳng sau khi lock đã hết hạn
A: SET NX PX 300 -> OK
B: SET NX PX 5000 -> OK
A: DEL -> 1 (xoá lock của B)
C: SET NX PX 5000 -> OK => B và C cùng tin mình giữ lock
3. Như bước 2, nhả bằng Lua
A: EVAL release(tokenA) -> 0 (token không khớp, không xoá)
4. Watchdog + GC pause: lock hết hạn khi A còn chạy, không fencing
[ 47 ms] A: SET NX PX 300 -> OK, fencing token = 33
[ 249 ms] A: watchdog PEXPIRE 300 -> 1 (còn gia hạn được)
[ 298 ms] A: bắt đầu GC pause 900 ms
[ 555 ms] B: SET NX PX 5000 -> OK, fencing token = 34
[ 560 ms] B: ghi qty = 95 với token 34 -> UPDATE 1 row
[1198 ms] A: tỉnh dậy, ghi qty = 120 với token 33
[1200 ms] A: watchdog PEXPIRE 300 -> 0
[1200 ms] A: UPDATE -> 1 row
5. Như bước 4, có fencing: UPDATE ... WHERE fence_token < $token
[1187 ms] A: UPDATE -> 0 row (bị từ chối: token cũ)
Kết quả: { qty: 95, fence_token: '34', updated_by: 'pod-b' }

Bước 4: số cũ 120 đè số mới 95. Bước 5: PostgreSQL giữ 95. Bộ đếm đặt sẵn 32 để token khớp hình.

Ưu điểm

  • Một lệnh, độ trễ thấp.

  • TTL tự giải phóng khi chủ chết.

  • Đủ cho lock hiệu quả.

  • < slot="cons">
  • Hết hạn khi chủ còn chạy; failover có thể mất lock.

  • Không có fencing token; tài nguyên đích phải tự kiểm tra.

  • Redlock tốn 5 node, vẫn dựa vào giả định thời gian.

Nhược điểm

  1. Ghi rõ lock cho hiệu quả hay tính đúng; loại sau bắt buộc có fencing.
  2. Nhả bằng script so token (Redis 8.4+: DELEX ... IFEQ); cấm DEL thẳng.
  3. TTL lớn hơn p99 thời gian job; job có deadline riêng, quá hạn thì dừng.
  4. Mọi lần ghi mang fencing token từ nguồn bền; đích kiểm tra fence_token < $token.
  5. Job vẫn idempotent: fencing không chặn side effect ngoài như gọi API hai lần.
  6. Cảnh báo khi script nhả trả 0: lock đã hết hạn giữa chừng.

Lỗi phổ biến nhất nằm ở bước nhả lock trong finally:

Nhả lock: DEL thẳng xoá cả lock của pod khác

Hay gặp

await redis.set(key, '1', 'PX', 30000, 'NX');
try { await syncInventory(); } finally { await redis.del(key); }

Nên viết

const token = randomBytes(16).toString('hex');
if ((await redis.set(key, token, 'PX', 30000, 'NX')) !== 'OK') return;
try { await syncInventory(); } finally { await redis.eval(RELEASE, 1, key, token); }

Vì sao

Job quá TTL thì DEL xoá lock của pod khác (demo bước 2).

Sai lầm thứ hai là tin gia hạn là đủ:

Cách hiểu sai Hệ quả Cần làm
Kiểm tra lock trước khi ghi là đủ Pause ngay sau kiểm tra Fencing ở tài nguyên đích
INCR trong Redis là token bền Failover làm token lặp Lấy token từ database hoặc hệ đồng thuận
  1. Ở demo bước 2, vì sao DEL của A trả 1 dù lock của A đã hết hạn?
  2. Watchdog gia hạn mỗi 100 ms, TTL 300 ms. Vì sao B vẫn lấy được lock khi A pause 900 ms?
  3. Kể một chuỗi sự kiện cho hai client cùng giữ quá bán 3/5 node Redlock.