Distributed Locks trong Backend Production: khi nào cần, lease timeout, fencing token và những ngộ nhận dễ gây double-processing
Trong nhiều hệ thống backend, nhu cầu “chỉ cho một process chạy việc này tại một thời điểm” xuất hiện rất sớm. Một cron worker không được gửi cùng một hóa đơn hai lần. Một job đồng bộ dữ liệu không được để hai replica cùng chạy lên một tập record. Một workflow thanh toán không được để hai consumer cùng claim một payment intent. Khi đối diện những tình huống như vậy, nhiều team gần như phản xạ: thêm distributed lock.
Phản xạ đó không sai, nhưng rất dễ nguy hiểm nếu lock được xem như một lá bùa đảm bảo correctness. Trong production, distributed lock không phải phép màu. Nó chỉ là một cơ chế điều phối ownership tạm thời trên tài nguyên chung. Nếu hiểu sai bản chất của nó, anh có thể vẫn gặp double-processing, lost update, split brain, stale lock, hoặc tệ hơn là tin rằng hệ thống “đã an toàn” trong khi thực tế chỉ đẩy race condition sang một chỗ khó nhìn hơn.
Bài này đi sâu vào distributed locks dưới góc nhìn backend production: khi nào lock là lựa chọn hợp lý, khi nào không nên dùng lock làm giải pháp chính, vì sao lease timeout không tự đảm bảo an toàn, sự khác nhau giữa lock ownership và write authority, vai trò của fencing token, lock drift, failure mode hay gặp với Redis/ZooKeeper/etcd/DB row lock, và checklist thực dụng để tránh biến distributed lock thành false sense of safety.
Distributed lock thực chất giải quyết bài toán gì?
Distributed lock giải quyết một nhu cầu rất cụ thể: điều phối quyền thực thi tạm thời giữa nhiều process/node cùng cạnh tranh trên một logical resource.
Điểm quan trọng là “logical resource” chứ không nhất thiết là record database duy nhất. Nó có thể là:
- một tenant đang chạy billing;
- một user đang được rebuild projection;
- một scheduled job chỉ nên có một active runner;
- một workflow compensation chỉ nên có một coordinator;
- một shard consumer chỉ nên có một owner.
Ở mức trừu tượng, distributed lock cố gắng trả lời câu hỏi:
- ai đang được quyền xử lý resource này trong cửa sổ thời gian hiện tại;
- quyền đó kéo dài bao lâu;
- chuyện gì xảy ra nếu holder chết giữa chừng;
- node khác dựa vào tín hiệu nào để biết có thể tiếp quản.
Nếu hiểu lock là “cấm tuyệt đối mọi actor khác chạm vào dữ liệu”, anh sẽ rất dễ kỳ vọng sai. Trong distributed system, lock thường chỉ cung cấp một best-effort mutual exclusion protocol dựa trên timing, leases và storage semantics của hệ mà anh dùng.
Vì sao production thường tìm đến distributed lock?
Có vài class use case rất phổ biến.
1. Scheduled jobs hoặc singleton workers
Ví dụ hệ thống có 6 replica cùng chạy cron mỗi phút, nhưng chỉ một replica được phép thực thi job reconcile payment. Nếu không có coordination, cùng một job có thể chạy 6 lần.
2. Claim work từ shared queue hoặc polling table
Khi nhiều worker cùng quét một tập task pending, team có thể nghĩ tới lock để tránh hai worker cùng xử lý một task.
3. Protect critical section ở tầng ứng dụng
Ví dụ một user chỉ được redeem voucher một lần, hoặc một workflow không được generate invoice song song.
4. Leader election lightweight
Một số team dùng lock như cách chọn một node làm leader tạm thời cho background duty.
Những use case này có vẻ giống nhau, nhưng không phải lúc nào cũng cần cùng một control. Có lúc chỉ cần idempotency key. Có lúc chỉ cần SELECT ... FOR UPDATE SKIP LOCKED. Có lúc cần queue semantics đúng. Có lúc mới thực sự cần distributed lock độc lập.
Sai lầm đầu tiên: bài toán của anh có thể không phải là distributed locking
Đây là chỗ tôi thấy nhiều team đi lệch hướng nhất. Họ thấy race condition rồi áp ngay lock phân tán, trong khi vấn đề thật nằm ở một lớp khác.
Nếu bài toán là duplicate side effect
Thứ anh cần có thể là idempotency, không phải lock.
Ví dụ gửi email xác nhận thanh toán. Nếu request retry hoặc event replay, hệ thống cần khả năng nhận ra side effect này đã xảy ra. Một lock chỉ chặn đồng thời ở thời điểm hiện tại; nó không bảo vệ anh trước retry muộn, replay, network timeout sau khi downstream đã commit, hay process crash sau khi gửi email nhưng trước khi release lock.
Nếu bài toán là nhiều worker cùng claim row trong database
Thứ anh cần có thể là primitive của chính database như:
-
SELECT ... FOR UPDATE; -
FOR UPDATE SKIP LOCKED; - unique constraint + insert-if-not-exists;
- optimistic concurrency với version column.
Dùng DB primitive tại nơi dữ liệu sống thường đơn giản và đúng semantics hơn so với thêm một lock service bên ngoài.
Nếu bài toán là serializing update trên một aggregate
Thứ anh cần có thể là compare-and-swap, optimistic locking, hoặc state machine transition hợp lệ, thay vì global lock.
Nếu bài toán là partition ownership lâu dài
Thứ anh cần có thể là leader election hoặc consistent assignment, không phải lock key ngắn hạn.
Distributed lock hợp lý nhất khi anh thật sự cần coordination tạm thời giữa nhiều actor, nhưng không có primitive transaction/local lock nào đủ phủ đúng resource boundary.
Lock ownership không đồng nghĩa với write authority
Đây là điểm cực kỳ quan trọng.
Một process giữ lock không có nghĩa là mọi write nó tạo ra đều phải được hệ thống chấp nhận mãi mãi. Trong distributed system, holder có thể bị tạm ngắt mạng, pause GC lâu, clock drift, hoặc đơn giản là lease đã hết hạn mà nó không nhận ra.
Kịch bản kinh điển:
- Worker A acquire lock trong 15 giây.
- Worker A bị pause 20 giây do GC hoặc node bị treo ngắn.
- Lease hết hạn.
- Worker B acquire lock mới và bắt đầu xử lý.
- Worker A “sống lại”, tưởng mình vẫn hợp lệ và tiếp tục write xuống downstream.
Nếu downstream không có cơ chế phân biệt A là owner cũ hay owner mới, hệ thống vẫn có thể ghi dữ liệu sai, dù lock service đã hoạt động “đúng thiết kế”.
Đây là lý do nói về distributed lock mà không nói tới fencing token thì gần như chưa nói đủ.
Lease timeout giúp recovery, nhưng không tự tạo correctness
Hầu hết distributed lock production dùng mô hình lease:
- acquire lock với TTL hoặc expiration;
- holder phải renew định kỳ;
- nếu holder chết hoặc mất kết nối quá lâu, lease hết hạn và node khác có thể giành lock.
Mô hình này cần thiết vì lock vĩnh viễn là thảm họa. Nhưng lease chỉ giải quyết bài toán liveness: hệ thống có thể tiến lên khi holder chết. Nó không tự giải quyết safety: ngăn owner cũ tiếp tục gây side effect sau khi lease hết hạn.
Nếu team chỉ dừng ở “có TTL là an toàn rồi”, họ thường bỏ sót ba failure mode lớn:
- process pause lâu hơn TTL;
- network partition khiến holder tưởng renew thành công hoặc tưởng lock còn sống;
- downstream không biết request nào đến từ owner hợp lệ mới nhất.
Nói thẳng: TTL là điều kiện cần để tránh stale lock tồn tại mãi, nhưng không phải điều kiện đủ để tránh stale owner write sai.
Fencing token: lớp bảo vệ mà nhiều hệ thống lock thiếu hẳn
Fencing token là số thứ tự đơn điệu tăng mỗi lần lock được cấp cho một owner mới. Khi holder thao tác với downstream, nó phải mang token này theo. Downstream chỉ chấp nhận write từ token mới nhất, và từ chối token cũ hơn.
Ví dụ:
- Worker A acquire lock cho
invoice:123với token41. - Sau khi lease hết hạn, Worker B acquire cùng lock với token
42. - Nếu Worker A tỉnh lại và cố ghi tiếp, downstream thấy token
41 < 42và từ chối.
Điểm hay ở đây là correctness không còn phụ thuộc hoàn toàn vào cảm nhận của holder về việc “mình còn giữ lock hay không”. Quyền write được xác nhận tại nơi side effect xảy ra.
Không có fencing token, một distributed lock lease-based rất dễ tạo ra race “old owner comes back from the dead”.
Redis lock không phải thần chú chống race condition
Redis là lựa chọn phổ biến vì nhanh và đơn giản: SET key value NX PX ttl. Với nhiều use case low-risk, cách này đủ thực dụng. Nhưng cần nhìn rõ giới hạn của nó.
Redis lock làm tốt điều gì?
- coordination nhẹ cho cron/scheduler;
- guard chống chạy trùng ngắn hạn;
- giảm khả năng hai worker vào cùng critical section cùng lúc;
- tốc độ cao, setup dễ.
Redis lock làm chưa đủ ở đâu?
- không tự cung cấp fencing token chuẩn trừ khi anh tự thiết kế thêm;
- TTL phụ thuộc timing và renew discipline;
- nếu downstream không kiểm fencing, stale owner vẫn có thể write;
- nếu Redis failover hoặc replication behavior không được hiểu kỹ, lock semantics có thể lệch kỳ vọng.
Redis lock không vô dụng. Chỉ là nó phù hợp nhất với use case có blast radius vừa phải và có thêm guard khác như idempotency, unique constraint, conditional write hoặc downstream verification.
Redlock gây tranh luận vì lý do gì?
Khi đọc về distributed locks với Redis, nhiều người sẽ gặp Redlock. Ý tưởng của nó là acquire lock trên nhiều Redis master độc lập để giảm nguy cơ lock sai do một node đơn lẻ lỗi.
Tranh luận lớn quanh Redlock không phải vì nó vô nghĩa, mà vì nhiều người vô tình dùng nó như thể nó mang lại strong correctness guarantee cho mọi tình huống. Thực tế guarantee của một lock protocol phụ thuộc vào:
- model failure anh chấp nhận;
- timing assumption;
- behavior của network partition;
- downstream có fencing hay conditional write hay không.
Nếu workload của anh thật sự có business-critical correctness cao — ví dụ ledger, payment settlement, inventory hard reservation — thì chỉ “đã dùng Redis/Redlock” chưa đủ để kết luận an toàn. Anh cần chứng minh end-to-end semantics, không chỉ semantics của việc acquire key.
ZooKeeper/etcd/Consul cho lock mạnh hơn ở điểm nào?
Những hệ như ZooKeeper hoặc etcd được dùng nhiều cho coordination vì chúng cung cấp semantics phù hợp hơn cho consensus-backed metadata:
- ephemeral session;
- ordered sequence nodes hoặc revision numbers;
- watch/lease rõ ràng hơn;
- dễ xây leader election và fencing token hơn.
Tuy nhiên, dùng chúng không có nghĩa correctness tự động rơi từ trên trời xuống.
Anh vẫn phải trả lời:
- downstream dùng gì để từ chối stale owner;
- timeout và session expiry được chọn theo latency/GC/profile nào;
- khi watch bị delay hoặc reconnect, business logic có an toàn không;
- nếu node giữ lock bị stalled nhưng chưa mất session ngay, impact là gì.
Consensus store cho coordination layer tốt hơn Redis ở nhiều bài toán, nhưng vẫn không thay thế cho idempotency, version check hay state transition guard ở resource layer.
Database row lock và advisory lock: đôi khi đơn giản hơn, đúng hơn
Rất nhiều backend team có xu hướng thêm Redis lock trước cả khi tự hỏi database hiện tại đã đủ primitive chưa.
Nếu mọi dữ liệu liên quan đều nằm trong cùng một relational database, dùng native primitive thường rõ nghĩa hơn:
SELECT ... FOR UPDATE
Phù hợp khi critical section gắn chặt với một row hoặc tập row trong cùng transaction.
FOR UPDATE SKIP LOCKED
Rất mạnh cho work queue trong DB: nhiều worker có thể claim batch task mà không đạp nhau.
Unique constraint
Nhiều bài toán “chỉ cho một lần” thực ra giải được bằng unique index trên business key.
Advisory lock
Một số DB như PostgreSQL hỗ trợ advisory lock theo application-defined key, hữu ích cho coordination scoped trong DB world.
Ưu điểm của DB-native approach là lock và dữ liệu nằm gần nhau. Anh giảm nguy cơ split-brain giữa “nơi giữ lock” và “nơi commit state”. Nhược điểm là phạm vi chỉ đẹp khi resource boundary thực sự sống trong DB đó.
Distributed lock và idempotency không thay thế nhau
Một ngộ nhận phổ biến là “đã có lock thì không cần idempotency nữa”. Tôi thấy ngược lại: hệ thống càng dùng distributed lock cho side effect quan trọng, càng cần idempotency.
Vì sao?
- lock chỉ bảo vệ cạnh tranh đồng thời trong một cửa sổ;
- idempotency bảo vệ retry, replay, duplicate delivery, timeout ambiguity;
- lock holder có thể crash sau khi side effect đã xảy ra nhưng trước khi commit local state;
- owner mới có thể tiếp quản và lặp lại thao tác nếu không có idempotent guard.
Ví dụ job gửi payout file cho ngân hàng:
- distributed lock giúp chỉ một worker active tại thời điểm đó;
- idempotency key giúp nếu worker retry upload hoặc re-run workflow, downstream vẫn nhận ra đây là cùng một logical operation.
Nếu phải chọn một trong hai cho correctness của side effect, rất thường tôi sẽ ưu tiên idempotency trước lock.
Leader election khác gì distributed lock?
Hai khái niệm này gần nhau nhưng không giống nhau.
Distributed lock
- thường scoped theo resource cụ thể;
- lifetime ngắn hơn;
- dùng cho critical section hoặc temporary ownership.
Leader election
- chọn một node điều phối cho cả một responsibility lớn hơn;
- lifetime có thể dài hơn;
- thường đi kèm session, heartbeat, failover semantics rõ.
Nhiều team dùng một lock key kiểu job-runner-leader để giả leader election. Việc này có thể đủ cho hệ thống nhỏ, nhưng khi nhiệm vụ của “leader” ngày càng nhiều, nên gọi đúng tên bài toán và dùng primitive đúng hơn.
Lock granularity: quá thô thì nghẽn, quá mịn thì khó giữ đúng
Chọn granularity của lock là một tradeoff thiết kế thật sự.
Lock quá thô
Ví dụ một key billing-global-lock cho toàn bộ hệ thống. Ưu điểm là dễ hiểu. Nhược điểm là throughput tệ, blast radius lớn, và chỉ một tenant chậm cũng có thể kìm toàn bộ workload.
Lock quá mịn
Ví dụ lock từng invoice, từng line item, từng bước workflow. Ưu điểm là concurrency tốt hơn. Nhược điểm là:
- khó reasoning hơn;
- dễ deadlock nếu phải acquire nhiều key;
- khó quan sát contention pattern;
- tăng complexity ở renewal và cleanup.
Granularity tốt thường đi theo business ownership tự nhiên: theo tenant, aggregate root, shard, hoặc logical partition. Đừng lock rộng hơn phạm vi side effect thực sự cần serial hoá.
Renewal logic thường là chỗ bug production ẩn rất sâu
Nhiều team chú ý acquire lock nhưng xem nhẹ renewal. Trong thực tế, renewal loop quyết định phần lớn failure behavior.
Các câu hỏi đáng hỏi:
- renew trước khi TTL còn bao lâu;
- nếu renew fail 1 lần thì có retry bao nhiêu lần;
- trong thời gian uncertainty có được tiếp tục xử lý không;
- thread renew và thread business logic có thể bị pause độc lập không;
- nếu event loop nghẽn hoặc GC pause, holder có tự stop side effect không.
Một lock implementation tốt không chỉ có acquire() và release(). Nó cần một state machine rõ cho:
- acquired;
- renewing;
- lease-lost;
- uncertain;
- released.
Và khi vào trạng thái lease-lost hoặc uncertain, business logic phải biết mình cần fail fast / stop work / avoid commit chứ không tiếp tục chạy như chưa có gì xảy ra.
Split brain và stale owner là hai nỗi sợ đáng thật
Split brain
Hai actor cùng tin rằng mình là owner hợp lệ.
Điều này có thể xảy ra khi:
- network partition làm thông tin lease lệch giữa các phía;
- failover của lock store không đồng bộ như anh nghĩ;
- session timeout và clock assumption không phù hợp;
- client library xử lý reconnect hời hợt.
Stale owner
Owner cũ không còn hợp lệ nhưng vẫn tiếp tục side effect. Đây là failure mode hay gặp hơn ngoài đời và cực kỳ khó debug vì log có thể trông “bình thường”.
Cả hai vấn đề này đều cho thấy một bài học giống nhau: lock service chỉ là một phần của control plane. Correctness phải được bảo vệ thêm ở data plane bằng fencing, version check, idempotency hoặc conditional write.
Observability cho distributed lock: đừng chỉ đo số lần acquire thành công
Nếu metric duy nhất của anh là “lock acquire success”, anh gần như đang mù.
Những tín hiệu nên có:
Lock contention
- acquire latency;
- số lần thất bại vì lock đang bị giữ;
- queue time chờ lock;
- resource/key nào contention cao nhất.
Lease health
- renewal success rate;
- renewal jitter;
- số lần lease-lost;
- số lần vào trạng thái uncertain.
Correctness signals
- duplicate processing detected;
- stale token rejected bởi downstream;
- số operation bị từ chối do fencing token cũ;
- số lần unique constraint cứu hệ thống khỏi double write.
Business impact
- job bị delay vì contention;
- throughput giảm do lock quá thô;
- tenant nào bị kẹt lâu nhất;
- số workflow phải replay thủ công sau lock incident.
Một lock system trưởng thành không chỉ nói “đã acquire được” mà còn phải cho thấy “đang contention ở đâu, lease có ổn không, downstream có reject stale owner không”.
Một decision framework thực dụng: khi nào nên dùng distributed lock?
Tôi thường đi qua chuỗi câu hỏi sau.
- Có thể giải bằng idempotency hoặc unique constraint không?
- Nếu có, ưu tiên lớp này trước.
- Resource nằm gọn trong một database transaction boundary không?
- Nếu có, ưu tiên DB primitive như row lock, SKIP LOCKED, compare-and-swap.
- Mình cần ngăn cạnh tranh đồng thời hay cần authority lâu dài?
- Nếu authority lâu dài, có thể leader election/partition assignment phù hợp hơn.
- Nếu stale owner quay lại, downstream có cách từ chối nó không?
- Nếu không, lock hiện tại chưa đủ an toàn cho side effect quan trọng.
- Business có chịu được duplicate nhẹ và có guard khắc phục không?
- Nếu có, Redis lease đơn giản có thể đủ.
- Workload có correctness cao như payment, ledger, inventory cứng không?
- Nếu có, cần thiết kế end-to-end với fencing/idempotency/conditional write rõ ràng, không chỉ acquire lock rồi tin tưởng.
Ví dụ pseudo-code với fencing token
async function processInvoice(invoiceId: string) {
const lock = await lockService.acquire(`invoice:${invoiceId}`, { ttlMs: 15000 })
if (!lock) return
try {
const invoice = await repo.get(invoiceId)
if (invoice.status !== 'READY') return
await payoutGateway.sendFile({
invoiceId,
amount: invoice.amount,
fencingToken: lock.token,
idempotencyKey: `payout:${invoiceId}`,
})
await repo.markProcessed(invoiceId, {
expectedLockToken: lock.token,
})
} finally {
await lock.release().catch(() => {})
}
}
Điều quan trọng trong ví dụ trên không phải dòng acquire lock. Điều quan trọng là:
- downstream nhận
fencingToken; - có
idempotencyKeycho side effect; - commit local state có
expectedLockTokenhoặc conditional update tương ứng.
Nếu chỉ có acquire() mà không có ba lớp sau, hệ thống vẫn có thể xử lý lặp hoặc ghi sai trạng thái.
Những anti-pattern tôi rất ngại khi audit production
1. Global Redis lock cho mọi thứ
Một key khóa cả hệ thống nghe đơn giản nhưng thường biến thành bottleneck và che đi thiết kế ownership tệ.
2. Lock nhưng không có TTL
Chỉ cần process chết giữa chừng là hệ thống tự làm tê liệt chính nó.
3. Có TTL nhưng không có fencing hoặc downstream verification
Đây là kiểu “trông hiện đại nhưng vẫn nguy hiểm”.
4. Tin lock hơn idempotency
Khi network và retry bắt đầu gây ambiguity, lock một mình không cứu được anh.
5. Release lock bằng cách xóa key mà không verify owner value
Nếu client A hết lease rồi client B acquire key mới, client A mà xóa key mù quáng có thể vô tình xóa lock của B. Đây là bug rất classic.
6. Dùng lock để vá domain model chưa rõ ownership
Nếu nhiều service cùng phải giành quyền sửa cùng một khối state mà ownership domain không rõ, lock chỉ làm vấn đề khó nhìn hơn.
Kết luận
Distributed lock là công cụ hữu ích, nhưng chỉ khi được đặt đúng chỗ trong hệ thống. Nó phù hợp để điều phối ownership tạm thời, giảm cạnh tranh đồng thời và hỗ trợ scheduler/worker coordination. Nó không tự động đảm bảo correctness của side effect, không thay thế idempotency, và không xoá được nhu cầu conditional write hoặc fencing ở downstream.
Điều tôi thấy quan trọng nhất là đổi cách nghĩ: đừng hỏi “làm sao để chỉ một process giữ lock”. Hãy hỏi “nếu owner cũ quay lại hoặc request bị retry, hệ thống vẫn bảo toàn đúng đắn bằng cách nào?”. Khi trả lời được câu đó, anh mới đang thiết kế production control thực sự, thay vì chỉ dựng một cánh cổng tượng trưng.
Nếu phải ưu tiên ba việc đầu tiên khi đưa distributed lock vào production, tôi sẽ làm như sau:
- chọn đúng bài toán: lock hay thực ra cần idempotency/DB primitive;
- đảm bảo lease có TTL, renewal state rõ và owner-aware release;
- thêm fencing token hoặc conditional write ở nơi side effect xảy ra.
<section class="internal-links"> <h2>Đọc tiếp về backend reliability và system design</h2> <ul> <li><a href="/transactional-outbox-event-driven-consistency/">Transactional Outbox trong Backend</a></li> <li><a href="/dead-letter-queue-event-driven-systems-production/">Dead Letter Queue trong Event-Driven Systems</a></li> <li><a href="/circuit-breaker-backend-production/">Circuit Breaker trong Backend Production</a></li> <li><a href="/read-after-write-consistency-replica-lag-production/">Read-after-write consistency trong production</a></li> <li><a href="/contract-testing-microservices-production/">Contract Testing cho Microservices</a></li> <li><a href="/schema-drift-event-driven-systems-production/">Schema Drift trong Event-Driven Systems</a></li> </ul> </section>