Danh sách bài viết

Bài 46: Fencing Token — Vì Sao Chỉ Lock Thôi Chưa Đủ An Toàn

Bài 44 mô tả kịch bản process pause khiến hai worker cùng chạy trong critical section dù lock đã implement đúng. Bài này đi vào nguyên nhân cốt lõi — khoảng trống giữa "client nghĩ mình có lock" và "client thực sự ghi resource" — và giải thích tại sao fencing token (số tăng đơn điệu cấp khi acquire) kết hợp resource-side check là cách duy nhất loại trừ stale writer sau khi lock đã chuyển tay. Trình bày dựa trên Kleppmann (2016).

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

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

  • Giải thích tại sao distributed lock — kể cả với TTL, watchdog, và Lua release đúng cách — vẫn không đủ để đảm bảo correctness khi có process pause.
  • Hiểu ý tưởng fencing token: số tăng đơn điệu (monotonically increasing) cấp khi acquire, gửi kèm mọi write tới resource.
  • Biết resource phải là arbiter cuối: reject write có token thấp hơn token đã thấy.
  • Implement acquire-with-fence bằng Redis INCR và resource-side check bằng SQL conditional update.
  • Phân biệt fencing token với idempotency key — hai cơ chế giải quyết hai vấn đề khác nhau.
  • Nhận ra giới hạn: nhiều resource không support fencing natively, và Redis INCR có thể không strictly monotonic qua failover.
2

Nhắc Lại Kịch Bản Process Pause

Bài 44 trình bày kịch bản sau:

  1. Client A acquire lock thành công, TTL = 30 giây.
  2. A bị pause — GC stop-the-world, VM live migration, kernel descheduling — kéo dài hơn 30 giây. Lock expire trong khi A không hay biết.
  3. Client B acquire lock thành công (lock đã expire).
  4. B bắt đầu ghi vào resource.
  5. A tiếp tục chạy sau khi resume. A vẫn tưởng mình đang giữ lock (không có cơ chế nào báo cho A biết lock đã expire). A tiếp tục ghi vào resource.
  6. Hai writer đồng thời — A và B — ghi vào cùng resource. Kết quả tùy thuộc vào thứ tự ghi, có thể là data corruption.

Bài 45 đã giải quyết bẫy "release lock của người khác". Watchdog từ bài 44 giảm xác suất lock expire sớm. Nhưng không cơ chế nào trong các bài trước ngăn được kịch bản trên:

  • Watchdog chạy trên process của A — khi A pause, watchdog cũng pause theo. Lock vẫn expire.
  • Lua release đúng: khi A wake up và gọi release, script trả 0 (đúng hành vi). Nhưng A đã ghi vào resource rồi — release xảy ra sau khi thiệt hại đã xảy ra.
  • Ownership token giúp release an toàn, không giúp gì cho write an toàn.

Gốc rễ vấn đề: lock đảm bảo mutual exclusion tại Redis, nhưng không đảm bảo mutual exclusion tại resource. Giữa "client giữ lock" và "client ghi resource" có một khoảng thời gian — và trong khoảng đó, lock có thể đã chuyển tay.

3

Tại Sao Lock Không Đủ

Lock Redis kiểm soát ai được quyền vào critical section theo quan điểm của Redis. Resource — database, file storage, external service — không biết gì về lock state. Resource chỉ thấy các write request đến, không có cách phân biệt request nào đến từ lock holder hợp lệ hay từ lock holder cũ đã expire.

Hình dung theo thứ tự thời gian:

Redis:    [A holds lock] ... [lock expire] ... [B holds lock] ...
Client A: ... acquire ... [PAUSE] ........................... [wake up] → ghi resource
Client B:                          ... acquire ...  → ghi resource
Resource:                                           [write B]     [write A] ← không biết A đã "stale"

Resource nhận write từ cả A và B. Không có thông tin nào trong write request của A cho resource biết rằng A đã "bị hết hạn". Lock state chỉ tồn tại trong Redis — resource không có channel nào kiểm tra trạng thái đó trước mỗi write.

Kleppmann (2016, "How to do distributed locking") gọi đây là vấn đề cố hữu của distributed lock: lock chỉ đảm bảo mutual exclusion ở cấp lock service, không ở cấp resource. Để đảm bảo correctness ở tầng resource, cần một cơ chế khác — fencing token.

4

Ý Tưởng Fencing Token

Fencing token là một số nguyên tăng đơn điệu (monotonically increasing integer) được cấp mỗi lần một client acquire lock thành công. Client phải gửi token này kèm theo mọi write request tới resource.

Resource — database, storage, service — thực hiện hai việc:

  1. Ghi nhớ token cao nhất nó đã thấy (max_seen_token).
  2. Từ chối (reject) bất kỳ write nào có token nhỏ hơn max_seen_token.

Tại sao điều này hoạt động: mỗi lần lock chuyển tay (từ A sang B), token tăng lên. B luôn có token cao hơn A. Khi B ghi trước, resource cập nhật max_seen_token. Khi A (stale) ghi sau, token của A thấp hơn → resource reject. A bị "fenced off" — rào chắn ra khỏi resource.

Điểm mấu chốt: resource là arbiter cuối cùng. Quyết định reject/accept không nằm ở lock service mà nằm ở chính resource. Đây là lý do fencing hoạt động ngay cả khi lock service (Redis) không hoàn toàn đáng tin cậy.

5

Minh Họa Luồng Hoạt Động

T= 0: A acquire lock → token = 33. Resource chưa có max_seen_token.
T= 1: A bắt đầu xử lý, bị pause (GC).
T=30: Lock expire.
T=31: B acquire lock → token = 34.
T=32: B ghi resource kèm token=34.
      Resource: max_seen_token = 34. Ghi thành công.
T=40: A wake up, ghi resource kèm token=33.
      Resource: 33 < 34 → REJECT.
      A nhận lỗi, không ghi được. Data của B không bị ghi đè.

Câu hỏi tự nhiên: A sẽ xử lý lỗi reject như thế nào? Tùy thuộc vào nghiệp vụ:

  • Nếu B đã hoàn thành đúng operation đó, A nên raise lỗi và không retry (operation đã được xử lý rồi).
  • Nếu cần retry, A nên acquire lock mới — nhận token mới cao hơn — rồi thử lại từ đầu.

Fencing không "sửa" A sau khi bị reject — nó chỉ đảm bảo A không làm hỏng data mà B đã ghi đúng. Đó là đủ để đảm bảo correctness.

6

Tạo Fencing Token Với Redis INCR

Redis INCR là atomic, trả về giá trị mới sau khi tăng. Dùng một key counter riêng cho mỗi resource là cách đơn giản nhất để cấp fencing token monotonic:

import uuid
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


def acquire_lock_with_fence(resource: str, ttl: int = 30) -> tuple[str | None, int | None]:
    """
    Acquire lock và trả về (ownership_token, fencing_token).
    ownership_token: UUID dùng để release lock đúng cách (bài 45).
    fencing_token:   số nguyên tăng đơn điệu, gửi kèm mọi write tới resource.
    Trả về (None, None) nếu acquire thất bại.
    """
    ownership_token = str(uuid.uuid4())
    acquired = r.set(f"lock:{resource}", ownership_token, nx=True, ex=ttl)
    if not acquired:
        return None, None
    # INCR atomic: mỗi acquire thành công nhận fence cao hơn acquire trước
    fence = r.incr(f"fence:{resource}")
    return ownership_token, fence

Hai loại token phục vụ hai mục đích khác nhau:

  • Ownership token (UUID): dùng để release lock đúng cách — Lua check-and-delete (bài 45). Không dùng làm fencing token vì UUID không có thứ tự.
  • Fencing token (integer từ INCR): dùng để gửi kèm write tới resource. Có thứ tự, so sánh được, tăng đơn điệu.

Tại sao UUID ownership token không dùng được làm fencing token: UUID không có thứ tự tự nhiên. Không thể so sánh "3f2a..." < "9c1b..." để biết UUID nào mới hơn. Fencing yêu cầu token có thứ tự để resource biết token nào là cũ hơn.

# Sử dụng
ownership_token, fence = acquire_lock_with_fence("order:42", ttl=30)
if fence is not None:
    try:
        # Gửi fence kèm mọi write tới resource
        process_payment(order_id=42, amount=100, fence_token=fence)
    finally:
        release_lock("order:42", ownership_token)  # Lua check-and-delete
7

Resource Phải Support Fencing

Fencing token chỉ hoạt động nếu resource tự thực hiện việc check và reject. Client không thể tự kiểm tra — check phía client vẫn có race condition tương tự GET+DEL không atomic. Check phải xảy ra ngay tại resource, trong cùng thao tác ghi.

Với database quan hệ, thêm một column last_fence vào bảng và dùng conditional UPDATE:

-- Schema: thêm cột last_fence
ALTER TABLE orders ADD COLUMN last_fence BIGINT NOT NULL DEFAULT 0;

-- Write với fencing check
-- Chỉ update nếu fence_token > last_fence đã lưu (write mới hơn)
UPDATE orders
SET status = 'paid', paid_amount = :amount, last_fence = :fence
WHERE id = :order_id
  AND status = 'pending'
  AND last_fence < :fence;

Nếu UPDATE trả về 0 rows affected:

  • Hoặc last_fence >= fence: request đến từ stale client (token cũ), đã bị fenced off.
  • Hoặc status != 'pending': order đã được xử lý bởi request hợp lệ trước đó.

Với object storage (S3-compatible), dùng conditional write theo ETag/version: If-Match: <etag> hoặc x-amz-expected-bucket-owner. Cơ chế khác nhau nhưng ý tưởng giống nhau — write chỉ thành công nếu state hiện tại của resource khớp với điều kiện.

Với external service, service đó phải cung cấp API nhận token và tự thực hiện check. Nếu service không có cơ chế này, fencing không áp dụng được — đây là giới hạn thực tế quan trọng.

8

Ví Dụ Thực Tế — Chống Double Payment

import psycopg2


class StaleFenceError(Exception):
    """Fencing token cũ — write bị reject bởi resource."""
    pass


def process_payment(order_id: int, amount: float, fence_token: int) -> None:
    """
    Ghi payment vào DB với fencing check.
    Raise StaleFenceError nếu fence_token cũ hơn last_fence đã lưu.
    DB là arbiter cuối — không cần biết trạng thái lock ở Redis.
    """
    conn = psycopg2.connect(dsn="...")
    with conn, conn.cursor() as cur:
        cur.execute(
            """
            UPDATE orders
            SET status = 'paid',
                paid_amount = %s,
                last_fence = %s
            WHERE id = %s
              AND status = 'pending'
              AND last_fence < %s
            """,
            [amount, fence_token, order_id, fence_token],
        )
        if cur.rowcount == 0:
            # 0 rows: hoặc token cũ, hoặc order đã paid
            # Phân biệt hai trường hợp nếu cần:
            cur.execute(
                "SELECT status, last_fence FROM orders WHERE id = %s",
                [order_id],
            )
            row = cur.fetchone()
            if row and row[1] >= fence_token:
                raise StaleFenceError(
                    f"fence_token={fence_token} bị reject (last_fence={row[1]})"
                )
            # Nếu status != 'pending': order đã xử lý → không raise, idempotent

Kịch bản double payment với fencing:

  1. Worker A acquire lock, fence=33. Worker A pause.
  2. Lock expire. Worker B acquire lock, fence=34.
  3. Worker B gọi process_payment(order_id=42, amount=100, fence_token=34). DB update thành công, last_fence=34.
  4. Worker A resume, gọi process_payment(order_id=42, amount=100, fence_token=33).
  5. UPDATE trả rowcount=033 < 34. StaleFenceError được raise.
  6. Payment không bị charge hai lần.
9

Độ Tin Cậy Của Redis INCR Làm Token Source

Trong điều kiện bình thường (single Redis instance, không failover), INCR hoàn toàn atomic và monotonic: mỗi lần gọi trả về giá trị tăng đúng 1, không bao giờ giảm hoặc trùng.

Vấn đề xảy ra khi có failover:

  • Redis dùng async replication: master ghi xong trả OK ngay, sau đó mới replicate sang replica.
  • Nếu master crash sau khi ghi INCR nhưng trước khi replicate, replica promote lên làm master với giá trị counter cũ hơn.
  • Client tiếp theo gọi INCR nhận giá trị thấp hơn giá trị đã từng được cấp trước đó — vi phạm monotonic.

Ví dụ cụ thể:

Master:  fence:order = 100. Client A acquire → INCR → 101. OK trả về.
Master crash trước khi replicate.
Replica promote: fence:order = 100 (chưa nhận được 101).
Client B acquire → INCR → 101. Hai client nhận cùng token 101.

Mitigation với Redis:

  • WAIT numreplicas timeout: sau INCR, gọi WAIT 1 100 để đợi ít nhất 1 replica confirm. Giảm window mất dữ liệu nhưng tăng latency và không đảm bảo 100% khi timeout xảy ra.
  • Dùng nguồn token mạnh hơn: etcd (Raft consensus, linearizable writes), ZooKeeper (ZAB consensus), PostgreSQL sequence (WAL-logged). Các hệ thống này có stronger durability guarantee cho counter.

Đánh giá thực tế: với nhiều use case, nguy cơ Redis INCR mất monotonic qua failover là nhỏ và có thể chấp nhận được (failover hiếm, window ngắn). Nhưng với hệ thống financial nghiêm ngặt, nên dùng nguồn token có strong consistency guarantee.

10

Fencing vs Idempotency

Hai cơ chế này giải quyết hai vấn đề khác nhau và thường bị nhầm lẫn:

Tiêu chí Fencing token Idempotency key (bài 48)
Vấn đề giải quyết Stale writer: reject write từ client không còn là lock holder hợp lệ Duplicate request: đảm bảo cùng request chạy nhiều lần chỉ có 1 effect
Cơ chế phân biệt Thứ tự: token cao hơn = lock holder mới hơn = được phép ghi Identity: cùng idempotency key = cùng request = bỏ qua lần chạy sau
Dựa vào Monotonic counter từ lock service Unique key do client generate (UUID, request ID)
Kịch bản điển hình Process pause → 2 worker khác nhau giữ lock lần lượt Network retry → cùng 1 request gửi 2 lần

Có thể kết hợp cả hai: fencing chống stale writer khi lock chuyển tay; idempotency chống double-effect khi cùng một request retry. Chúng không thay thế nhau.

11

Khi Nào Cần Và Khi Nào Không Cần Fencing

Cần fencing khi

  • Lock dùng vì mục đích correctness: hai writer gây data corruption (double payment, oversell inventory, exclusive resource write).
  • Resource hỗ trợ conditional write (DB với conditional UPDATE, versioned storage).
  • Hậu quả của hai writer đồng thời nghiêm trọng và khó rollback.

Không cần fencing khi

  • Lock dùng vì mục đích efficiency: hai worker chạy cùng lúc chỉ tốn thêm CPU/memory, không gây sai data (vd: generate report, warm cache). Race condition ở đây chỉ làm việc chạy thừa, không gây lỗi.
  • Resource không support fencing — token gửi đi nhưng không ai check, vô nghĩa.
  • Operation đã idempotent tự nhiên (chạy N lần = chạy 1 lần).
  • Dùng DB transaction trực tiếp thay vì distributed lock — DB tự đảm bảo isolation, không cần fencing từ bên ngoài.
12

Giới Hạn Thực Tế

Fencing không phải silver bullet. Các giới hạn cần nhận ra trước khi quyết định áp dụng:

  • Resource phải được sửa đổi: thêm column last_fence vào DB, thay đổi logic write. Với legacy system, third-party service, hoặc append-only log không có conditional write, fencing không áp dụng được.
  • Mọi write path phải enforce fencing: nếu có một code path nào ghi vào resource mà không check token, cơ chế vô hiệu. Cần review toàn bộ write path.
  • Redis INCR không strictly monotonic qua failover (đã đề cập ở mục 9).
  • Phức tạp hơn đáng kể: acquire trả thêm token, mọi write phải mang token, resource phải lưu và check token. Overhead về code, latency (thêm column check trong SQL), và maintenance.

Hệ quả thực tế: nhiều team chọn một trong hai hướng thay vì implement fencing đầy đủ:

  1. Dùng DB transaction trực tiếp: SELECT FOR UPDATE + update trong một transaction — DB tự đảm bảo isolation, không cần distributed lock hay fencing. Chỉ áp dụng khi tất cả write trong cùng một DB.
  2. Chấp nhận risk + implement idempotency: dùng idempotency key để đảm bảo "chạy thừa" không gây double-effect, chấp nhận rằng đôi khi operation chạy hai lần nhưng chỉ có effect một lần.

Fencing là lựa chọn phù hợp khi: distributed lock thực sự cần thiết (nhiều service, nhiều DB), resource có thể sửa được, và correctness yêu cầu zero duplicate write.

13

Anti-patterns & Best Practices

Anti-patterns

  • Correctness lock không fencing: lock đúng cách (TTL, Lua release, ownership token) nhưng không fencing → two writers khi có process pause. Vẫn là bug nghiêm trọng.
  • Fencing token nhưng resource không check: token được tạo ra và gửi đi, nhưng DB/service không thực hiện conditional write → token tồn tại nhưng vô nghĩa.
  • Token không monotonic (random UUID làm fencing token): UUID không có thứ tự tự nhiên, không thể so sánh "cái nào cũ hơn". Fencing không hoạt động.
  • Tin Redis INCR strictly monotonic qua failover mà không có mitigation: trong môi trường HA với async replication, counter có thể rollback khi failover. Cần WAIT hoặc dùng nguồn token khác cho strict use case.
  • Check token phía client trước khi ghi: race condition tương tự GET+DEL không atomic. Check và ghi phải xảy ra atomic tại resource.
  • Fencing cho efficiency lock: over-engineering. Efficiency lock chấp nhận duplicate work — không cần reject stale writer.

Best Practices

  • Correctness lock → fencing token (INCR) + resource-side conditional write.
  • Resource là arbiter cuối: DB conditional UPDATE, versioned storage conditional write.
  • Ownership token (UUID) và fencing token (integer) phục vụ hai mục đích khác nhau — giữ nguyên cả hai.
  • Efficiency lock → không cần fencing.
  • Xét nghiêm túc DB transaction (SELECT FOR UPDATE) trước khi chọn distributed lock + fencing — DB transaction đơn giản hơn và có strong guarantee.
  • Dùng WAIT hoặc nguồn token strong consistency (etcd, PostgreSQL sequence) khi yêu cầu strict monotonic.
14

Tổng Kết & Quiz

Tổng kết

  • Lock đảm bảo mutual exclusion tại Redis. Resource không biết lock state — nhận write từ cả stale client lẫn active lock holder.
  • Process pause → lock expire → hai writer đồng thời. Lock + watchdog + Lua release không giải quyết được kịch bản này.
  • Fencing token: số nguyên monotonically increasing, cấp khi acquire (Redis INCR). Client gửi kèm mọi write tới resource.
  • Resource là arbiter: lưu max_seen_token, reject write có token thấp hơn. DB dùng conditional UPDATE; storage dùng conditional write.
  • Redis INCR đủ cho đa số use case; có thể không strictly monotonic qua failover — dùng WAIT hoặc nguồn token strong consistency khi cần.
  • Fencing chống stale writer; idempotency chống duplicate request — hai vấn đề khác nhau, không thay thế nhau.
  • Cần khi correctness lock + resource có thể sửa. Không cần khi efficiency lock, resource không support, hoặc có thể thay bằng DB transaction.

Quiz

  1. Worker A acquire lock, fence=10. Lock expire. Worker B acquire lock, fence=11. B ghi resource thành công (last_fence=11). A wake up, thử ghi resource với fence=10. Điều gì xảy ra ở tầng DB và tại sao? Nếu A ghi thành công (giả sử resource không check), hậu quả là gì?
  2. Tại sao không thể dùng ownership token (UUID) làm fencing token? Cho ví dụ cụ thể về tình huống lỗi nếu cố dùng UUID.
  3. Giải thích tại sao check token phía client (trước khi gửi write tới resource) không giải quyết được vấn đề, dù check đó rất nhanh. Lỗi kỹ thuật cụ thể là gì?
  4. Redis master ghi INCR fence:order → 50. OK trả về. Master crash ngay sau đó. Replica promote với counter = 49. Client tiếp theo gọi INCR nhận 50. Điều gì sai ở đây và tại sao gây nguy hiểm cho fencing?
  5. Team bạn muốn implement fencing cho một third-party payment gateway API không có cơ chế token check. Bạn xử lý như thế nào? Liệt kê ít nhất hai phương án thay thế.

Đáp án gợi ý

  1. DB thực hiện: UPDATE orders ... WHERE last_fence < 10. Vì last_fence = 11, điều kiện 11 < 10 false → rowcount = 0 → A nhận lỗi, write bị reject. Nếu resource không check (không có fencing): A ghi đè lên kết quả của B với dữ liệu stale/sai → data của B bị mất, có thể là double charge, overwrite trạng thái đúng, hoặc data inconsistency.
  2. UUID không có thứ tự tự nhiên. Kịch bản: A acquire → tokenA = "3f2a...". B acquire → tokenB = "9c1b...". Không thể so sánh "3f2a..." < "9c1b..." để biết cái nào mới hơn — string comparison không ánh xạ tới thứ tự acquire. Nếu cố so sánh lexicographically, kết quả ngẫu nhiên và sai.
  3. Race condition: (1) A check token phía client: token mình = 33, max_seen ở resource = 32 → OK, A quyết định ghi. (2) Giữa lúc A check và A thực sự ghi, B (token=34) ghi resource trước, max_seen = 34. (3) A ghi tới resource với token=33: vì check đã xảy ra ở client trước khi B ghi, resource không có cơ hội reject. Đây là race condition giống GET+DEL không atomic — check và write phải là một thao tác atomic tại resource.
  4. Sai: hai client nhận cùng fencing token 50. Fencing yêu cầu mỗi acquire nhận token unique và monotonically increasing. Nếu hai client có cùng token, resource không thể phân biệt client nào "mới hơn" và nên từ chối client nào. Cả hai ghi với token=50 đều pass điều kiện last_fence < 50 — fencing vô hiệu, vẫn có thể có hai writer đồng thời.
  5. Phương án 1: Idempotency key — generate unique request ID, gửi kèm mỗi call tới payment gateway. Nếu gateway hỗ trợ idempotency key, cùng key = cùng request = không charge lại. Không phải fencing nhưng giải quyết được double charge qua retry. Phương án 2: DB-side state machine — dùng conditional update trên DB nội bộ: UPDATE orders SET status='charging' WHERE status='pending' (chỉ một worker thành công, worker còn lại thấy rowcount=0 và bỏ qua). Gọi payment gateway chỉ khi update thành công. Kết hợp với distributed lock cho acquire nhưng DB là arbiter thực sự.

Bài tiếp theo

Bài 47 đi vào Redlock — giải thuật multi-node lock của Redis — và tranh luận giữa Kleppmann và antirez về việc liệu Redlock có đủ an toàn cho correctness lock hay không.

Tham khảo