Bài 9 kết thúc ở chỗ mọi thứ phụ thuộc vào việc đồng hồ lệch bao nhiêu. Distributed lock là ví dụ trực tiếp nhất: nó là một cơ chế dựa trên giả định về thời gian, và khi giả định đó sai thì hai worker cùng tin mình đang giữ lock — dù Redis hoạt động hoàn toàn đúng và không có lệnh nào lỗi.

Một từ dùng suốt bài cần nói ngay: vùng tới hạn (critical section) là đoạn code mà chỉ được phép có một worker chạy tại một thời điểm — ví dụ đoạn đọc số dư, tính toán, rồi ghi số dư mới. Trong một tiến trình JavaScript đơn luồng bạn gần như không gặp khái niệm này, vì không có gì chạy song song. Nhưng khi có hai tiến trình trên hai máy, nó quay lại — và không còn cách nào bảo vệ nó bằng ngôn ngữ nữa.

Lab của bài này cho hai worker Node thật tranh một lock Redis và đếm số xung đột: số lần có từ hai worker cùng ở trong vùng tới hạn. Kết quả có một con số làm mình phải chạy lại nhiều lần để chắc: một lock được cài đúng chuẩn nhưng có TTL ngắn hơn thời gian xử lý cho 79 xung đột trên 80 lần vào vùng tới hạn — tệ hơn cả việc không dùng lock nào (41/80). Một lock sai còn nguy hiểm hơn không có lock, vì nó cho bạn sự tự tin.

ℹ️ Cấu hình máy đo
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64 (Node v22.23.2), Redis 7-alpine. Hai container worker chạy cùng một file worker/lock-worker.js, mỗi worker giới hạn 1 CPU, mỗi worker hoàn thành 40 vùng tới hạn (tổng 80). Thời gian xử lý mô phỏng 300 ms mỗi lần.

Bộ đếm xung đột nằm trong Redis và được cập nhật bằng script Lua — nghĩa là nó atomic, nên con số đếm được không bị chính cuộc đua mà nó đang đo làm sai.

10.1 Hai nhu cầu hoàn toàn khác nhau

Trước khi bàn về cách cài lock, phải phân biệt hai lý do dùng nó. Phần lớn tài liệu gộp chúng làm một, và đó là nguồn gốc của mọi tai nạn liên quan tới distributed lock.

Lock cho HIỆU QUẢ Lock cho ĐÚNG ĐẮN
Mục đích Tránh làm việc trùng lặp Ngăn hai worker cùng thay đổi cùng một dữ liệu
Hỏng thì sao Tốn CPU, gửi email hai lần, tạo thumbnail hai lần Dữ liệu sai, trừ tiền hai lần, mất hàng trong kho
Lock phân tán có đủ? Đủ — hỏng hiếm khi thì chấp nhận được KHÔNG đủ — cần fencing token, hoặc tốt hơn là đừng dùng lock
Công cụ đúng Redis lock là lựa chọn hợp lý Transaction của database, hoặc idempotency (Bài 11), hoặc phân vùng theo key
⚠️ Cạm bẫy: dùng distributed lock để bảo đảm đúng đắn cho nghiệp vụ tiền bạc
Distributed lock không cho bạn đảm bảo đó, và mục 10.3 sẽ chứng minh bằng số. Lý do sâu xa: một lock chỉ nói "ở thời điểm bạn hỏi, không ai khác giữ nó". Nó không nói gì về thời điểm bạn ghi, vốn xảy ra sau đó — và giữa hai thời điểm ấy tiến trình của bạn có thể bị dừng lâu hơn TTL.

Nếu hai worker cùng chạy sẽ làm mất tiền, thì công cụ đúng là transaction của database (nó có cơ chế cô lập thật, không dựa vào đồng hồ), hoặc idempotency (lặp lại không gây hại nên không cần ngăn), hoặc phân vùng theo key (mỗi key chỉ có một worker, nên không có gì phải tranh). Lock phân tán vẫn hữu ích — chỉ là cho nhóm hiệu quả.

10.2 Ba điều bắt buộc của một Redis lock

Một lock Redis đúng chuẩn cần đúng ba thứ, và thiếu bất kỳ thứ nào cũng sinh ra một lỗi riêng:

worker/lock-worker.js — giành và giải phóng lock
/**
 * The three MANDATORY parts of a correct Redis lock (Lesson 10, section 10.2):
 *   NX      only set if the key does not exist — this is the "acquire" part
 *   PX ttl  a TTL is MANDATORY, otherwise a dead holder is a permanent deadlock
 *   token   a random value, so ONLY the owner can delete it (see release)
 */
async function acquire(key, token, ttlMs) {
  const res = await redis.cmd('SET', key, token, 'NX', 'PX', String(ttlMs));
  return res === 'OK';
}

/**
 * Releasing MUST be atomic: check the token and only then DEL, in one command.
 *
 * Why a plain `DEL` is wrong: your lock may already have EXPIRED and someone else may
 * have acquired it. A plain `DEL` would delete THEIR lock, and from there everything
 * breaks in a chain.
 */
const RELEASE_LUA = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end`;

async function release(key, token) {
  return redis.cmd('EVAL', RELEASE_LUA, '1', key, token);
}
Thiếu thứ gì Lỗi sinh ra Biểu hiện
Không có NX Không có lock nào cả — ai ghi cũng thắng Xung đột ngay từ đầu, dễ phát hiện
Không có TTL Deadlock vĩnh viễn khi holder chết Hệ thống đứng và không tự hồi phục; phải xoá key bằng tay
Không có token Worker xoá lock của worker khác Xung đột ngẫu nhiên, chỉ xảy ra sau khi có một lần hết hạn — rất khó tái tạo
Giải phóng không atomic Cùng lỗi trên, chỉ hẹp hơn: cửa sổ giữa GETDEL Hiếm hơn, nên còn khó tìm hơn

Đo thật: lock có làm được việc của nó không?

Hai worker, mỗi worker hoàn thành 40 vùng tới hạn (tổng 80), thời gian xử lý 300 ms. Bộ đếm XUNG ĐỘT tăng mỗi khi có từ hai worker cùng ở trong vùng tới hạn:

Cấu hình Vào vùng tới hạn Xung đột Tỉ lệ
A · Không dùng lock 80 41 51%
B · Lock đúng, TTL 1000 ms > xử lý 300 ms 80 0 0%
C · Lock đúng nhưng TTL 200 ms < xử lý 300 ms 80 79 99%

Hàng B là điều ta mong đợi: lock hoạt động, 0 xung đột. Hàng C là hàng đáng dừng lại rất lâu. Lock ở hàng C được cài hoàn toàn đúng — có NX, có TTL, có token, giải phóng bằng Lua. Không có GC pause nào, không có clock skew nào. Nó chỉ có một tham số sai: TTL ngắn hơn thời gian xử lý. Và kết quả là 99% — tệ hơn cả không dùng lock.

🔬 Vì sao TTL ngắn lại tệ hơn không có lock
Cơ chế rất đơn giản khi nhìn ra: worker A giành lock, bắt đầu xử lý 300 ms. Đến mốc 200 ms, lock tự hết hạn trong khi A vẫn đang làm. Worker B lập tức giành được và vào vùng tới hạn — bây giờ có hai worker bên trong. Chuyện này lặp lại ở gần như mọi vòng, nên tỉ lệ là 99% chứ không phải một con số ngẫu nhiên.

Vì sao tệ hơn không có lock? Vì không có lock thì 51% — hai worker chạy tự do và đôi khi lệch pha nhau. Có lock TTL ngắn thì lock đồng bộ hoá chúng: B luôn bắt đầu đúng lúc lock của A hết hạn, tức là đúng lúc A còn 100 ms nữa mới xong. Lock đã biến sự trùng lặp ngẫu nhiên thành sự trùng lặp có hệ thống.

Hệ quả thực hành: TTL phải dài hơn thời gian xử lý xấu nhất, không phải trung bình. Và nếu bạn không biết thời gian xấu nhất là bao nhiêu — trường hợp rất phổ biến khi trong vùng tới hạn có lời gọi mạng — thì bạn không đặt được TTL đúng, và đó là dấu hiệu nên chọn cách khác (mục 10.5).
GC pause dài hơn TTL — Redis đúng hoàn toàn, lock vẫn hỏng 0 1000 ms 2000 3000 4000 WORKER A lấy lock GC PAUSE 1200 ms — tiến trình bị dừng GHI A vẫn TIN mình đang giữ lock — nhưng lock đã hết hạn từ mốc 1000 TTL 1000 ms ← lock TỰ hết hạn ở đây WORKER B lấy lock GHI B giành lock hợp lệ — nó không làm gì sai cả CẢ HAI cùng ở trong vùng tới hạn Đo thật: pause 1200 ms > TTL 1000 ms 31 xung đột / 80 lần vào vùng tới hạn = 39% Không lệnh Redis nào lỗi. Không log nào bất thường. Vì sao thêm node Redis KHÔNG cứu được Vấn đề nằm ở giả định về THỜI GIAN của tiến trình, không nằm ở độ tin cậy của Redis. 5 node cũng vậy. Đây là cốt lõi lập luận của Kleppmann về Redlock. GC pause không phải giả tưởng: stop-the-world của JVM, VM bị migrate, container bị throttle CPU, hoặc máy bị swap — tất cả đều dừng tiến trình hàng giây.
Điểm cần nhớ: lock trả lời câu hỏi "lúc này ai giữ?" nhưng bạn cần câu trả lời cho thời điểm ghi — và giữa hai thời điểm đó không ai đảm bảo gì.

10.3 Vì sao Redlock gây tranh cãi

Redlock là thuật toán được đề xuất để làm lock phân tán an toàn hơn: giành lock trên đa số trong 5 node Redis độc lập. Nó bị Martin Kleppmann phản biện, và lập luận cốt lõi rất gọn — vấn đề không nằm ở Redis.

Thí nghiệm ở lab tái tạo đúng lập luận đó. Cùng lock đúng chuẩn, cùng TTL 1000 ms dài hơn xử lý 300 ms:

Cấu hình Vào vùng tới hạn Xung đột Nhận xét
B · TTL 1000, không có pause 80 0 Lock làm đúng việc của nó
D · TTL 1000, GC pause 1200 ms 80 31 (39%) Không lệnh Redis nào lỗi. Redis hoạt động hoàn hảo

Cơ chế: worker A giành lock, rồi bị dừng 1200 ms — GC stop-the-world, VM bị migrate, container bị throttle CPU, hoặc máy bị swap. Trong lúc đó lock hết hạn ở mốc 1000 ms, worker B giành được một cách hoàn toàn hợp lệ. A tỉnh lại ở mốc 1200 ms và vẫn tin mình đang giữ lock — nó không có cách nào biết rằng thời gian đã trôi qua. Nó ghi. Bây giờ có hai worker ghi.

⚡ Thêm node Redis không sửa được vấn đề này
Đây là điểm quan trọng nhất của mục này. Nếu bạn nghĩ "dùng 5 node Redis thì an toàn hơn 1 node" thì hãy đọc lại thí nghiệm D: Redis không hề sai gì cả. Nó nhận SET NX PX, nó hết hạn key đúng lúc, nó trao lock cho B đúng theo giao thức. Lỗi nằm ở giả định rằng tiến trình của bạn không bị dừng lâu hơn TTL — một giả định về phía client, không phải về phía Redis.

Thêm bốn node nữa chỉ làm phần "giành lock" bền hơn với sự cố của chính Redis. Nó không làm tiến trình của bạn ngừng bị GC pause, và cũng không cho tiến trình đó biết rằng thời gian đã trôi qua.

10.4 Fencing token — lời giải đúng cho nhóm đúng đắn

Nếu lock không thể đảm bảo "chỉ một worker ghi", thì phải chuyển việc kiểm tra sang chỗ khác: chính tài nguyên đích. Lock cấp một số tăng đơn điệu — fencing token — và mọi lệnh ghi phải kèm số đó. Tài nguyên nhớ token lớn nhất nó từng thấy và từ chối mọi token nhỏ hơn.

Nhờ vậy worker "zombie" quay lại sau GC pause bị chặn — không phải vì nó biết mình đã mất lock, mà vì tài nguyên biết đã có người mới hơn.

worker/lock-worker.js — fencing token
// Fencing token: a MONOTONICALLY INCREASING number, issued by Redis via INCR (atomic).
// Taken AFTER acquiring the lock, so token order matches lock-acquisition order.
const fence = USE_FENCE ? Number(await redis.cmd('INCR', 'lab:fence:seq')) : 0;

/**
 * Write to the "resource", with fencing-token checking (Lesson 10, section 10.4).
 *
 * This is the crux: the target resource itself rejects any token LOWER than the highest
 * one it has seen. That is how a "zombie" worker returning from a GC pause — still
 * believing it holds the lock — gets blocked AT THE RESOURCE LAYER rather than by the
 * lock. If the resource does not check, the fencing token is just a decorative number.
 */
const FENCED_WRITE_LUA = `
local seen = tonumber(redis.call('GET', KEYS[1]) or '0')
local tok  = tonumber(ARGV[1])
if tok < seen then
  redis.call('INCR', KEYS[3])          -- count the REJECTED writes
  return 0
end
redis.call('SET', KEYS[1], tok)
redis.call('INCR', KEYS[2])            -- count the ACCEPTED writes
return 1`;

// In the worker: only do the work if the resource ACCEPTED the token.
let accepted = true;
if (USE_FENCE) accepted = (await fencedWrite(fence)) === 1;
if (accepted) await sleep(WORK_MS);
Fencing token — tài nguyên nhớ token lớn nhất đã thấy và từ chối token nhỏ hơn worker A worker B lock (cấp token) tài nguyên 1 · A lấy lock → nhận token 33 GC PAUSE 2 · lock của A hết hạn trong lúc A bị dừng 3 · B lấy lock → token 34 4 · B ghi kèm token 34 CHẤP NHẬN đã thấy: 34 5 · A tỉnh lại, vẫn TIN mình giữ lock, ghi kèm token 33 TỪ CHỐI 33 < 34 đã thấy Đo thật: 15 lệnh ghi bị từ chối trên 80 lần vào vùng tới hạn · 65 được chấp nhận · 65 + 15 = 80.
A không hề biết mình đã mất lock — và nó không cần biết. Việc kiểm tra nằm ở tài nguyên, nơi duy nhất có thông tin đầy đủ.

Cùng cấu hình gây hỏng ở thí nghiệm D — TTL 1000 ms, GC pause 1200 ms — nhưng bật fencing:

Cấu hình Xung đột Ghi được chấp nhận Ghi bị TỪ CHỐI
D · lock, pause 1200 > TTL 1000 31
E · lock + fencing, cùng pause 15 65 15
🔬 Đọc bảng này cho đúng: fencing không ngăn xung đột
Cột "xung đột" ở hàng E vẫn là 15, không phải 0 — và đó không phải thất bại. Fencing token không hề ngăn hai worker cùng vào vùng tới hạn; chỉ lock làm được việc đó, và lock đã hỏng vì GC pause. Fencing ngăn thiệt hại: 15 lệnh ghi của worker zombie bị tài nguyên từ chối, và 65 + 15 = 80 khớp đúng tổng số lần vào vùng tới hạn.

Vì sao 15 chứ không phải 31 như hàng D? Vì khi lệnh ghi bị từ chối, worker bỏ luôn phần xử lý 300 ms — nên vùng tới hạn của nó ngắn hơn nhiều và ít chồng lấn hơn. Đó là hệ quả của thiết kế lab, và nó cũng phản ánh đúng thực tế: một zombie bị chặn sớm thì gây ít nhiễu hơn.

Cách hiểu đúng: lock là lớp phòng ngừa, fencing là lớp bảo đảm. Lock giảm số lần tranh chấp (và nhờ đó giữ throughput); fencing đảm bảo rằng khi lock hỏng thì không có dữ liệu nào bị ghi sai. Nhóm "đúng đắn" ở mục 10.1 cần cả hai.
⚠️ Cạm bẫy: triển khai fencing nhưng tài nguyên không kiểm token
Đây là cách phổ biến nhất để làm fencing sai: worker lấy token, truyền token đi, ghi token vào log — nhưng tài nguyên đích không so sánh gì cả. Lúc đó token chỉ là một con số trang trí, và bạn có toàn bộ cảm giác an toàn mà không có chút an toàn nào.

Câu hỏi kiểm tra: tài nguyên của bạn có thể TỪ CHỐI một lệnh ghi không? Nếu nó chỉ biết nhận thì fencing không áp dụng được. Với database, cơ chế tương đương và rất thực dụng là optimistic concurrency: UPDATE ... WHERE version = :expected — số dòng bị ảnh hưởng bằng 0 chính là "bị từ chối". Nó không cần lock, không cần token riêng, và database đã có sẵn.

10.5 Tránh lock hoàn toàn — thường là câu trả lời tốt nhất

Sau bốn mục trên, kết luận thực dụng là: distributed lock đúng thì khó, và ngay cả khi đúng nó vẫn không cho đảm bảo mà nhóm "đúng đắn" cần. Nên trong phần lớn trường hợp, câu hỏi tốt hơn không phải "cài lock thế nào" mà là "có cách nào không cần lock?"

Cách Hoạt động thế nào Dùng khi
Thao tác atomic sẵn có INCR, SETNX, UPDATE ... SET n = n + 1 Thao tác diễn đạt được bằng một lệnh. Rẻ nhất và chắc nhất — không có gì để tranh vì không có khoảng thời gian nào ở giữa
Optimistic concurrency UPDATE ... WHERE version = :expected, 0 dòng bị ảnh hưởng ⇒ thử lại Xung đột hiếm. Không có lock nào bị giữ nên không có deadlock, không có TTL phải đoán
Idempotency (Bài 11) Làm hai lần cũng cho cùng kết quả nên không cần ngăn lần thứ hai Thao tác có thể thiết kế lại thành idempotent. Đây thường là lời giải đúng nhất, không chỉ là lời giải tránh lock
Phân vùng theo key (Bài 12) Mỗi key chỉ đi tới một worker cố định ⇒ không có hai worker nào cùng chạm một key Có hàng đợi phân vùng. Nó biến bài toán loại trừ lẫn nhau thành bài toán định tuyến — dễ hơn rất nhiều
Transaction của database Cơ chế cô lập thật, không dựa vào đồng hồ hay TTL Dữ liệu đã ở trong một database. Đây là công cụ đúng cho nghiệp vụ tiền bạc
⚠️ Cạm bẫy: giữ lock trong lúc gọi mạng
Nếu trong vùng tới hạn có một lời gọi HTTP hoặc một truy vấn database, thì thời gian giữ lock phụ thuộc vào một thứ bạn không kiểm soát. Hai hậu quả cùng lúc: TTL không đặt được đúng (bạn không biết giới hạn trên của một lời gọi mạng — xem lại mục 10.2 hàng C), và throughput bị chặn bởi dependency chậm nhất vì mọi worker khác đang xếp hàng chờ lock.

Còn một cạm bẫy song sinh: lock quá thô. Một lock cho cả bảng thay vì một lock cho mỗi bản ghi biến hệ thống song song thành hệ thống tuần tự. Nếu bạn phải dùng lock, hãy đặt phạm vi hẹp nhất có thể — theo order_id, không phải theo orders.
Ba cách giải cùng một bài toán — chỉ cách thứ nhất cần đoán TTL 1 · DISTRIBUTED LOCK w1 w2 LOCK tài nguyên Phải đoán TTL Hỏng khi GC pause > TTL Throughput bị tuần tự hoá Đo được: 31/80 xung đột khi pause vượt TTL 2 · THAO TÁC ATOMIC w1 w2 INCR / CAS atomic sẵn có Không có TTL nào để sai Không có khoảng giữa Cả hai worker đều chạy Giới hạn: chỉ dùng được khi diễn đạt được bằng MỘT lệnh 3 · PHÂN VÙNG THEO KEY key A,C,E key B,D,F w1 w2 Không có gì để tranh: mỗi key chỉ có MỘT worker xử lý Biến bài toán loại trừ lẫn nhau thành bài toán ĐỊNH TUYẾN Đổi lại: hotspot theo key (Bài 8) Thứ tự nên thử: (2) atomic → (3) phân vùng → transaction/idempotency → và chỉ khi hết cách mới tới (1). Cách 1 là cách duy nhất phải trả lời câu hỏi "TTL bao nhiêu là đủ" — một câu hỏi thường không có câu trả lời đúng.
Distributed lock không sai — nó chỉ là công cụ đắt nhất trong ba, nên nên là lựa chọn cuối chứ không phải phản xạ đầu tiên.

Tự làm lại các phép đo

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile lock up -d redis

# ./tools/lock-test.sh <LOCK> <FENCE> <PAUSE_MS> <TTL> <ROUNDS> <WORK_MS>
# The number that matters most is CONFLICTS: how often TWO OR MORE workers were
# inside the critical section at the same time.

./tools/lock-test.sh off 0 0    1000 40 300   # A: no lock         -> 41/80
./tools/lock-test.sh on  0 0    1000 40 300   # B: correct lock    ->  0/80
./tools/lock-test.sh on  0 0     200 40 300   # C: TTL < work time -> 79/80 (!)
./tools/lock-test.sh on  0 1200 1000 40 300   # D: GC pause > TTL  -> 31/80
./tools/lock-test.sh on  1 1200 1000 40 300   # E: + fencing -> 15 writes REJECTED

# A NOTE ON EXPERIMENT DESIGN: WORK_MS must be long enough relative to PAUSE_MS.
# Otherwise the other worker has already left the critical section by the time the
# "zombie" wakes up, and you measure 0 conflicts even though the lock really did fail.
# The first version of this lab made exactly that mistake.

Tóm lại

Phải phân biệt lock cho hiệu quả (hỏng thì tốn CPU) và lock cho đúng đắn (hỏng thì mất tiền). Distributed lock chỉ đủ cho nhóm thứ nhất, và toàn bộ mục 10.3 là bằng chứng bằng số cho điều đó.

Một lock Redis đúng cần đủ ba thứ: NX, TTL, và token ngẫu nhiên với lệnh giải phóng atomic. Nhưng cài đúng vẫn chưa đủ — phát hiện mạnh nhất của bài này là hàng C: lock đúng chuẩn với TTL 200 ms ngắn hơn thời gian xử lý 300 ms cho 79 xung đột trên 80, tệ hơn cả không dùng lock (41/80). Vì lock đã đồng bộ hoá hai worker để chúng trùng nhau một cách có hệ thống thay vì ngẫu nhiên.

Với GC pause dài hơn TTL, xung đột là 31/80 — và không một lệnh Redis nào lỗi. Đó là lý do thêm node Redis không sửa được: giả định sai nằm ở phía client. Fencing token là lời giải, nhưng phải đọc đúng: nó không ngăn xung đột (vẫn 15/80), nó ngăn thiệt hại — 15 lệnh ghi bị tài nguyên từ chối. Và nó chỉ có tác dụng nếu tài nguyên thật sự kiểm token.

Thực dụng nhất: tránh lock. Thao tác atomic, optimistic concurrency, phân vùng theo key, transaction — tất cả đều không phải trả lời câu hỏi "TTL bao nhiêu là đủ", một câu hỏi thường không có câu trả lời đúng.

Bài 11 đi vào cách tránh lock mạnh nhất trong danh sách đó: idempotency. Nếu làm hai lần cũng cho cùng kết quả thì không cần ngăn lần thứ hai — và đó cũng là câu trả lời cho một câu hỏi mà mọi hệ thống phân tán phải đối mặt: khi client nhận timeout, nó không thể biết request đã được xử lý hay chưa.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Worker đã sinh ra cả năm hàng số liệu trong bài — giành lock, giải phóng atomic bằng Lua, mô phỏng GC pause, và fencing token có kiểm tại tài nguyên. Không phụ thuộc thư viện nào, kể cả client Redis:

Tải về lock-worker.js

Bài viết liên quan trong series

Bài 9: CAP & Các Mô Hình Nhất Quán Bài 11: Idempotency & Retry An Toàn Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận