Mục lục
- Mục Tiêu Bài Học
- Dilemma TTL — Không Có Con Số "Đúng"
- Failure Mode 1 — Lock Expire Giữa Chừng
- Failure Mode 2 — Deadlock Khi Client Chết
- Giải Pháp Tạm: TTL Đủ Lớn + Buffer
- Watchdog Pattern — Lease Renewal
- Implementation Python — LockWithWatchdog
- Extend Chỉ Khi Còn Sở Hữu — Lua Script
- Library Có Sẵn: Redisson, python-redis-lock
- Giới Hạn Của Watchdog — GC Pause & Process Pause
- Efficiency Lock vs Correctness Lock
- Chọn TTL Thực Tế
- Anti-Pattern & Best Practice
- Bài Tập
Mục Tiêu Bài Học
Sau bài này bạn sẽ:
- Giải thích được tại sao không có TTL nào là "đúng" khi job time không xác định.
- Mô tả chính xác hai failure mode: lock expire giữa chừng (double processing) và deadlock khi client chết.
- Hiểu watchdog pattern — background thread định kỳ extend TTL, dừng khi client chết.
- Implement
LockWithWatchdogbằng Python với Lua script extend ownership-safe. - Nhận biết giới hạn của watchdog trước GC pause / process pause và biết khi nào cần fencing token.
Dilemma TTL — Không Có Con Số "Đúng"
Mọi distributed lock dựa trên TTL đều phải chọn một con số: lock tồn tại bao lâu trước khi Redis tự xóa nếu client không release. Hai hướng đều có hệ quả:
| TTL | Khi client chết | Khi job chạy lâu hơn TTL |
|---|---|---|
| Ngắn (vd 5s) | Resource free nhanh — tốt | Lock expire giữa job → worker khác acquire → double processing |
| Dài (vd 5 phút) | Resource bị khóa đến 5 phút — liveness kém | Job lâu vẫn an toàn — tốt |
Vấn đề nằm ở chỗ job time thường không cố định: DB chậm, GC pause, network spike, outlier data — tất cả đều có thể khiến job chạy lâu hơn bình thường. Một TTL phù hợp với 99% trường hợp vẫn có thể sai ở 1% outlier còn lại.
Failure Mode 1 — Lock Expire Giữa Chừng
Đây là failure mode nguy hiểm hơn vì vi phạm mutual exclusion — tính đảm bảo cốt lõi của distributed lock.
Kịch bản cụ thể: TTL = 30s. Job thường mất 10s. Hôm nay DB chậm, job mất 35s.
T=0s Worker A: SET lock:resource token-A EX 30 → OK (lock acquired)
T=0-30s Worker A: đang xử lý order #1234 ...
T=30s Redis: TTL hết → TỰ XÓA key (A vẫn đang chạy, không biết gì)
T=31s Worker B: GET lock:resource → nil
T=31s Worker B: SET lock:resource token-B EX 30 → OK (B cũng acquire được)
T=31-35s A và B CÙNG xử lý order #1234
T=35s Worker A: xong, gọi release → DEL lock:resource (xóa lock của B!)
Hậu quả tùy ngữ cảnh:
- Billing job: user bị charge 2 lần.
- Email job: user nhận 2 email xác nhận.
- Inventory update: stock bị trừ 2 lần cho 1 đơn hàng.
- DB write: ghi đè lẫn nhau, data corruption.
Thêm nữa: ở T=35s, Worker A release bằng DEL lock:resource — nhưng lúc này key đang là lock của B, không phải của A. Nếu không có ownership check (token validation), A xóa nhầm lock của B, khiến Worker C có thể acquire ngay lập tức — một vấn đề riêng sẽ được xử lý ở bài 45.
Failure Mode 2 — Deadlock Khi Client Chết
Đây chính là lý do cần TTL ngay từ đầu: nếu lock không có TTL, client crash không bao giờ gọi được release, resource bị khóa mãi mãi — deadlock vĩnh viễn.
Nhưng TTL dài vẫn tạo ra deadlock tạm thời:
T=0s Worker A: acquire lock:resource (TTL = 5 phút)
T=5s Worker A: crash (OOM, kill -9, hardware failure)
T=5s Worker B: thử acquire → FAIL (key vẫn tồn tại)
T=5s đến T=300s Toàn bộ worker khác đều blocked chờ lock
T=300s Redis: TTL hết → key xóa → Worker B mới acquire được
Trong môi trường production, 5 phút downtime của một resource có thể không chấp nhận được — đặc biệt với các job cần chạy thường xuyên hoặc background worker xử lý queue.
Nhưng TTL 5 giây? Một job bình thường mất 10 giây sẽ xảy ra failure mode 1. Đây là dilemma không có lời giải đơn giản.
Giải Pháp Tạm: TTL Đủ Lớn + Buffer
Cách tiếp cận đơn giản nhất: đo job time, lấy percentile cao, nhân hệ số an toàn.
# Đo distribution job time trong production
P50 = 8s
P99 = 25s
P99.9 = 40s
# Chọn TTL = P99.9 × 2 = 80s
# Nếu job ổn định, 80s đủ cover mọi trường hợp trừ outlier thực sự cực đoan
Cách này phù hợp khi:
- Job time tương đối predictable, outlier hiếm.
- Chấp nhận resource bị khóa đến TTL nếu client crash.
- Không có job time variable theo data size hoặc external dependency.
Vẫn không xử lý được:
- Outlier thực sự: GC full pause kéo dài vài phút, network partition 10 phút.
- Job time tỉ lệ thuận với data (xử lý file 1MB vs 1GB).
- External API timeout không xác định.
Nếu job time không predictable, cần watchdog.
Watchdog Pattern — Lease Renewal
Ý tưởng: thay vì cố gắng chọn TTL đủ lớn ngay từ đầu, lock bắt đầu với TTL ngắn (vd 30s) và tự động gia hạn trong khi client còn chạy.
Cách hoạt động:
- Client acquire lock, TTL = 30s.
- Background thread (watchdog) khởi động, đặt interval = TTL / 3 = 10s.
- Mỗi 10s, watchdog gọi Redis để extend TTL thêm 30s nữa — miễn client còn alive.
- Job xong → client stop watchdog → lock expire tự nhiên sau TTL cuối cùng (hoặc release ngay).
- Client crash → watchdog dừng theo → không còn ai extend → lock expire sau tối đa 30s.
T=0s A acquire lock (TTL=30s). Watchdog start (interval=10s).
T=10s Watchdog: extend → TTL reset về 30s (key còn 30s nữa)
T=20s Watchdog: extend → TTL reset về 30s
T=30s Watchdog: extend → TTL reset về 30s (lock không expire dù đã 30s)
T=35s Job xong → watchdog stop → release lock.
--- Nếu client crash ở T=15s ---
T=15s Process die → watchdog thread kết thúc theo
T=25s (T=15 + 10s interval còn lại) Redis: TTL hết → lock xóa
→ Worker B có thể acquire ngay (tối đa chờ 20-30s)
So với TTL cố định, watchdog giải quyết cả hai phía: job lâu tùy ý vẫn giữ được lock, client chết thì lock tự free trong vài chục giây thay vì vài phút.
Implementation Python — LockWithWatchdog
Implementation đầy đủ với Python threading.Event để stop watchdog sạch sẽ:
import threading
import time
import uuid
import redis
# Lua script: chỉ extend TTL nếu token khớp (ownership check)
EXTEND_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
"""
class LockWithWatchdog:
def __init__(self, redis_client, resource: str, ttl: int = 30):
"""
redis_client: redis.Redis instance
resource: tên resource cần lock (vd 'invoice:1234')
ttl: TTL tính bằng giây, watchdog renew mỗi ttl/3
"""
self.redis = redis_client
self.key = f"lock:{resource}"
self.token = str(uuid.uuid4()) # unique token per lock instance
self.ttl = ttl
self._stop_event = threading.Event()
self._watchdog_thread = None
self._extend_script = redis_client.register_script(EXTEND_LUA)
def acquire(self, timeout: float = 0) -> bool:
"""
Thử acquire lock.
timeout=0: chỉ thử 1 lần (non-blocking).
timeout>0: retry trong khoảng thời gian đó (blocking với retry).
Trả về True nếu thành công.
"""
deadline = time.monotonic() + timeout
while True:
ok = self.redis.set(self.key, self.token, nx=True, ex=self.ttl)
if ok:
# Khởi động watchdog ngay sau khi acquire
self._stop_event.clear()
self._watchdog_thread = threading.Thread(
target=self._watchdog_loop,
daemon=True, # thread tự kết thúc khi process exit
name=f"watchdog:{self.key}"
)
self._watchdog_thread.start()
return True
if time.monotonic() >= deadline:
return False
time.sleep(0.1) # retry interval nhỏ
def _watchdog_loop(self):
"""
Background loop: extend TTL mỗi ttl/3 giây.
Dừng khi _stop_event được set hoặc extend thất bại (lock bị mất).
"""
interval = self.ttl / 3
while not self._stop_event.wait(timeout=interval):
# PEXPIRE nhận milliseconds
result = self._extend_script(
keys=[self.key],
args=[self.token, self.ttl * 1000]
)
if result == 0:
# Lock không còn thuộc về mình nữa (expired hoặc bị revoke)
# Dừng watchdog — không tiếp tục extend
break
def release(self):
"""
Stop watchdog trước, sau đó release lock (atomic check-and-delete).
Chi tiết Lua release ở bài 45.
"""
# Signal watchdog dừng
self._stop_event.set()
if self._watchdog_thread:
self._watchdog_thread.join(timeout=2)
# Atomic: chỉ DEL nếu token khớp (tránh xóa lock của worker khác)
release_lua = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
self.redis.eval(release_lua, 1, self.key, self.token)
def __enter__(self):
if not self.acquire():
raise RuntimeError(f"Could not acquire lock: {self.key}")
return self
def __exit__(self, *args):
self.release()
# Sử dụng với context manager
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
with LockWithWatchdog(r, resource='invoice:1234', ttl=30) as lock:
# Job có thể chạy lâu tùy ý — watchdog tự extend
process_invoice('1234')
# release() gọi tự động khi thoát block
Một số điểm cần chú ý trong implementation:
daemon=True: watchdog thread sẽ tự kết thúc khi main process exit — không cản shutdown.threading.Event.wait(timeout): tốt hơntime.sleep()vì có thể interrupted ngay khiset()được gọi, không phải chờ hết interval.- Watchdog tự dừng khi
extend_scripttrả về 0: tức là lock đã bị expire hoặc bị ai revoke — không cố extend thêm. uuid4()làm token: đảm bảo unique trong distributed system (collision probability cực thấp).
Extend Chỉ Khi Còn Sở Hữu — Lua Script
Tại sao extend phải dùng Lua thay vì gọi thẳng PEXPIRE key ttl?
Xét trường hợp không có ownership check:
T=0s Worker A acquire lock (TTL=30s). token-A.
T=30s Lock A expire (watchdog A bị delay vì GC pause).
T=31s Worker B acquire lock. token-B.
T=32s Watchdog A "wake up" sau GC pause → gọi PEXPIRE lock:resource 30000
→ Redis set TTL mới: 30s (nhưng key này đang là lock của B!)
→ A đang extend lock của B — sai hoàn toàn
Với Lua script có ownership check:
-- EXTEND_LUA
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
-- Chỉ PEXPIRE nếu value hiện tại khớp với token của mình
-- Nếu key đã thuộc B (value = token-B), A không thể extend
Script này chạy atomically trên Redis server — không có race condition giữa GET và PEXPIRE. Kết quả trả về 1 nếu extend thành công, 0 nếu không còn own lock. Watchdog dựa vào return value này để quyết định có tiếp tục loop không.
Library Có Sẵn: Redisson, python-redis-lock
Watchdog không phải pattern mới — nhiều library Redis client đã implement sẵn:
Redisson (Java)
// Redisson tích hợp watchdog mặc định
RLock lock = redissonClient.getLock("lock:invoice:1234");
// Không truyền leaseTime → watchdog tự động, mặc định 30s, renew mỗi 10s
lock.lock();
try {
processInvoice("1234");
} finally {
lock.unlock();
}
// Hoặc lock với leaseTime cụ thể (tắt watchdog, dùng fixed TTL)
lock.lock(60, TimeUnit.SECONDS);
Redisson watchdog: lease time mặc định 30s, renew mỗi lease/3 = 10s. Nếu client JVM die, watchdog dừng, lock expire sau tối đa 30s.
python-redis-lock
import redis_lock
conn = redis.StrictRedis()
# redis_lock hỗ trợ expire và auto-renewal
with redis_lock.Lock(conn, "invoice:1234", expire=30, auto_renewal=True):
process_invoice("1234")
aioredlock (asyncio)
from aioredlock import Aioredlock
lock_manager = Aioredlock([{"host": "localhost", "port": 6379}])
async with await lock_manager.lock("resource:invoice:1234") as lock:
assert lock.valid
await process_invoice_async("1234")
Trong môi trường production, ưu tiên dùng library đã mature thay vì tự implement — các edge case như network error khi renew, graceful shutdown, lock monitoring đều đã được xử lý.
Giới Hạn Của Watchdog — GC Pause & Process Pause
Watchdog giảm đáng kể xác suất lock expire giữa chừng — nhưng không loại bỏ hoàn toàn. Vấn đề cốt lõi: bất kỳ process nào cũng có thể bị pause bất ngờ, và khoảng thời gian pause không có giới hạn trên.
Các nguồn gây process pause:
- JVM Full GC (Stop-the-World): ứng dụng Java có thể bị pause từ vài giây đến hơn 1 phút khi full GC xảy ra. Toàn bộ thread dừng — kể cả watchdog thread.
- OS scheduling: OS preempt process để chạy process khác, đặc biệt trên host có nhiều VM hoặc container.
- VM live migration: cloud provider migrate VM sang host khác, process có thể pause vài giây đến vài chục giây.
- Network partition: watchdog còn alive nhưng không reach được Redis để extend → Redis coi như không có renewal → TTL hết.
- Swap / memory pressure: process bị đẩy sang swap, execution chậm lại đáng kể.
Kịch bản GC pause:
T=0s Worker A acquire lock (TTL=30s). Watchdog start.
T=5s JVM Full GC bắt đầu → tất cả thread (kể cả watchdog) đều DỪNG
T=50s GC kết thúc (45 giây pause)
T=50s Trong lúc đó:
T=30s Redis: lock A expire (watchdog không renew được vì thread bị pause)
T=31s Worker B acquire lock (token-B)
T=50s Worker A "wake up" sau GC → tiếp tục xử lý
→ A tưởng mình vẫn giữ lock (chưa ai báo cho A biết)
→ A và B cùng chạy
Martin Kleppmann đã mô tả vấn đề này trong bài "How to do distributed locking" (2016): "A distributed lock based on expiry cannot provide mutual exclusion guarantees — at any point, the process holding the lock can be paused for longer than the lease duration."
Watchdog chỉ xử lý case bình thường tốt hơn. Với correctness đầy đủ, cần thêm cơ chế ở tầng resource — đó là fencing token (bài 46).
Efficiency Lock vs Correctness Lock
Phân biệt hai mục đích dùng distributed lock dẫn đến yêu cầu bảo đảm khác nhau:
| Efficiency lock | Correctness lock | |
|---|---|---|
| Mục đích | Tránh làm việc thừa (vd nhiều worker cùng compute một result) | Đảm bảo chỉ một worker thực sự tác động vào resource (billing, DB write) |
| Hậu quả lock fail | Compute thừa, chậm hơn, tốn tài nguyên — không sai kết quả | Double charge, data corruption, side-effect không thể undo |
| Watchdog đủ không? | Thường đủ — lock fail chỉ làm thêm việc, không sai | Chưa đủ — process pause vẫn dẫn đến double processing |
| Cần thêm gì | TTL + watchdog | TTL + watchdog + fencing token + resource-level check |
Ví dụ efficiency lock: nhiều instance cùng cache-warm một key Redis. Nếu 2 instance cùng warm cùng lúc, kết quả vẫn đúng — chỉ tốn thêm một lần DB read. TTL + watchdog là đủ.
Ví dụ correctness lock: chỉ một worker được phép ghi vào file trên S3, hoặc debit tài khoản. Nếu 2 worker cùng thực hiện, hậu quả không thể sửa dễ dàng. Cần fencing token để resource biết request nào đến sau và từ chối.
Chọn TTL Thực Tế
Quy trình thực tế để chọn TTL ban đầu:
- Đo job time distribution trong production (hoặc staging với load thực tế). Thu thập P50, P99, P99.9.
- TTL ban đầu = P99.9 × 2. Hệ số 2 là buffer cho outlier nhẹ. Nếu P99.9 = 40s thì TTL = 80s.
- Thêm watchdog cho job time variable hoặc khi outlier nặng hơn (GC, external API). Watchdog interval = TTL / 3.
- Monitor lock-expire-before-release: alert khi Redis expire một key lock trước khi client gọi release. Đây là dấu hiệu TTL quá ngắn hoặc GC/pause xảy ra.
# Prometheus metric: đếm số lần lock tự expire
# (client không release, Redis tự xóa)
from prometheus_client import Counter
lock_expired_counter = Counter(
'redis_lock_expired_total',
'Number of locks that expired without explicit release',
['resource']
)
# Trong watchdog, khi extend trả về 0 (lock đã bị expire)
if result == 0:
lock_expired_counter.labels(resource=resource_name).inc()
break
Một lock-expire event không phải lúc nào cũng là lỗi (client crash là bình thường), nhưng nếu event xảy ra khi client vẫn còn alive thì TTL cần tăng hoặc watchdog cần kiểm tra lại interval.
Anti-Pattern & Best Practice
Anti-pattern
- TTL nhỏ hơn P50 job time: lock gần như chắc chắn expire giữa chừng trong điều kiện bình thường.
- Không có TTL (persist key): một client crash là deadlock vĩnh viễn. Luôn có TTL.
- Watchdog extend không check ownership: gọi thẳng
EXPIRE key ttlmà không kiểm tra token → extend lock của worker khác sau khi mình đã mất lock. - Tin watchdog đảm bảo mutual exclusion 100%: process pause dài hơn TTL vẫn dẫn đến double processing. Correctness lock cần thêm fencing token.
- TTL cố định cho job time không xác định: batch job xử lý file nhỏ mất 2s, file lớn mất 10 phút — một TTL cố định không phù hợp cả hai.
Best practice
- Đo P99.9 job time, TTL ban đầu = P99.9 × 2.
- Dùng watchdog khi job time variable; interval = TTL / 3.
- Mọi extend phải dùng Lua ownership check — không raw
EXPIRE. - Ưu tiên library mature (Redisson, python-redis-lock) hơn tự implement từ đầu.
- Monitor lock-expire-before-release, alert khi tần suất tăng bất thường.
- Với correctness lock: bổ sung fencing token và resource-level check (bài 46).
- Release lock dùng Lua atomic check-and-delete, không raw
DEL(bài 45).
Bài Tập
- Một job có P50=5s, P99=20s, P99.9=35s, P99.99=120s (outlier do external API). Bạn chọn TTL bao nhiêu và có dùng watchdog không? Lý giải.
- Watchdog interval đặt bằng TTL/3 thay vì TTL/2 hay TTL/10. Tại sao interval quá ngắn cũng có vấn đề?
- Client A acquire lock (TTL=30s, watchdog interval=10s). Ở T=25s, Redis bị network partition 20s. Điều gì xảy ra với lock? A còn nghĩ mình giữ lock không?
- Mô tả sự khác biệt giữa efficiency lock và correctness lock với ví dụ cụ thể. Với loại nào watchdog là đủ?
- Trong implementation
LockWithWatchdog, nếu bỏdaemon=Truekhi tạo thread thì điều gì xảy ra khi main process cố gắng exit?
Đáp án gợi ý
- TTL = P99.9 × 2 = 70s. Tuy nhiên P99.99 = 120s vượt qua TTL → watchdog cần thiết để handle outlier 120s. Không dùng TTL = 240s vì nếu client chết thì resource bị khóa 4 phút. Dùng TTL=70s + watchdog.
- Interval quá ngắn → nhiều lần gọi Redis không cần thiết → tốn resource và tăng latency. Interval = TTL/3 cân bằng: đảm bảo ít nhất 2 lần renew trong mỗi TTL window trước khi expire, đủ an toàn khi một lần renew lỡ.
- Ở T=25s, watchdog cố renew nhưng không reach được Redis → extend thất bại (trả về 0 hoặc exception). Ở T=30s, Redis expire lock (không nhận được PEXPIRE trong 30s). Ở T=45s kết nối phục hồi → Worker B acquire. A vẫn đang chạy và nghĩ mình giữ lock — đây chính là process pause problem. A không biết mình đã mất lock.
- Efficiency lock: nhiều worker cache-warm cùng key. Nếu 2 worker cùng warm, kết quả đúng, chỉ thêm 1 DB read. Watchdog đủ. Correctness lock: debit tài khoản. Nếu 2 worker cùng debit, user mất tiền 2 lần. Watchdog không đủ — cần fencing token để storage từ chối request cũ.
- Không có
daemon=True, watchdog là non-daemon thread. Python không exit cho đến khi tất cả non-daemon thread kết thúc. Nếu watchdog loop không có điều kiện dừng rõ ràng (hay_stop_eventkhông được set), process sẽ bị treo sau khi main code chạy xong.
Bài tiếp theo
Bài 45 giải quyết phần còn lại: release lock an toàn bằng Lua script atomic check-and-delete — đảm bảo chỉ owner mới có thể xóa lock của mình.
