Skip to content

Distributed lock ​

Distributed lock là lock dùng chung cho nhiều process chạy trên nhiều máy, để tại một thời điểm chỉ một process được làm một việc nào đó. Trong một process Node.js thì một biến hay một Map là đủ. Nhưng khi app chạy 10 instance thì lock phải nằm ở một nơi mà cả 10 instance cùng đọc được.

Cái khó là lock vẫn phải đúng khi có sự cố, ví dụ process đang giữ lock bị crash, mạng chậm, hay server giữ lock bị sập.

Có cần distributed lock không? ​

Nhiều trường hợp không cần đến distributed lock, và không dùng thì hệ thống còn an toàn hơn:

  • Dữ liệu nằm trong database. Nếu vậy thì dùng chính database. Transaction, SELECT … FOR UPDATE, UNIQUE hay lệnh ghi có điều kiện (UPDATE … WHERE status = 'pending') đều an toàn hơn một lock đặt ở ngoài (Chống trùng & race condition).
  • Nhiều worker cùng lấy việc từ một hàng đợi. Dùng SELECT … FOR UPDATE SKIP LOCKED (Hàng đợi bằng database), hoặc để message broker chia việc.
  • Gọi API bên ngoài mà sợ gọi trùng. Dùng idempotency key (Xử lý lỗi & idempotency).

Distributed lock cần thiết khi:

  • Một job định kỳ chạy trên mọi instance, nhưng chỉ được phép chạy ở một instance (gửi email tổng kết, đối soát cuối ngày).
  • Tài nguyên không nằm trong database và tự nó không chống được ghi đồng thời, ví dụ API của bên thứ ba, hay file trên S3 khi không dùng conditional write (xem Fencing token).
  • Cần chọn ra một instance làm leader, ví dụ chỉ một instance chạy scheduler.
  • Cần chặn nhiều request cùng dựng lại một key cache (Thundering herd).

Lock cho efficiency hay cho correctness ​

Đây là câu hỏi cần trả lời trước khi chọn cách làm (Martin Kleppmann, How to do distributed locking, 2016). Lock cho efficiency chỉ để tránh làm trùng một việc tốn kém. Lock cho correctness thì để dữ liệu không bị sai.

EfficiencyCorrectness
Mục đíchTránh làm trùng việc tốn kémTránh sai dữ liệu
Nếu hai process cùng giữ lockTốn công gấp đôi, gửi trùng một emailGhi đè lên nhau, hỏng file, trừ tiền hai lần
Ví dụDựng lại cache, job tính báo cáoGhi file dùng chung, cấp số thứ tự
Đủ dùngMột Redis với SET NX PXHệ consensus (etcd, ZooKeeper) kèm fencing token, hoặc transaction của database

Hệ consensus trong bảng là các hệ mà mỗi lệnh ghi chỉ được chấp nhận khi đa số node trong cluster đã nhận, như giải thích ở phần dưới. Phần lớn lock trong ứng dụng web chỉ cần efficiency. Nếu lock thuộc loại correctness thì cần đọc kỹ các phần bên dưới.

TTL: không thể thiếu, nhưng cũng là nguồn lỗi ​

Lock phải có TTL (còn gọi là lease). Nếu không, process đang giữ lock mà crash, bị kill hay mất mạng thì lock không bao giờ được release, và không process nào acquire được nữa.

Nhưng TTL lại gây ra một lỗi khó thấy. Process đang giữ lock có thể bị treo lâu hơn TTL mà không hề biết mình vừa bị treo:

  1. Client 1 acquire lock với TTL 10 giây.
  2. Client 1 bị treo 14 giây.
  3. Đến giây thứ 11 thì lock hết hạn, client 2 acquire được lock và ghi dữ liệu.
  4. Client 1 chạy tiếp. Dòng code tiếp theo của nó là ghi dữ liệu, nên nó ghi đè lên dữ liệu của client 2.

Process có thể bị treo vì nhiều lý do:

  • GC pause của V8 hoặc JVM.
  • Event loop bị block trong Node.js, do một vòng lặp nặng CPU, JSON.parse một chuỗi rất lớn, hay một hàm đồng bộ như fs.readFileSync, crypto.pbkdf2Sync.
  • Container bị giới hạn CPU, máy ảo bị tạm dừng hoặc bị chuyển sang host khác, máy bị swap.
  • Mạng chậm. Process gửi lệnh ghi khi còn giữ lock, nhưng lệnh đến storage khi lock đã hết hạn.

Hai cách vá hay gặp không sửa được lỗi này:

  • Kiểm tra lock còn hạn rồi mới ghi. Process vẫn có thể bị treo đúng vào khoảng giữa lúc kiểm tra và lúc ghi.
  • Dùng timer để gia hạn lock. Khi event loop bị block hoặc GC đang chạy thì timer cũng không chạy. Gia hạn chỉ có ích khi việc kéo dài, còn khi process bị treo thì không cứu được.
Thử: client đang giữ lock bị treo lâu hơn TTL
Client 1 chạy tiếp, vẫn tưởng mình giữ lock, nên ghi đè lên dữ liệu client 2 vừa ghi. Có 1,5 giây cả hai cùng tin mình đang giữ lock.
0s5s10s15s20sClient 1Client 2LockStoragebị treo 14schờ lock…token 33hết hạntoken 34ghi (34)ghi (33)
client 1client 2bị treo (GC, event loop bị block)cả hai client cùng tin mình giữ lock

Giả định: client 1 acquire lock ở giây 1, bị treo từ giây 2, chạy tiếp thì ghi ngay rồi release. Client 2 chờ lock từ giây 3, acquire được thì 1 giây sau ghi, và 0,5 giây sau nữa release. Khoảng cả hai cùng tin mình giữ lock tính từ lúc client 2 acquire tới lúc một trong hai release. Thanh nhạt ở hàng client là khoảng thời gian client đó tin mình đang giữ lock, còn hàng Lock là trạng thái thật trên lock service.

Fencing token ​

Mỗi lần cấp lock, lock service trả kèm một số tăng dần, ví dụ lần trước là 33 thì lần sau là 34. Số này gọi là fencing token. Client gửi token kèm mọi lệnh ghi, còn storage nhớ token lớn nhất từng nhận và từ chối mọi lệnh ghi mang token nhỏ hơn.

sql
-- storage là một bảng: chỉ ghi khi token không cũ hơn token từng nhận
UPDATE documents
SET content = :content, fence = :token
WHERE id = :id AND fence <= :token;
-- 0 dòng bị ảnh hưởng nghĩa là đã có client mang token mới hơn ghi trước

Fencing chỉ hoạt động khi có đủ hai điều kiện:

  1. Token phải thực sự tăng dần, kể cả khi lock service gặp sự cố. Các hệ consensus có sẵn số như vậy: etcd có revision và ZooKeeper có zxid, cả hai tăng dần trên toàn cluster. Số thứ tự của znode trong ZooKeeper thì chỉ tăng dần trong một node cha, nhưng một lock dùng đúng một node cha nên vẫn dùng làm token được. Còn chuỗi ngẫu nhiên trong SET NX của Redis thì không dùng được, vì không so được chuỗi nào cấp trước, chuỗi nào cấp sau.
  2. Storage phải biết kiểm tra token. Database làm được, như ví dụ trên. Nhưng nhiều hệ thống không làm được, ví dụ API của bên thứ ba không hề biết đến token này. S3 cũng không kiểm tra token, nhưng hỗ trợ conditional write theo ETag (If-Match). Nếu ETag không khớp thì S3 trả 412 Precondition Failed, nên có thể dùng nó như compare-and-set.

Nếu storage không kiểm tra được token thì chỉ còn cách giảm khả năng xảy ra lỗi. Đặt TTL dài hơn nhiều lần thời gian làm việc, tránh block event loop, và chấp nhận rằng lock không an toàn tuyệt đối.

Lock service phải strongly consistent ​

Strongly consistent ở đây nghĩa là mọi client đều thấy cùng một trạng thái lock mới nhất, như thể dữ liệu chỉ có một bản. Thuật ngữ chính xác là linearizable. Bản thân lock service cũng cần fault tolerance, tức là vẫn chạy được khi có máy hỏng, nên nó phải có nhiều replica. Cách replicate dữ liệu quyết định lock có an toàn hay không:

Cách replicateVấn đề với lock
Đọc từ replica, async replicationReplica chưa nhận bản ghi “A đang giữ lock” nên trả lời B rằng lock đang trống. Kết quả là cả hai cùng giữ lock
Đọc và ghi đều qua leader, async replicationA acquire lock ở leader. Leader sập trước khi kịp gửi bản ghi sang replica. Replica lên làm leader mà không biết A đang giữ lock, nên cấp lock cho B. Tài liệu Redis nêu đúng kịch bản này để giải thích vì sao Redis có replica và failover vẫn không đảm bảo lock
Sync replication tới mọi replicaLock an toàn, nhưng chỉ cần một replica sập là cả hệ thống không ghi được nữa, tức là mất fault tolerance
Leaderless quorum (W + R > N, kiểu Cassandra)Không linearizable. Một lệnh ghi lỗi giữa chừng có thể để lại giá trị ở vài node, và hai client có thể nhận cùng một số thứ tự
Consensus (Raft, Paxos, Zab)Lệnh ghi chỉ thành công khi đa số node đã nhận, nên leader mới luôn có đủ mọi lệnh ghi đã commit. Cluster vẫn chạy đúng khi số node hỏng chưa quá một nửa

Vì vậy lock cho correctness thường dùng etcd, ZooKeeper hoặc Consul. Cả ba đều chạy consensus trên một cluster 3 hoặc 5 node (Đồng thuận, Nhất quán & CAP). Số node luôn lẻ, vì thêm một node để thành số chẵn không giúp chịu thêm lỗi: cluster 3 node cần đa số 2 nên chịu được 1 node hỏng, còn cluster 4 node cần đa số 3 nên cũng chỉ chịu được 1 node hỏng (bảng trong FAQ của etcd).

Redis ​

Một instance ​

SET lock:report-daily <giá trị ngẫu nhiên> NX PX 30000
  • NX: chỉ set khi key chưa tồn tại. Redis trả OK nghĩa là acquire được lock, trả nil nghĩa là đang có client khác giữ.
  • PX 30000: Redis tự xoá key sau 30 giây.
  • Giá trị ngẫu nhiên, mỗi lần acquire một giá trị khác nhau. Nhờ giá trị này mà lúc release, client chỉ xoá đúng lock của mình. Nếu release bằng DEL thì một client bị treo quá TTL sẽ xoá mất lock mà client khác vừa acquire.

Release lock bằng một Lua script so giá trị trước rồi mới xoá. Từ Redis 8.4 có lệnh DELEX key IFEQ value làm đúng việc này.

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

Cách này có hai giới hạn. Thứ nhất, Redis có replica và failover thì vẫn có thể mất lock như bảng ở phần trên. Thứ hai, Redis tính TTL theo đồng hồ hệ thống, nên khi đồng hồ bị chỉnh nhảy thì lock có thể hết hạn sớm. Với lock cho efficiency thì như vậy là đủ tốt.

Redlock ​

Redlock là thuật toán do tác giả Redis đề xuất. Nó dùng N instance Redis độc lập, thường là 5, không replicate cho nhau:

  1. Client ghi lại thời điểm bắt đầu.
  2. Client gửi SET NX PX với cùng key và cùng giá trị tới cả N instance. Mỗi lệnh có timeout ngắn.
  3. Nếu acquire được ở đa số instance (ít nhất 3 trên 5) và tổng thời gian đã tốn vẫn nhỏ hơn TTL thì client coi như đã có lock. Thời gian lock còn hiệu lực bằng TTL trừ đi thời gian đã tốn. Tài liệu Redis còn nói rõ rằng lock chỉ được đảm bảo nếu client làm xong việc trong khoảng đó trừ thêm vài ms để bù độ lệch đồng hồ giữa các process.
  4. Nếu không đạt thì client release lock ở mọi instance, chờ một khoảng ngẫu nhiên rồi thử lại.

Tài liệu Redis cũng nêu một rủi ro của Redlock khi một instance khởi động lại. Giả sử Redis chạy không có persistence: client acquire lock ở 3 trên 5 instance, rồi một trong ba instance đó restart và mất key. Lúc này lại có 3 instance trống, và client khác acquire được cùng lock. Bật AOF giúp được trong trường hợp tắt máy bình thường, nhưng với cấu hình mặc định Redis chỉ fsync xuống disk mỗi giây, nên mất điện vẫn có thể mất key. Muốn an toàn trước mọi kiểu restart thì phải fsync sau mỗi lệnh ghi, và hiệu năng giảm. Cách tài liệu đề xuất là delayed restart: instance vừa crash phải chờ lâu hơn TTL lớn nhất rồi mới nhận lệnh trở lại, để mọi lock nó từng tham gia đều đã hết hạn. Đổi lại, nếu đa số instance cùng crash thì không lock nào acquire được trong suốt khoảng TTL đó.

Tranh luận giữa Kleppmann và antirez (2016). Kleppmann chỉ ra rằng Redlock dựa vào các giả định về thời gian, cụ thể là độ trễ mạng, thời gian process bị treo và độ lệch đồng hồ đều phải nhỏ so với TTL. Khi giả định sai, ví dụ đồng hồ một node nhảy hay client bị treo sau khi đã gửi lệnh, thì hai client có thể cùng giữ lock. Ngoài ra Redlock không sinh được fencing token. antirez, tác giả Redis, phản hồi ba ý:

  • Redlock không cần đồng hồ các máy khớp nhau. Nó chỉ cần mỗi máy đo khoảng thời gian trôi qua với sai số nhỏ. Ông đồng ý rằng đồng hồ nhảy là vấn đề thật, và hướng xử lý là chuyển Redis sang đồng hồ monotonic.
  • Client đo lại thời gian sau khi acquire được đa số, nên nếu bị treo trong lúc acquire thì client sẽ phát hiện ra. Còn bị treo sau khi đã có lock thì loại lock nào có TTL cũng chịu chung rủi ro.
  • Có thể dùng chính giá trị ngẫu nhiên của lock để storage làm check-and-set, thay cho token tăng dần. Hơn nữa, nếu storage đã làm được check-and-set thì gần như không cần một lock mạnh nữa.

Hiện nay chính tài liệu Redis cũng khuyên dùng fencing token, và lưu ý rằng Redis không dùng đồng hồ monotonic để tính TTL.

Kết luận thực tế: với lock cho efficiency thì một Redis là đủ, dùng Redlock là thừa. Với lock cho correctness thì Redlock chưa đủ, mà cần hệ consensus kèm fencing token, hoặc dùng database.

etcd và ZooKeeper ​

Các bài system design hay mô tả distributed lock theo đúng cách etcd và ZooKeeper làm:

  • Lease gắn với session. Client mở một session và gửi heartbeat đều đặn. Nếu quá TTL mà không nhận được heartbeat thì server cho session hết hạn và release mọi lock của session đó. Nhờ vậy không phải đoán TTL cho từng việc. Nhưng session lease chỉ giải quyết chuyện chọn TTL. Client bị treo lâu hơn TTL của session thì vẫn mất lock mà không biết, đúng như trường hợp ở trên, nên vẫn cần fencing token.
  • Xếp hàng thay vì tranh nhau. Mỗi client muốn acquire lock tạo một node có số thứ tự tăng dần (ZooKeeper dùng ephemeral sequential znode, etcd dùng key gắn với lease và sắp theo revision). Client có số nhỏ nhất là client đang giữ lock. Mỗi client còn lại chỉ watch node đứng ngay trước nó. Vì vậy khi lock được release, server chỉ báo cho đúng một client, và không xảy ra cảnh hàng trăm client cùng lao vào acquire (thundering herd).
  • Có sẵn fencing token. Revision của etcd tăng dần trên toàn cluster, còn số thứ tự znode của ZooKeeper tăng dần trong node cha của lock, nên dùng thẳng làm token gửi kèm lệnh ghi.
  • Đọc linearizable. Mặc định etcd cho lệnh đọc đi qua consensus, nên luôn thấy dữ liệu mới nhất. etcd cũng có tuỳ chọn đọc serializable, nhanh hơn nhưng có thể trả dữ liệu cũ.

Trong Node.js, thư viện etcd3 có sẵn lock và leader election. ZooKeeper thì ít gặp trong hệ sinh thái Node.

Dùng database làm lock ​

Nếu hệ thống đã có MySQL hoặc PostgreSQL thì đây thường là cách đơn giản nhất cho job định kỳ và leader election.

Advisory lock là lock theo tên, không gắn với dòng dữ liệu nào.

sql
-- MySQL: chờ tối đa 5 giây; trả 1 nghĩa là acquire được, 0 nghĩa là hết thời gian chờ
SELECT GET_LOCK('report-daily', 5);     -- tên tối đa 64 ký tự
SELECT RELEASE_LOCK('report-daily');    -- COMMIT/ROLLBACK không release lock này

-- PostgreSQL: lock theo một số nguyên 64 bit
SELECT pg_try_advisory_lock(42);        -- giữ đến khi gọi unlock hoặc session kết thúc
SELECT pg_advisory_unlock(42);
SELECT pg_try_advisory_xact_lock(42);   -- tự release khi transaction kết thúc

Advisory lock gắn với connection. Nếu dùng connection pool thì phải acquire và release trên cùng một connection, và giữ riêng connection đó trong suốt thời gian giữ lock. Khi connection bị đứt, database tự release lock mà process có thể chưa biết, tức là lại quay về bài toán TTL ở trên. Với PostgreSQL đứng sau PgBouncer ở chế độ transaction pooling thì session advisory lock không dùng được (tài liệu PgBouncer ghi “Never”), vì mỗi transaction có thể chạy trên một connection server khác. Khi đó chỉ dùng được pg_try_advisory_xact_lock, lock tự release khi transaction kết thúc.

Bảng lease kèm fencing token. Lệnh UPDATE không tạo dòng mới, nên phải tạo dòng cho tên lock trước (một lần, hoặc mỗi lần acquire với lệnh bỏ qua khi đã có). Token nên lấy ra ngay trong lệnh acquire, không cần thêm một lệnh đọc:

sql
CREATE TABLE leases (
  name       VARCHAR(100) PRIMARY KEY,
  owner      VARCHAR(64) NULL,
  expires_at TIMESTAMP(3) NULL,
  fence      BIGINT NOT NULL DEFAULT 0
);

-- MySQL
INSERT IGNORE INTO leases (name) VALUES (:name);               -- tạo dòng nếu chưa có
UPDATE leases
SET owner = :me, expires_at = NOW(3) + INTERVAL 30 SECOND, fence = LAST_INSERT_ID(fence + 1)
WHERE name = :name AND (owner IS NULL OR expires_at < NOW(3));
-- 1 dòng bị ảnh hưởng: đã acquire. Token là LAST_INSERT_ID() trên cùng connection,
-- mysql2 trả sẵn trong result.insertId

-- PostgreSQL
INSERT INTO leases (name) VALUES (:name) ON CONFLICT DO NOTHING;
UPDATE leases
SET owner = :me, expires_at = now() + interval '30 seconds', fence = fence + 1
WHERE name = :name AND (owner IS NULL OR expires_at < now())
RETURNING fence;                                               -- token

-- Release: chỉ xoá đúng lock của mình
UPDATE leases SET owner = NULL, expires_at = NULL
WHERE name = :name AND owner = :me AND fence = :token;

LAST_INSERT_ID(expr) là cách tài liệu MySQL hướng dẫn để giả lập sequence: hàm trả về giá trị của expr và nhớ nó cho lần gọi LAST_INSERT_ID() tiếp theo, riêng cho từng connection. Đã chạy thử trên MySQL 8.0 và PostgreSQL 17 (hai lượt, kết quả giống nhau):

BướcKết quả
Bảng chưa có dòng nào, A acquireĐược, token 1
B acquire khi A đang giữ0 dòng, không được
Hết TTL, B acquireĐược, token 2
A tỉnh dậy, ghi document với token 1 (sau khi B đã ghi với token 2)0 dòng, bị từ chối
A release với token 10 dòng, không xoá được lock của B
20 client acquire cùng lúcĐúng 1 client được

Thời gian lấy theo đồng hồ của database, nên mọi instance dùng chung một đồng hồ. Cột fence tăng lên mỗi lần cấp lock, nên dùng luôn làm fencing token. Tuy vậy, nếu database dùng async replication thì failover vẫn có thể làm mất lock như bảng ở phần trước.

Nếu việc cần bảo vệ cũng nằm trong chính database đó thì không cần lock nữa, chỉ cần làm trong một transaction.

Node.js: lock bằng Redis viết cho đúng ​

js
import { randomUUID } from 'node:crypto'

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

// node-redis v4+: trả về kết quả của fn, hoặc null nếu không acquire được lock
export async function withLock(redis, key, ttlMs, fn) {
  const token = randomUUID()
  const ok = await redis.set(key, token, { NX: true, PX: ttlMs })
  if (ok !== 'OK') return null
  try {
    return await fn()
  } finally {
    try {
      await redis.eval(RELEASE, { keys: [key], arguments: [token] })
    } catch (err) {
      // Lỗi trong finally sẽ che mất kết quả hay lỗi của fn, nên chỉ ghi log; lock tự hết hạn theo TTL
      console.warn('release lock thất bại', key, err)
    }
  }
}

// Dựng lại cache tốn kém: chỉ một instance làm, xong thì release để lần sau làm được ngay
await withLock(redis, 'lock:rebuild:top-products', 30_000, () => rebuildTopProducts())

Job hằng ngày thì không dùng withLock như trên. Cron trên các instance không bao giờ chạy đúng cùng một khoảnh khắc. Nếu instance A chạy xong báo cáo trong 3 giây và release lock, thì instance B khởi động chậm hay đồng hồ lệch 5 giây vẫn acquire được lock và chạy báo cáo lần nữa. Có hai cách sửa:

js
// Cách 1: đưa ngày vào tên key và KHÔNG release, để key tự hết hạn sau 25 giờ.
// Mọi instance phải tính ngày theo cùng một múi giờ.
import os from 'node:os'

async function runDailyReport(redis, day) {          // day = '2026-10-20'
  const ok = await redis.set(`lock:report-daily:${day}`, os.hostname(), { NX: true, PX: 25 * 3600_000 })
  if (ok !== 'OK') return                          // instance khác đã nhận việc của ngày này
  await buildDailyReport(day)
}
sql
-- Cách 2: ghi “đã chạy cho ngày X” vào database, trong cùng transaction với kết quả nếu được
CREATE TABLE job_runs (job VARCHAR(64), run_date DATE, finished_at TIMESTAMP NULL, PRIMARY KEY (job, run_date));
INSERT INTO job_runs (job, run_date) VALUES ('report-daily', '2026-10-20');  -- lỗi trùng khoá: đã có instance nhận

Cách 1 có một nhược điểm: job crash giữa chừng thì key vẫn còn, nên không instance nào chạy lại job của ngày đó. Cách 2 lưu được cả trạng thái finished_at, nên có thể báo động hoặc cho chạy lại khi job nhận việc mà mãi không xong. Cả hai đều là biến việc đó thành idempotent theo ngày, đúng như lời khuyên ngay bên dưới.

  • Đặt TTL dài hơn nhiều lần thời gian làm việc dự kiến, và viết việc đó idempotent, tức là chạy lại lần hai cũng không gây hại.
  • Không release bằng DEL. Lúc nào cũng phải so token trước khi xoá.
  • Việc kéo dài thì gia hạn định kỳ bằng một script so token rồi gọi PEXPIRE. Nếu gia hạn thất bại thì dừng việc lại, ví dụ bằng AbortController. Thư viện redlock trên npm có sẵn cơ chế tự gia hạn.
  • Không block event loop trong lúc giữ lock.
  • Giữ nhiều lock cùng lúc thì luôn acquire theo một thứ tự cố định, ví dụ theo tên tăng dần. Hai process acquire hai lock theo thứ tự ngược nhau thì có thể chờ nhau mãi (deadlock), giống deadlock trong database.
  • Cần correctness tuyệt đối thì dùng etcd hoặc database, và gửi fencing token tới storage.

Thiết kế một lock service (câu hỏi system design) ​

Khi gặp câu hỏi “thiết kế distributed lock”, một câu trả lời đầy đủ gồm các ý sau:

  1. Yêu cầu. Tại một thời điểm chỉ một client giữ lock, kể cả khi có sự cố. Hệ thống vẫn chạy khi có máy hỏng. Nhiều client có thể cùng chờ một lock.
  2. Lưu trữ. Dùng một cluster 3 hoặc 5 node chạy consensus (Raft). Mỗi lệnh acquire là một lệnh ghi vào log, và chỉ commit khi đa số node đã nhận. Leader giữ trạng thái lock trong bộ nhớ để trả lời nhanh. Nhưng trước khi trả lời lệnh đọc, leader vẫn phải xác nhận mình còn là leader, vì một leader cũ bị cô lập có thể trả lời sai.
  3. Fencing token. Dùng chỉ số log (hoặc revision) của lệnh cấp lock, vì số này tăng dần trên toàn cluster.
  4. Phát hiện client chết. Mỗi client có một session gửi heartbeat qua một kết nối giữ lâu, như gRPC stream hoặc WebSocket. Quá TTL mà không nhận được heartbeat thì server release lock của client đó.
  5. Chờ lock. Mỗi lock có một hàng đợi. Khi lock được release, server chỉ báo cho client kế tiếp (server push). Client không phải hỏi liên tục, và server không báo cho tất cả cùng lúc.
  6. Đánh đổi. Cluster càng nhiều node thì càng chịu được nhiều node hỏng, nhưng mỗi lệnh ghi càng chậm.

Ghi nhớ

  1. Trước hết hỏi xem có thật cần lock không. Nếu dữ liệu nằm trong database thì dùng transaction, UNIQUE hoặc lệnh ghi có điều kiện.
  2. Phân biệt lock cho efficiency (một Redis với SET NX PX là đủ) với lock cho correctness (cần consensus kèm fencing token, hoặc database).
  3. Lock phải có TTL. Nhưng process bị treo quá TTL sẽ chạy tiếp mà vẫn tưởng mình giữ lock, và gia hạn bằng timer không cứu được trường hợp này.
  4. Fencing token là số tăng dần cấp kèm lock, và storage từ chối mọi token cũ hơn token từng nhận. Cách này chỉ dùng được khi storage biết kiểm tra token.
  5. Lock service phải linearizable, vì async replication và leaderless quorum đều có thể cấp lock cho hai client cùng lúc.
  6. Với Redis, acquire bằng một giá trị ngẫu nhiên và release bằng cách so giá trị đó. Redlock không giải quyết được bài toán correctness.

Nguồn tham khảo ​

Đã đối chiếu (đọc lại nguồn ngày 2026-10-15; các dòng ghi 2026-10-20 được đọc thêm khi kiểm theo một bài review):

NguồnDùng cho phần
Redis docs, Distributed Locks with RedisSET NX PX, giá trị ngẫu nhiên, script release, DELEX … IFEQ (Redis 8.4), các bước Redlock, vì sao replica và failover làm mất lock, TTL không dùng đồng hồ monotonic, gia hạn lock, thư viện node-redlock. 2026-10-20: thời gian hiệu lực trừ thêm độ lệch đồng hồ (MIN_VALIDITY = TTL − (T2 − T1) − CLOCK_DRIFT), instance restart mất key khi không có persistence, fsync mặc định mỗi giây, delayed restart
ZooKeeper docs, Programmer’s Guide (2026-10-20)Bộ đếm của sequential znode là riêng cho node cha; zxid xếp thứ tự toàn bộ thay đổi
PgBouncer, Features (2026-10-20)Session-level advisory lock: “Never” ở chế độ transaction pooling
etcd docs, FAQ (2026-10-20)Khuyên số node lẻ, bảng đa số và số node hỏng chịu được (3 và 4 node đều chịu được 1)
MySQL 8.4 Reference Manual, Information Functions (2026-10-20)LAST_INSERT_ID(expr) để giả lập sequence, giá trị nhớ theo từng connection, mysql_insert_id() cập nhật sau UPDATE
Martin Kleppmann, How to do distributed locking (2016)Lock cho efficiency và cho correctness, ví dụ GC pause, fencing token, các giả định thời gian của Redlock, khuyến nghị cuối bài
antirez, Is Redlock safe? (2016)Phản hồi của tác giả Redis cho bài của Kleppmann
etcd docs, API guaranteesĐọc linearizable mặc định, tuỳ chọn serializable, revision tăng dần trên toàn cluster, lease
ZooKeeper docs, Recipes: LocksEphemeral sequential znode, chỉ watch node đứng trước để tránh thundering herd
MySQL 8.4 Reference Manual, Locking FunctionsGET_LOCK, RELEASE_LOCK: giá trị trả về, tên tối đa 64 ký tự, lock gắn với session
PostgreSQL docs, Advisory Lock Functionspg_try_advisory_lock, pg_advisory_unlock, pg_try_advisory_xact_lock
AWS docs, S3 conditional writesIf-Match theo ETag, lỗi 412 Precondition Failed

Video gốc chủ repo gửi (đọc qua bản chép lời): Distributed locking. Phần lớn nội dung trong video đúng, và trang này đã tóm vào hai phần “Lock service phải strongly consistent” và “Thiết kế một lock service”. Có vài chỗ trang này không theo video:

  • Video gọi bước replicate của Raft là two-phase commit. Thực ra Raft chỉ cần đa số node nhận bản ghi, còn 2PC cần mọi bên đồng ý.
  • Video nói node bỏ phiếu cho ứng viên nếu log của ứng viên có từ bằng số entry trở lên. Raft so term của entry cuối trước, rồi mới so độ dài log.
  • Video nói các follower gửi heartbeat cho nhau. Thực tế chỉ leader gửi heartbeat tới follower, và Raft tránh nhiều node cùng ứng cử một lúc bằng election timeout ngẫu nhiên.
  • Video cho leader đọc trạng thái lock thẳng từ bộ nhớ. Muốn linearizable thì leader phải xác nhận mình còn là leader trước khi trả lời.
  • Video không nhắc tới việc phân biệt lock cho efficiency với lock cho correctness, việc storage phải biết kiểm tra token, cũng như Redis, Redlock và lock bằng database.

Từ kiến thức chung, chưa đối chiếu lần này:

  • Diego Ongaro, John Ousterhout, In Search of an Understandable Consensus Algorithm (Raft), 2014: luật bỏ phiếu (§5.4.1), leader xác nhận vai trò trước khi trả lời lệnh đọc (§8). Dùng để nhận xét video.
  • Martin Kleppmann, Designing Data-Intensive Applications (O’Reilly, 2017), chương 8 (process bị treo, fencing token) và chương 9 (linearizability, vì sao quorum không linearizable).
  • Cú pháp node-redis v4 (set với { NX, PX }, eval với { keys, arguments }): node-redis.
  • Thư viện etcd3 cho Node.js có lock và leader election: microsoft/etcd3.
  • Các nguyên nhân làm process Node.js bị treo (event loop bị block, container bị giới hạn CPU, máy ảo bị tạm dừng).
  • Code withLock và hai cách cho job hằng ngày chưa chạy thử với Redis thật (lab chưa có Redis); lỗi chạy job hai lần là suy luận từ cách cron chạy lệch nhau, theo một bài review ngày 2026-10-20.

Code đã dùng để chạy thử ​

Bảng lease chạy bằng npm run lab -- distributed-lock (cần npm run lab:up, khoảng 5 giây), hai lượt ngày 2026-10-20 trên MySQL 8.0 và PostgreSQL 17, xem Lab.

lab/distributed-lock/lease.mjs: bảng lease, token, hết hạn, release, 20 client tranh lock
js
// Trang distributed/distributed-lock: bảng lease kèm fencing token trên MySQL và PostgreSQL. Cần lab (npm run up).
// Kiểm từng bước của đoạn SQL trong trang:
//   tạo dòng cho tên lock nếu chưa có; acquire và lấy token trong cùng một lệnh; lock hết hạn thì client khác acquire được;
//   client cũ release thì 0 dòng; lệnh ghi mang token cũ bị storage từ chối; 20 client tranh cùng lúc chỉ 1 client được.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG, sleep } from '../common/config.mjs'

const TTL = 1 // giây, ngắn để thử hết hạn

async function runMysql() {
  const admin = await mysql.createConnection(MYSQL)
  await admin.query('CREATE DATABASE IF NOT EXISTS dlock')
  await admin.end()
  const pool = mysql.createPool({ ...MYSQL, database: 'dlock', connectionLimit: 25 })
  await pool.query('DROP TABLE IF EXISTS leases, documents')
  await pool.query(`CREATE TABLE leases (
    name VARCHAR(100) PRIMARY KEY, owner VARCHAR(64) NULL, expires_at TIMESTAMP(3) NULL, fence BIGINT NOT NULL DEFAULT 0)`)
  await pool.query('CREATE TABLE documents (id INT PRIMARY KEY, content TEXT, fence BIGINT NOT NULL DEFAULT 0)')
  await pool.query("INSERT INTO documents VALUES (1, 'gốc', 0)")

  const acquire = async (me) => {
    await pool.execute('INSERT IGNORE INTO leases (name) VALUES (?)', ['report'])
    const [r] = await pool.execute(
      `UPDATE leases SET owner = ?, expires_at = NOW(3) + INTERVAL ${TTL} SECOND, fence = LAST_INSERT_ID(fence + 1)
       WHERE name = ? AND (owner IS NULL OR expires_at < NOW(3))`, [me, 'report'])
    return r.affectedRows === 1 ? r.insertId : null // insertId của mysql2 chính là giá trị LAST_INSERT_ID(fence + 1)
  }
  const release = async (me, token) => (await pool.execute(
    'UPDATE leases SET owner = NULL, expires_at = NULL WHERE name = ? AND owner = ? AND fence = ?', ['report', me, token]))[0].affectedRows
  const write = async (content, token) => (await pool.execute(
    'UPDATE documents SET content = ?, fence = ? WHERE id = 1 AND fence <= ?', [content, token, token]))[0].affectedRows

  console.log('\n=== MySQL 8.0 ===')
  console.log('bảng chưa có dòng nào cho lock, client A acquire → token', await acquire('A'))
  console.log('client B acquire khi A đang giữ → token', await acquire('B'))
  await sleep(TTL * 1000 + 200)
  const tb = await acquire('B')
  console.log(`hết TTL, client B acquire → token ${tb}`)
  console.log('B ghi document với token', tb, '→ số dòng:', await write('của B', tb))
  console.log('A tỉnh dậy, ghi với token 1 → số dòng:', await write('của A', 1), '(bị từ chối)')
  console.log('A release với token 1 → số dòng:', await release('A', 1), '(không xoá được lock của B)')
  console.log('B release với token', tb, '→ số dòng:', await release('B', tb))
  const [[doc]] = await pool.query('SELECT content, fence FROM documents WHERE id = 1')
  console.log('document cuối:', JSON.stringify(doc))
  const c = await pool.getConnection()
  await c.execute(`UPDATE leases SET owner = 'C', expires_at = NOW(3) + INTERVAL 1 SECOND, fence = LAST_INSERT_ID(fence + 1) WHERE name = 'report'`)
  const [[{ id }]] = await c.query('SELECT LAST_INSERT_ID() AS id')
  c.release()
  console.log('SELECT LAST_INSERT_ID() trên cùng connection sau UPDATE →', id)
  await pool.query("UPDATE leases SET owner = NULL, expires_at = NULL WHERE name = 'report'")
  const got = (await Promise.all(Array.from({ length: 20 }, (_, i) => acquire('w' + i)))).filter((t) => t !== null)
  console.log(`20 client acquire cùng lúc → ${got.length} client được, token ${got.join(', ')}`)
  await pool.end()
}

async function runPg() {
  const admin = new pg.Client(PG)
  await admin.connect()
  const has = await admin.query("SELECT 1 FROM pg_database WHERE datname = 'dlock'")
  if (!has.rowCount) await admin.query('CREATE DATABASE dlock')
  await admin.end()
  const pool = new pg.Pool({ ...PG, database: 'dlock', max: 25 })
  await pool.query('DROP TABLE IF EXISTS leases, documents')
  await pool.query('CREATE TABLE leases (name VARCHAR(100) PRIMARY KEY, owner VARCHAR(64) NULL, expires_at TIMESTAMPTZ NULL, fence BIGINT NOT NULL DEFAULT 0)')
  const acquire = async (me) => {
    await pool.query('INSERT INTO leases (name) VALUES ($1) ON CONFLICT DO NOTHING', ['report'])
    const r = await pool.query(
      `UPDATE leases SET owner = $1, expires_at = now() + interval '${TTL} second', fence = fence + 1
       WHERE name = $2 AND (owner IS NULL OR expires_at < now()) RETURNING fence`, [me, 'report'])
    return r.rowCount === 1 ? Number(r.rows[0].fence) : null
  }
  console.log('\n=== PostgreSQL 17 ===')
  console.log('client A acquire → token', await acquire('A'))
  console.log('client B acquire khi A đang giữ → token', await acquire('B'))
  await sleep(TTL * 1000 + 200)
  console.log('hết TTL, client B acquire → token', await acquire('B'))
  await pool.query("UPDATE leases SET owner = NULL, expires_at = NULL WHERE name = 'report'")
  const got = (await Promise.all(Array.from({ length: 20 }, (_, i) => acquire('w' + i)))).filter((t) => t !== null)
  console.log(`20 client acquire cùng lúc → ${got.length} client được, token ${got.join(', ')}`)
  await pool.end()
}

await runMysql()
await runPg()