Danh sách bài viết

Bài 45: Unlock An Toàn — Lua Script & Ownership Validation

Bài 43 nêu bẫy "release lock của người khác" nhưng chưa sửa. Bài này đi thẳng vào giải pháp: tại sao DEL trực tiếp sai, tại sao GET rồi DEL vẫn sai dù đã check owner, và Lua atomic check-and-delete giải quyết triệt để như thế nào. Nội dung gồm code Python đầy đủ với ownership token, context manager xử lý cả trường hợp release trả về 0, và đánh giá redis-py Lock built-in.

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

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

  • Hiểu tại sao DEL trự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_lock hoà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 Lock built-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.
2

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:

  1. Client A gọi SET lock:order nx ex 30 — acquire thành công, TTL = 30 giây.
  2. A xử lý chậm hơn dự kiến (GC pause, network stall, ...) — lock expire.
  3. Client B gọi SET lock:order nx ex 30 — acquire thành công vì A đã expire.
  4. A xử lý xong, gọi DEL lock:order để release.
  5. 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.

3

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.

4

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 GETDEL 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ể:

  1. A: GET lock:order → nhận về token của A, đúng owner.
  2. (Đúng sau bước GET, lock expire)
  3. B: SET lock:order nx ex 30 → acquire thành công với token của B.
  4. 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 GETDEL 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.

5

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:

  1. GET giá trị hiện tại của lock.
  2. So sánh với token của client muốn release.
  3. Nếu khớp: DEL và 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.

6

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.

7

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.

8

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.

9

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

10

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.

11

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:

  1. Acquire: SET lock:{resource} {token} NX EX {ttl} — token được ghi vào Redis.
  2. Extend (watchdog): Lua kiểm tra token trước khi chạy EXPIRE — chỉ extend nếu vẫn là owner.
  3. 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)
12

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: RedissonRLock 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ợ.

13

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:

  1. Worker A acquire lock, chạy critical section.
  2. A bị process pause dài (GC stop-the-world, VM snapshot) — lock expire.
  3. Worker B acquire lock, bắt đầu critical section.
  4. A tiếp tục chạy sau khi pause — hai worker cùng chạy trong critical section.
  5. 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.

14

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ặc secrets.token_hex(16).
  • Luôn release trong try/finally hoặc context manager.
  • Log + alert khi release trả 0.
  • Dùng redis-py Lock built-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.
15

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:

  • DEL trự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ả 0 là tín hiệu nghiệp vụ quan trọng — không bỏ qua.
  • Redis-py Lock xử lý tất cả những điều trên — nên dùng khi có thể.

Quiz

  1. 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.
  2. 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.
  3. 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?
  4. Redis-py Lock(timeout=30, blocking_timeout=10): timeoutblocking_timeout có nghĩa gì? Chúng ảnh hưởng đến behavior khác nhau như thế nào?
  5. Context manager redis_lock trong bài dùng try/finally. Nếu yield token raise exception, điều gì xảy ra với lock? Nếu không có finally thì sao?

Đáp án gợi ý

  1. 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.
  2. 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.
  3. Đặt "free" thay vì DEL nghĩa là key vẫn tồn tại với value "free". Vấn đề: acquire cần check NX (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.
  4. 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 raise LockNotOwnedError tùy config). Hai thông số độc lập nhau hoàn toàn.
  5. Với try/finally: khi yield token raise exception, Python thực thi khối finally trước khi exception propagate ra ngoài — lock được release đúng cách. Nếu không có finally (dùng except hoặc không có gì): exception trong critical section khiến code sau yield khô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.

Tham khảo