Danh sách bài viết

Bài 42: Distributed Lock — Tại Sao Cần Lock Phân Tán

Khi ứng dụng chạy trên một process duy nhất, threading.Lock hay mutex đủ để đảm bảo chỉ một thread truy cập tài nguyên cùng lúc. Nhưng khi app scale ra nhiều instance trên nhiều máy, in-process lock hoàn toàn vô dụng — mỗi process có lock riêng, không biết gì về nhau. Bài này phân tích bài toán distributed lock từ gốc: vì sao nó cần thiết, các use case thực tế (cron dedup, double payment, inventory oversell, leader election), 5 yêu cầu một distributed lock phải đáp ứng, vì sao Redis là lựa chọn phổ biến, và quan trọng nhất — phân biệt efficiency lock vs correctness lock để biết khi nào Redis lock đơn giản là đủ và khi nào cần phức tạp hơn. Bài cũng cảnh báo rõ khi nào nên tránh dùng lock hoàn toàn.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

  • Giải thích vì sao in-process lock (threading.Lock, mutex) không hoạt động trong hệ thống multi-instance.
  • Nhận diện các use case thực tế cần distributed lock: cron dedup, double payment, inventory oversell, leader election.
  • So sánh in-process lock và distributed lock trên các chiều: phạm vi, tốc độ, failure handling.
  • Nắm 5 yêu cầu cốt lõi mà một distributed lock phải đáp ứng.
  • Phân biệt efficiency lock và correctness lock — hiểu khi nào Redis lock đơn giản là đủ và khi nào chưa đủ.
  • Biết khi nào nên tránh lock hoàn toàn bằng atomic operation hoặc idempotency key.
2

Bài Toán: In-process Lock Không Đủ

Trong một process duy nhất, đồng bộ hóa thread là bài toán quen thuộc. Python có threading.Lock(), Java có synchronized, Go có sync.Mutex. Tất cả đều hoạt động tốt vì tất cả thread chia sẻ cùng một vùng nhớ — lock nằm trong RAM của process đó, và mọi thread trong process đều thấy cùng trạng thái lock.

# In-process lock — CHỈ work trong 1 process
import threading

lock = threading.Lock()

def process_order(order_id):
    with lock:           # Thread 2 phải chờ Thread 1 xong
        # Chỉ 1 thread chạy đoạn này cùng lúc
        charge_payment(order_id)
        update_inventory(order_id)

Vấn đề xuất hiện khi app scale ra nhiều instance. Trong production, ứng dụng thường chạy trên ít nhất 2-3 pod (Kubernetes), hay nhiều process trên nhiều máy. Khi đó:

  • Instance A có lock_A trong RAM của nó.
  • Instance B có lock_B trong RAM của nó.
  • lock_Alock_B hoàn toàn độc lập — không biết gì về nhau.
  • Instance A acquire lock_A thành công → nghĩ mình đang giữ lock.
  • Instance B acquire lock_B thành công → cũng nghĩ mình đang giữ lock.
  • Cả hai chạy song song vào critical section → race condition.
Instance A (pod-1)          Instance B (pod-2)
─────────────────           ─────────────────
lock_A.acquire()  ✓         lock_B.acquire()  ✓
  # Cả 2 cùng nghĩ          # mình có lock
  charge_payment()    <-- chạy đồng thời -->  charge_payment()
  # → double charge!

Để giải quyết vấn đề này cần một cơ chế lock bên ngoài tất cả các process — một kho lưu trữ mà mọi instance đều kết nối vào và đồng thuận về trạng thái lock. Đó là distributed lock.

Distributed lock là một lock được lưu trong một hệ thống chia sẻ (thường là Redis, etcd, hoặc ZooKeeper) mà mọi instance trong hệ thống đều có thể đọc và ghi. Chỉ instance nào thành công ghi "tôi đang giữ lock" vào hệ thống chia sẻ đó mới được tiến vào critical section.

3

Use Cases Cần Distributed Lock

Cron deduplication

Một hệ thống chạy 5 instance, mỗi instance có scheduler chạy cùng một cron job mỗi phút (vd gửi email digest, chạy batch report). Nếu không có coordination, job chạy 5 lần — 5 email gửi đến user, 5 lần batch report tốn tài nguyên. Với distributed lock, chỉ instance nào acquire được lock mới chạy job đó; 4 instance còn lại thấy lock đã bị chiếm và bỏ qua.

Chống double payment

User bấm "Thanh toán" hai lần nhanh, hoặc hai request đến hai instance khác nhau gần như đồng thời. Cả hai đều đọc order trạng thái "pending" từ database, cả hai đều gọi payment gateway và charge thẻ. Kết quả: user bị charge 2 lần. Distributed lock per order_id ngăn instance thứ 2 xử lý trong khi instance thứ 1 đang làm việc.

Inventory oversell

Còn đúng 1 sản phẩm. Hai user mua đồng thời, hai request đến hai instance khác nhau. Cả hai đọc quantity = 1, cả hai thấy còn hàng, cả hai trừ xuống → quantity = -1 (hoặc 0 nhưng cả hai order đều "thành công"). Distributed lock per product_id đảm bảo chỉ 1 request xử lý trong vùng kiểm tra + trừ tồn kho.

Resource exclusive access

Chỉ một worker được phép ghi vào một file nhất định, hoặc gọi một external API có giới hạn concurrent connection. Distributed lock cấp quyền truy cập độc quyền tạm thời cho một worker.

Leader election

Trong một cluster nhiều instance, đôi khi cần chính xác một instance làm "leader" thực hiện tác vụ đặc biệt (vd primary replica trong database cluster, instance duy nhất xử lý message từ queue không hỗ trợ consumer group). Distributed lock là cơ chế đơn giản để elect leader: instance nào acquire được lock thì là leader; khi lock expire hoặc bị release, cuộc bầu mới diễn ra.

Tóm lại pattern chung

Cả năm use case trên đều có chung cấu trúc:

  1. Có một tài nguyên hoặc operation mà tối đa một entity được xử lý cùng lúc.
  2. Nhiều instance/process có thể cố gắng xử lý nó cùng lúc.
  3. Cần cơ chế đảm bảo mutual exclusion (loại trừ lẫn nhau) giữa các instance.
4

In-process Lock vs Distributed Lock

Đặc điểm In-process (threading.Lock) Distributed (Redis)
Phạm vi 1 process duy nhất Nhiều process, nhiều máy
Tốc độ acquire Nanosecond (RAM local) Network round-trip (~0.5–2 ms)
Khi process chết Lock tự mất — OK, OS cleanup Cần TTL để lock tự expire, tránh deadlock
Khi network partition Không có vấn đề Lock có thể bị giữ quá lâu hoặc mất sớm
Stale lock Không xảy ra Có thể xảy ra nếu TTL quá ngắn hoặc client hang
Độ phức tạp Thấp — OS primitives Cao — cần handle expire, ownership, fencing
Use case Thread trong 1 app Multi-instance, multi-host

Distributed lock có overhead mạng và cần handle nhiều failure mode hơn in-process lock. Đây là chi phí không thể tránh khỏi khi hệ thống distributed. Hiểu rõ sự đánh đổi này trước khi quyết định dùng.

5

5 Yêu Cầu Của Distributed Lock

Để một distributed lock hoạt động đúng trong hệ thống production, nó cần đáp ứng 5 yêu cầu sau:

1. Mutual exclusion (loại trừ lẫn nhau)

Tại bất kỳ thời điểm nào, tối đa một client được phép giữ lock. Đây là yêu cầu cốt lõi — nếu vi phạm, mọi đảm bảo khác đều vô nghĩa.

2. Deadlock-free (không deadlock)

Nếu client đang giữ lock bị crash, network bị ngắt, hay process bị kill, lock phải tự được release sau một khoảng thời gian. Cơ chế thực hiện là TTL (Time To Live) trên key Redis — khi TTL expire, key tự xóa và lock được giải phóng. Không có TTL → nếu client chết → lock bị giữ mãi mãi → deadlock.

3. Fault tolerance (chịu lỗi)

Lock cần tiếp tục hoạt động đúng khi một phần hệ thống fail. Với Redis single-node, nếu Redis node chết thì lock service cũng chết — đây là single point of failure. Đây là lý do Redlock (bài 47) ra đời: dùng quorum trên nhiều Redis node để tăng fault tolerance.

4. Safety (an toàn)

Không có hai client nào đồng thời tin rằng mình đang giữ lock. Yêu cầu này khó hơn mutual exclusion một chút: ngay cả khi TTL đã expire, client cũ không được tiếp tục hành động như thể mình vẫn giữ lock. Đây là bài toán fencing token (bài 46) giải quyết.

5. Liveness (tiến triển)

Lock cuối cùng phải được release — hệ thống phải tiến triển được. Điều này đảm bảo rằng nếu mọi client hiện tại release hoặc fail, lock không bị giữ mãi và client mới có thể acquire.

Một distributed lock đơn giản với Redis SET NX EX (bài 43) đáp ứng được 1, 2 và 5 trong điều kiện thông thường. Yêu cầu 3 và 4 đòi hỏi cơ chế phức tạp hơn — fencing token và Redlock — được phân tích sâu ở các bài sau.

6

Vì Sao Redis Cho Distributed Lock

Redis là lựa chọn phổ biến nhất để implement distributed lock vì một số tính chất phù hợp:

  • Atomic operations: SET key value NX EX seconds là atomic — set key chỉ khi chưa tồn tại (NX) và set TTL trong cùng một command. Không có race condition giữa "check tồn tại" và "ghi key".
  • Tốc độ: in-memory, sub-millisecond latency. Lock acquire/release đủ nhanh để không trở thành bottleneck.
  • TTL tự động: Redis native TTL đảm bảo lock tự expire nếu client chết — deadlock prevention có sẵn.
  • Lua scripting: các thao tác phức tạp hơn (vd unlock chỉ khi mình là owner) có thể implement bằng Lua script chạy atomic trên Redis.
  • Phổ biến và sẵn có: hầu hết hệ thống đã có Redis. Không cần thêm infrastructure (etcd, ZooKeeper) chỉ để làm lock.

Cảnh báo quan trọng: Redis distributed lock không phải giải pháp hoàn hảo cho mọi use case. Cụ thể với các bài toán đòi hỏi correctness tuyệt đối (không được sai data dù bất cứ điều gì xảy ra), Redis lock đơn giản chưa đủ. Debate giữa Martin Kleppmann và Antirez (tác giả Redis) về Redlock — bài 47 — đi sâu vào chính xác điểm này. Trước khi dùng distributed lock với Redis, cần hiểu rõ giới hạn của nó.

7

Efficiency Lock vs Correctness Lock

Martin Kleppmann (trong bài blog "How to do distributed locking", 2016) phân biệt hai mục đích rất khác nhau của distributed lock. Đây là phân biệt quan trọng nhất để chọn đúng cơ chế:

Efficiency lock (lock hiệu quả)

Mục đích: tránh làm việc thừa, giảm lãng phí tài nguyên. Nếu lock fail và hai worker cùng thực hiện operation, kết quả không sai — chỉ tốn thêm compute.

Ví dụ điển hình: tránh hai worker cùng tính lại và ghi vào cache cùng một key. Nếu cả hai tính và ghi, không ai bị double-charge, không ai mất tiền — chỉ tốn thêm CPU và một lần ghi cache thừa. Redis lock đơn giản là đủ cho loại này.

# Efficiency lock: tránh stampede khi cache miss
# Nếu lock fail → 2 worker cùng rebuild cache → chỉ tốn compute thừa
# Không có hậu quả nghiêm trọng

def get_expensive_report(report_id):
    cached = redis.get(f"report:{report_id}")
    if cached:
        return json.loads(cached)

    lock_key = f"lock:report:{report_id}"
    acquired = redis.set(lock_key, "1", nx=True, ex=30)  # TTL 30s

    if acquired:
        try:
            result = compute_expensive_report(report_id)
            redis.setex(f"report:{report_id}", 300, json.dumps(result))
            return result
        finally:
            redis.delete(lock_key)
    else:
        # Chờ worker khác xong rồi đọc cache
        time.sleep(0.1)
        return get_expensive_report(report_id)  # retry

Correctness lock (lock đúng đắn)

Mục đích: đảm bảo tính đúng đắn của dữ liệu. Nếu lock fail và hai worker cùng thực hiện operation, kết quả SAI DATA nghiêm trọng.

Ví dụ: double payment. Nếu hai instance cùng charge thẻ, user bị mất tiền oan. Redis lock đơn giản không đủ an toàn cho loại này trong mọi failure scenario. Cần fencing token (bài 46) để đảm bảo rằng ngay cả khi lock expire và bị giành lại bởi instance khác, thao tác của instance cũ không được chấp nhận.

Correctness lock — failure scenario nguy hiểm:

1. Instance A acquire lock, TTL = 30s
2. Instance A bắt đầu charge payment
3. GC pause / network lag → Instance A bị block 35s
4. TTL expire → lock bị release tự động
5. Instance B acquire lock (lock trống rồi)
6. Instance B charge payment → lần 1
7. Instance A "tỉnh dậy" → tiếp tục charge → lần 2
   (Instance A vẫn nghĩ mình giữ lock, không hay biết TTL đã hết)
→ double charge

Fencing token giải quyết bằng cách: mỗi lần lock được acquire, server cấp một token tăng dần (vd monotonic counter). Khi ghi vào resource, request phải kèm token. Resource (database, external service) reject request với token cũ hơn token hiện tại đã thấy. Như vậy request muộn của Instance A bị reject dù Instance A nghĩ mình vẫn giữ lock.

Quyết định thực tế

Loại lock Lock fail → hậu quả Cơ chế cần thiết
Efficiency Tốn compute thừa, không sai data Redis SET NX EX là đủ
Correctness Sai data, mất tiền, double action Cần fencing token + resource validation

Trước khi implement lock, xác định rõ use case thuộc loại nào. Phần lớn cron dedup, cache stampede prevention là efficiency lock. Payment, inventory deduction, state machine transition là correctness lock.

8

Khi Nào Nên Tránh Lock

Distributed lock phức tạp và có nhiều edge case tinh tế. Trước khi dùng lock, hỏi: có cách nào không cần lock không? Thường xuyên có.

Dùng atomic operation thay lock

Nếu operation có thể thực hiện bằng một lệnh Redis atomic (hoặc Lua script atomic), không cần lock. Ví dụ:

  • Trừ tồn kho: DECR hay Lua check-and-decrement atomic — không cần lock.
  • Counter rate limit: INCR + EXPIRE trong Lua — không cần lock (xem Module 3).
  • Thêm vào set unique: SADD — không cần lock.
  • Conditional set chỉ khi chưa tồn tại: SET NX — đây chính là atomic operation, không cần lock wrapper.
# Không cần lock — Lua atomic check-and-decrement
LUA_DECREMENT = """
local qty = redis.call('GET', KEYS[1])
if qty == false or tonumber(qty) <= 0 then
    return 0
end
redis.call('DECR', KEYS[1])
return 1
"""

def try_reserve_item(product_id):
    result = redis.eval(LUA_DECREMENT, 1, f"inventory:{product_id}")
    return result == 1  # 1 = thành công, 0 = hết hàng

Dùng idempotency key thay lock

Nếu operation là idempotent (chạy nhiều lần nhưng effect chỉ 1 lần), dùng idempotency key thay lock. Ví dụ: gán một idempotency_key duy nhất cho mỗi payment request. Database có unique constraint trên key đó. Nếu hai request đến cùng lúc với cùng key, chỉ một insert thành công, cái còn lại bị reject bởi constraint.

Cách này robust hơn lock trong nhiều trường hợp: không cần TTL, không cần cleanup, không có race condition giữa check và write. Bài 48 đi sâu vào pattern này.

Dùng database row lock / transaction

Nếu toàn bộ critical section chỉ đụng vào một database duy nhất, SELECT ... FOR UPDATE (row-level lock trong PostgreSQL/MySQL) hay optimistic locking với version column đôi khi đủ và đơn giản hơn nhiều. Distributed lock chỉ cần thiết khi operation span nhiều system (Redis + DB + external API).

Single-writer design

Một số hệ thống giải quyết vấn đề bằng cách đảm bảo chỉ có một writer cho một loại data (vd một service duy nhất ghi vào bảng orders, không có write path khác). Khi đó không cần distributed lock dù có nhiều instance của service đó — queue/message broker serialize write requests.

Tóm lại thứ tự ưu tiên

  1. Atomic Redis operation hoặc Lua script.
  2. Idempotency key.
  3. Database row lock / transaction (nếu single DB).
  4. Single-writer design qua queue.
  5. Distributed lock — khi các cách trên không áp dụng được.

Lock là phương án cuối, không phải đầu tiên.

9

Khi Nào Cần Lock

Sau khi đã loại trừ các lựa chọn đơn giản hơn, distributed lock phù hợp khi:

  • Operation đa bước không thể atomic: chuỗi thao tác gồm nhiều bước (đọc state → tính toán → ghi kết quả) mà không thể gói gọn trong một Lua script hoặc một DB transaction — vì quá phức tạp, có I/O bên ngoài, hoặc span nhiều hệ thống.
  • Cross-resource coordination: operation cần đồng thời đụng vào Redis, database, và external API (vd payment gateway). Không có một transaction boundary chung nào bao phủ tất cả. Lock là cơ chế serialization thủ công.
  • Leader election: cần elect một instance làm leader trong cluster. Lock là primitive đơn giản nhất để implement election — instance acquire được lock là leader, lock expire thì election lại.
  • Singleton task / cron dedup: đảm bảo chỉ một instance chạy job nhất định tại một thời điểm, trong hệ thống không có cơ chế scheduler tập trung.
10

Preview Module 4

Module 4 đi từ implementation đơn giản nhất đến phức tạp nhất, theo thứ tự tăng dần của các vấn đề cần giải quyết:

  • Bài 43: SET NX EX — cách implement lock cơ bản và bẫy đầu tiên (unlock nhầm của người khác).
  • Bài 44: Lock expiration problem — điều gì xảy ra khi TTL hết trước khi critical section xong.
  • Bài 45: Unlock an toàn — Lua script kiểm tra ownership trước khi delete key.
  • Bài 46: Fencing token — vì sao lock ownership thôi chưa đủ với correctness lock.
  • Bài 47: Redlock — thuật toán multi-node lock, tranh luận Kleppmann vs Antirez.
  • Bài 48: Idempotency key — chống double payment không dùng lock.
  • Bài 49: Leader election với Redis.
  • Bài 50: Distributed semaphore — giới hạn concurrency toàn hệ thống.
  • Bài 51: Singleton worker và cron deduplication.

Incident Preview

Module 4 kết bằng một incident thực tế: lock được set với TTL 30 giây, nhưng job xử lý mất 45 giây (database query chậm bất ngờ). Ở giây thứ 30, lock expire và instance thứ hai acquire lock thành công — bắt đầu charge payment. Ở giây thứ 45, instance đầu tiên hoàn thành và cũng charge. Kết quả: double charge. Fencing token là câu trả lời — không phải tăng TTL.

11

Tổng Kết & Quiz

Tổng kết

  • In-process lock (threading.Lock, mutex) chỉ work trong một process duy nhất. Khi app scale multi-instance, mỗi process có lock riêng, không biết về nhau → race condition.
  • Distributed lock lưu trạng thái lock trong hệ thống chia sẻ (Redis) để mọi instance đều thấy cùng trạng thái.
  • Các use case điển hình: cron dedup, chống double payment, inventory oversell, resource exclusive access, leader election.
  • 5 yêu cầu: mutual exclusion, deadlock-free (TTL), fault tolerance, safety, liveness.
  • Efficiency lock: lock fail → chỉ tốn compute thừa, không sai data. Redis SET NX EX là đủ.
  • Correctness lock: lock fail → sai data nghiêm trọng. Cần fencing token + resource-side validation.
  • Ưu tiên tránh lock: dùng atomic operation, idempotency key, DB row lock, single-writer design trước. Lock là phương án cuối.
  • Lock cần thiết khi operation đa bước không thể atomic, cross-resource, hoặc cần singleton execution.

Quiz 5 câu

  1. Giải thích cụ thể tại sao threading.Lock() trong Python không ngăn được race condition khi cùng code chạy trên 2 pod Kubernetes khác nhau.
  2. Nếu không đặt TTL cho distributed lock và client đang giữ lock bị crash, điều gì xảy ra? Đây được gọi là vấn đề gì?
  3. Phân biệt efficiency lock và correctness lock bằng một ví dụ cụ thể cho mỗi loại.
  4. Bạn có inventory counter trong Redis. Cần đảm bảo không oversell khi 1000 request đến đồng thời. Bạn sẽ chọn distributed lock hay Lua atomic script? Vì sao?
  5. Một payment service cần charge user và update order status trong cùng một flow. DB là PostgreSQL, payment gateway là Stripe. Atomic Redis operation có đủ không? Tại sao?

Đáp án gợi ý

  1. threading.Lock() là object trong RAM của Python process. Pod A có lock_A trong RAM của nó; Pod B có lock_B trong RAM của nó. Khi Pod A acquire lock_A, Pod B không biết gì về lock_A — nó chỉ thấy lock_B của nó là free. Cả hai cùng "acquire thành công" lock riêng của mình và đều tiến vào critical section.
  2. Lock không bao giờ được release. Mọi client sau đó cố acquire lock đều bị block vĩnh viễn vì key vẫn tồn tại trong Redis. Đây là deadlock — hệ thống bị đình trệ hoàn toàn cho tài nguyên đó.
  3. Efficiency: tránh hai worker cùng rebuild cache cho cùng key. Nếu cả hai rebuild, chỉ tốn CPU thừa, không sai data. Correctness: đảm bảo không double-charge payment. Nếu hai instance cùng charge, user mất tiền oan — sai data nghiêm trọng.
  4. Lua atomic script. Lý do: check-and-decrement (kiểm tra còn hàng rồi mới trừ) có thể gói gọn trong Lua script chạy atomic trên Redis. Không cần round-trip acquire lock → critical section → release lock. Lua script đơn giản hơn, ít latency hơn, không có failure mode của lock (expire, ownership).
  5. Không đủ. Lý do: operation span hai hệ thống khác nhau (PostgreSQL và Stripe). Không có transaction boundary chung. Redis atomic operation chỉ đảm bảo atomicity trong Redis, không cover Stripe call hay PostgreSQL write. Đây là use case điển hình cần distributed lock (hoặc idempotency key cho Stripe + DB transaction cho PostgreSQL).

Bài tiếp theo

Bài 43 implement distributed lock đầu tiên bằng SET NX EX — cú pháp, luồng acquire/release, và bẫy hay gặp nhất: unlock nhầm lock của client khác.

Tham khảo