Danh sách bài viết

Bài 44: Lock Expiration Problem — Deadlock & Lock Hết Hạn Giữa Chừng

Bài 43 đã xây distributed lock cơ bản với SET NX EX và token ownership. Bài này đào sâu vào vấn đề trung tâm mà TTL đặt ra: chọn ngắn quá thì lock expire giữa job còn client vẫn đang chạy — hai worker cùng xử lý tài nguyên. Chọn dài quá thì client chết làm resource bị khóa đến hết TTL. Không có TTL "đúng" khi job time không xác định trước. Bài này phân tích cả hai failure mode, giải thích watchdog pattern (lease renewal) để xử lý job time biến đổi, implementation Python với background thread, và tại sao watchdog vẫn không loại bỏ hoàn toàn double processing trước GC pause.

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

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 LockWithWatchdog bằ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.
2

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.

3

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.

4

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.

5

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.

6

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:

  1. Client acquire lock, TTL = 30s.
  2. Background thread (watchdog) khởi động, đặt interval = TTL / 3 = 10s.
  3. Mỗi 10s, watchdog gọi Redis để extend TTL thêm 30s nữa — miễn client còn alive.
  4. Job xong → client stop watchdog → lock expire tự nhiên sau TTL cuối cùng (hoặc release ngay).
  5. 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.

7

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ơn time.sleep() vì có thể interrupted ngay khi set() được gọi, không phải chờ hết interval.
  • Watchdog tự dừng khi extend_script trả 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).
8

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.

9

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ý.

10

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).

11

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.

12

Chọn TTL Thực Tế

Quy trình thực tế để chọn TTL ban đầu:

  1. Đo job time distribution trong production (hoặc staging với load thực tế). Thu thập P50, P99, P99.9.
  2. TTL ban đầu = P99.9 × 2. Hệ số 2 là buffer cho outlier nhẹ. Nếu P99.9 = 40s thì TTL = 80s.
  3. Thêm watchdog cho job time variable hoặc khi outlier nặng hơn (GC, external API). Watchdog interval = TTL / 3.
  4. 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.

13

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 ttl mà 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).
14

Bài Tập

  1. 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.
  2. 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 đề?
  3. 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?
  4. 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à đủ?
  5. Trong implementation LockWithWatchdog, nếu bỏ daemon=True khi tạo thread thì điều gì xảy ra khi main process cố gắng exit?

Đáp án gợi ý

  1. 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.
  2. 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ỡ.
  3. Ở 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.
  4. 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ũ.
  5. 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_event khô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.

Tham khảo