Mục lục
- Mục Tiêu Bài Học
- Nhắc Lại Bẫy Từ Bài 43
- Sai Lầm #1 — DEL Trực Tiếp
- Sai Lầm #2 — GET Check Rồi DEL (Không Atomic)
- Fix — Lua Atomic Check-And-Delete
- Token Phải Đủ Unique & Unguessable
- Code Đầy Đủ Python
- Context Manager An Toàn
- Ý Nghĩa Của Return Value
- Tại Sao Lua, Không MULTI/EXEC
- Kết Hợp Với Watchdog (Bài 44)
- Library Có Sẵn
- Giới Hạn Còn Lại
- Anti-patterns & Best Practices
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Hiểu tại sao
DELtrực tiếp phá mutual exclusion. - Nhận ra race condition trong GET-then-DEL dù đã check owner.
- Viết được Lua script atomic check-and-delete đúng cách.
- Implement
acquire_lock/release_lockhoàn chỉnh với ownership token bằng redis-py. - Xây dựng context manager xử lý cả trường hợp release trả
0. - Biết khi nào dùng redis-py
Lockbuilt-in thay vì tự viết. - Hiểu giới hạn còn lại — release an toàn là điều kiện cần nhưng chưa đủ cho correctness.
Nhắc Lại Bẫy Từ Bài 43
Bài 43 mô tả bẫy phổ biến nhất khi dùng distributed lock không có ownership check:
- Client A gọi
SET lock:order nx ex 30— acquire thành công, TTL = 30 giây. - A xử lý chậm hơn dự kiến (GC pause, network stall, ...) — lock expire.
- Client B gọi
SET lock:order nx ex 30— acquire thành công vì A đã expire. - A xử lý xong, gọi
DEL lock:orderđể release. - A vừa xóa lock của B. Mutual exclusion bị phá — cả A và B cùng tiếp tục chạy trong critical section.
Gốc rễ vấn đề: DEL không kiểm tra ai đang giữ lock. Mọi client đều có thể xóa lock của người khác. Giải pháp là gắn một ownership token khi acquire và chỉ cho phép release nếu token khớp.
Sai Lầm #1 — DEL Trực Tiếp
# SAI: DEL trực tiếp, không check owner
def release_lock_naive_v1(resource):
redis.delete(f"lock:{resource}")
Lệnh này xóa bất kỳ lock nào đang tồn tại trên key đó, kể cả lock của client khác đang giữ hợp lệ. Không có gì ngăn A xóa lock của B như trong kịch bản bài 43.
Sai Lầm #2 — GET Check Rồi DEL (Không Atomic)
# SAI: có check owner nhưng không atomic
def release_lock_naive_v2(resource, token):
if redis.get(f"lock:{resource}") == token: # check owner
redis.delete(f"lock:{resource}") # DEL
Nhìn qua có vẻ đúng vì đã so sánh token. Nhưng GET và DEL là hai lệnh riêng biệt, không có gì đảm bảo trạng thái Redis không thay đổi giữa hai lệnh đó.
Race condition cụ thể:
- A:
GET lock:order→ nhận về token của A, đúng owner. - (Đúng sau bước GET, lock expire)
- B:
SET lock:order nx ex 30→ acquire thành công với token của B. - A:
DEL lock:order→ xóa lock của B.
Kết quả giống hệt sai lầm #1: mutual exclusion bị phá. Khoảng thời gian giữa GET và DEL chỉ cần đủ dài để lock expire và B acquire — không cần nhiều, vài micro-giây trong điều kiện tranh chấp cao là đủ.
Vấn đề cốt lõi: cần thực hiện check-and-delete như một thao tác duy nhất, không thể bị xen vào. Đó là định nghĩa của atomic.
Fix — Lua Atomic Check-And-Delete
Redis thực thi Lua script theo cơ chế single-threaded: toàn bộ script chạy xong trước khi Redis xử lý lệnh tiếp theo. Không có lệnh nào từ client khác xen vào giữa.
-- KEYS[1] = lock key, ARGV[1] = token của client
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
Script thực hiện ba việc trong một đơn vị atomic:
GETgiá trị hiện tại của lock.- So sánh với token của client muốn release.
- Nếu khớp:
DELvà trả về1. Nếu không khớp: trả về0, không xóa gì.
Khoảng thời gian giữa GET và DEL từ bài trên không còn tồn tại — cả hai là một phần của cùng một Lua execution.
Token Phải Đủ Unique & Unguessable
Lua script chỉ giải quyết được race condition nếu token đủ unique và không đoán được. Nếu client khác đoán được token, nó vẫn có thể release nhầm lock.
Đúng:
uuid.uuid4()— random UUID, 122 bit entropy, chuẩn nhất.secrets.token_hex(16)— 128-bit random hex, tương đương.
Sai:
- Timestamp (
time.time(),datetime.now()) — hai acquire gần nhau có thể trùng token. - Hostname + PID — không đủ entropy khi nhiều process cùng restart.
- Counter tăng dần — đoán được, hoặc trùng sau khi reset.
Token phải là per-acquire: mỗi lần gọi acquire_lock sinh ra một token mới. Không reuse token giữa các acquire, dù trên cùng resource.
Code Đầy Đủ Python
import uuid
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
# Lua script: atomic check-and-delete
# Trả về 1 nếu đúng owner và đã xóa, 0 nếu không phải owner (hoặc lock đã expire)
RELEASE_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
_release_script = r.register_script(RELEASE_LUA)
def acquire_lock(resource: str, ttl: int = 30) -> str | None:
"""
Acquire lock trên resource với TTL giây.
Trả về token (string) nếu acquire thành công, None nếu thất bại.
Token là UUID4 random — unique per acquire, unguessable.
"""
token = str(uuid.uuid4())
acquired = r.set(f"lock:{resource}", token, nx=True, ex=ttl)
return token if acquired else None
def release_lock(resource: str, token: str) -> int:
"""
Release lock chỉ nếu token khớp (đúng owner).
Trả về 1 nếu release thành công, 0 nếu lock đã expire hoặc bị người khác giữ.
"""
return _release_script(keys=[f"lock:{resource}"], args=[token])
register_script nạp Lua script một lần rồi dùng lại — redis-py tự tính SHA và dùng EVALSHA sau lần đầu, giảm băng thông truyền script lên server.
Context Manager An Toàn
import logging
from contextlib import contextmanager
logger = logging.getLogger(__name__)
class LockNotAcquired(Exception):
pass
@contextmanager
def redis_lock(resource: str, ttl: int = 30):
"""
Context manager acquire/release lock an toàn.
Raise LockNotAcquired nếu không lấy được lock.
Log warning nếu release trả 0 (lock đã expire + bị acquire lại).
"""
token = acquire_lock(resource, ttl)
if token is None:
raise LockNotAcquired(f"Cannot acquire lock: {resource}")
try:
yield token
finally:
released = release_lock(resource, token)
if not released:
# Release trả 0: lock đã expire trước khi ta kịp release,
# và có thể đã bị worker khác acquire. Đây là tín hiệu cảnh báo
# về TTL quá ngắn hoặc critical section chạy quá lâu.
logger.warning(
"Lock %s release failed (returned 0) — "
"lock may have expired and been re-acquired",
resource,
)
# Sử dụng
def process_order(order_id: int) -> None:
with redis_lock(f"order:{order_id}", ttl=30):
# Critical section: chỉ một worker chạy đây tại một thời điểm
charge_payment(order_id)
update_inventory(order_id)
try/finally đảm bảo release_lock luôn được gọi, kể cả khi critical section raise exception. Nếu không dùng finally, exception trong critical section sẽ khiến lock không bao giờ được release — chỉ TTL mới giải phóng được, và trong khoảng thời gian đó không ai acquire được lock.
Ý Nghĩa Của Return Value
| Return value | Nghĩa | Hành động nên làm |
|---|---|---|
1 |
Token khớp, lock đã được xóa. Release thành công. | Bình thường, không cần làm gì thêm. |
0 |
Token không khớp: lock đã expire trước khi release, hoặc đã bị client khác acquire. | Log warning + alert. Điều tra TTL và thời gian chạy của critical section. |
Return 0 không phải lỗi kỹ thuật của Redis hay Lua — đó là tín hiệu nghiệp vụ quan trọng. Nó có nghĩa là critical section chạy dài hơn TTL, lock đã expire, và một worker khác có thể đã (hoặc đang) chạy trong critical section đó. Bỏ qua return 0 là mất đi một trong những tín hiệu cảnh báo sớm nhất về double-processing.
Tại Sao Lua, Không MULTI/EXEC
Redis MULTI/EXEC cung cấp transaction nhưng có giới hạn quan trọng: không có conditional logic bên trong transaction. Không thể viết "nếu GET == token thì DEL" bên trong MULTI/EXEC vì các lệnh trong queue không biết kết quả của nhau.
WATCH kết hợp MULTI/EXEC có thể mô phỏng optimistic locking:
# WATCH + MULTI/EXEC: phức tạp hơn và cần retry loop
def release_with_watch(resource, token):
with r.pipeline() as pipe:
while True:
try:
pipe.watch(f"lock:{resource}")
current = pipe.get(f"lock:{resource}")
if current != token:
pipe.reset()
return 0
pipe.multi()
pipe.delete(f"lock:{resource}")
pipe.execute()
return 1
except redis.WatchError:
continue # Retry nếu key thay đổi giữa WATCH và EXECUTE
So sánh với Lua:
- WATCH+MULTI: cần retry loop, code phức tạp hơn, nhiều round-trip hơn khi có tranh chấp.
- Lua: không retry, không loop, một round-trip duy nhất, kết quả dứt khoát.
Lua là cách chuẩn cho release lock trong Redis. Redis documentation cũng dùng Lua trong ví dụ distributed lock chính thức.
Kết Hợp Với Watchdog (Bài 44)
Bài 44 giới thiệu watchdog thread để extend TTL khi critical section chạy lâu hơn dự kiến. Token là liên kết xuyên suốt ba thao tác:
- Acquire:
SET lock:{resource} {token} NX EX {ttl}— token được ghi vào Redis. - Extend (watchdog): Lua kiểm tra token trước khi chạy
EXPIRE— chỉ extend nếu vẫn là owner. - Release: Lua kiểm tra token trước khi chạy
DEL— chỉ xóa nếu vẫn là owner.
Với watchdog, critical section được gia hạn TTL định kỳ nên release trả 0 ít xảy ra hơn. Nhưng release vẫn phải check token — watchdog và release an toàn là hai cơ chế bổ sung nhau, không thay thế nhau.
import threading
EXTEND_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
"""
_extend_script = r.register_script(EXTEND_LUA)
def _watchdog(resource: str, token: str, ttl: int, stop_event: threading.Event):
interval = ttl // 3 # extend mỗi 1/3 TTL
while not stop_event.wait(interval):
result = _extend_script(keys=[f"lock:{resource}"], args=[token, ttl])
if not result:
break # Lock đã mất, dừng watchdog
@contextmanager
def redis_lock_with_watchdog(resource: str, ttl: int = 30):
token = acquire_lock(resource, ttl)
if token is None:
raise LockNotAcquired(resource)
stop = threading.Event()
watcher = threading.Thread(
target=_watchdog, args=(resource, token, ttl, stop), daemon=True
)
watcher.start()
try:
yield token
finally:
stop.set()
released = release_lock(resource, token)
if not released:
logger.warning("Lock %s expired before release", resource)
Library Có Sẵn
redis-py (từ phiên bản 3.x) có Lock built-in xử lý token + Lua release sẵn:
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
# redis-py Lock: token tự động, Lua release tích hợp
lock = r.lock("lock:order:42", timeout=30, blocking_timeout=10)
if lock.acquire():
try:
process_order(42)
finally:
lock.release() # tự check ownership bằng Lua
# Hoặc dùng context manager tích hợp:
with r.lock("lock:order:42", timeout=30, blocking_timeout=10) as lock:
process_order(42)
# release tự động khi thoát with block
redis-py Lock còn hỗ trợ:
extend(additional_time): gia hạn TTL thủ công (dùng Lua, có check token).owned(): kiểm tra client hiện tại có đang giữ lock không.reacquire(): reset TTL về giá trị ban đầu nếu vẫn là owner.
Các thư viện khác cho Python: python-redis-lock (cú pháp tương tự), redlock-py (multi-node Redlock algorithm). Cho Java: Redisson có RLock với watchdog tích hợp.
Quy tắc thực tế: dùng library khi dự án cho phép. Tự viết chỉ khi có ràng buộc về dependency hoặc cần customization mà library không hỗ trợ.
Giới Hạn Còn Lại
Release an toàn giải quyết đúng một vấn đề: không xóa nhầm lock của người khác. Nó không giải quyết tất cả vấn đề của distributed lock.
Tình huống vẫn còn nguy hiểm:
- Worker A acquire lock, chạy critical section.
- A bị process pause dài (GC stop-the-world, VM snapshot) — lock expire.
- Worker B acquire lock, bắt đầu critical section.
- A tiếp tục chạy sau khi pause — hai worker cùng chạy trong critical section.
- Khi A gọi release, Lua script trả về
0(đúng — A không còn là owner). Nhưng thiệt hại đã xảy ra.
Release an toàn là điều kiện cần: không có nó, lock hoàn toàn không an toàn. Nhưng nó chưa phải điều kiện đủ cho correctness trong môi trường có process pause.
Để giải quyết triệt để double-processing, cần thêm fencing token — một cơ chế phát hiện "thao tác đã bị invalidate" ở phía resource (database, file storage). Bài 46 sẽ đi vào cơ chế này.
Anti-patterns & Best Practices
Anti-patterns
- DEL trực tiếp: không check owner, xóa lock của người khác.
- GET + DEL riêng rẽ: dù check token nhưng không atomic, có race condition.
- Token đoán được: timestamp, hostname, counter — không đủ entropy.
- Token reuse: dùng cùng một token cho nhiều lần acquire — mất tính unique per-acquire.
- Bỏ qua return 0: mất tín hiệu cảnh báo quan trọng về lock expire.
- Release ngoài
finally: exception trong critical section khiến lock không bao giờ được release. - Tự viết khi redis-py Lock đã có sẵn: thêm surface area bug không cần thiết.
Best Practices
- Lua atomic check-and-delete cho release.
- Token random, unguessable, per-acquire:
uuid.uuid4()hoặcsecrets.token_hex(16). - Luôn release trong
try/finallyhoặc context manager. - Log + alert khi release trả
0. - Dùng redis-py
Lockbuilt-in khi có thể — token + Lua release đã được xử lý. - Kết hợp watchdog (bài 44) để giảm xác suất lock expire sớm.
Tổng Kết & Quiz
Bài này giải quyết bẫy "release lock của người khác" từ bài 43. Điểm cốt lõi:
DELtrực tiếp: không check owner, luôn sai trong distributed setting.- GET + DEL: check owner nhưng hai lệnh riêng → race condition giữa chúng.
- Lua: GET + so sánh + DEL chạy atomic → không race, chỉ xóa đúng owner.
- Token phải random, unguessable, per-acquire.
- Release trả
0là tín hiệu nghiệp vụ quan trọng — không bỏ qua. - Redis-py
Lockxử lý tất cả những điều trên — nên dùng khi có thể.
Quiz
- Client A gọi release với token đúng của mình, nhưng Lua script trả về
0. Giải thích nguyên nhân có thể xảy ra và hậu quả với mutual exclusion. - Tại sao dùng
time.time_ns()làm token lại không an toàn? Cho ví dụ cụ thể về kịch bản lỗi. - Viết Lua script release lock, nhưng thay vì xóa key, đặt lại value thành
"free"(tức là lock vẫn tồn tại nhưng không ai giữ). Điều này có vấn đề gì so với cách DEL? - Redis-py
Lock(timeout=30, blocking_timeout=10):timeoutvàblocking_timeoutcó nghĩa gì? Chúng ảnh hưởng đến behavior khác nhau như thế nào? - Context manager
redis_locktrong bài dùngtry/finally. Nếuyield tokenraise exception, điều gì xảy ra với lock? Nếu không cófinallythì sao?
Đáp án gợi ý
- Lock đã expire trước khi A kịp release. Sau khi expire, có thể B đã acquire lock. Lua thấy value là token của B (không khớp token A) nên trả
0, không xóa — đúng hành vi. Tuy nhiên mutual exclusion đã bị phá ở bước trước đó (khi lock expire): B đang chạy critical section trong khi A vẫn chưa xong. Release an toàn không ngăn được điều này — nó chỉ ngăn A xóa lock của B. time.time_ns()có độ phân giải nanosecond nhưng trên cùng máy hoặc các máy với clock đồng bộ, hai acquire rất gần nhau có thể nhận cùng giá trị (hoặc giá trị đoán được). Hơn nữa, nếu clock drift hoặc reset, token có thể lặp lại. Kịch bản: worker A và B acquire trên hai server khác nhau với clock đồng bộ NTP trong cùng nanosecond → cùng token → một trong hai có thể release lock của kia.- Đặt
"free"thay vì DEL nghĩa là key vẫn tồn tại với value"free". Vấn đề: acquire cần checkNX(key không tồn tại) để set, nhưng giờ key luôn tồn tại → acquire không bao giờ thành công trừ khi thay đổi logic acquire. Phức tạp hơn DEL và mở ra nhiều edge case (vd key expire trước khi "reset về free"). DEL sạch sẽ hơn: key không tồn tại = không có lock = acquire được. timeout=30: TTL của lock khi acquire — lock tự expire sau 30 giây.blocking_timeout=10: thời gian tối đa chờ acquire nếu lock đang bị giữ bởi client khác — sau 10 giây không acquire được thìacquire()trảFalse(hoặc raiseLockNotOwnedErrortùy config). Hai thông số độc lập nhau hoàn toàn.- Với
try/finally: khiyield tokenraise exception, Python thực thi khốifinallytrước khi exception propagate ra ngoài — lock được release đúng cách. Nếu không cófinally(dùngexcepthoặc không có gì): exception trong critical section khiến code sauyieldkhông chạy, lock không được release, mọi worker khác bị block cho đến khi TTL expire.
Bài tiếp theo
Bài 46 xét kịch bản process pause khiến hai worker cùng chạy trong critical section dù lock đã được implement đúng — và tại sao fencing token là cơ chế duy nhất giải quyết được ở phía resource.
