Postgres Advisory Lock: điều phối worker không cần Redis — hoatq.dev

cat blog/.md

Postgres Advisory Lock: điều phối worker không cần Redis

date: tags: backend, postgres, distributed-systems, concurrency, database

Có một class vấn đề rất phổ biến: cần đảm bảo chỉ một worker đang chạy một đoạn code tại một thời điểm. Cron job nightly aggregate, leader election cho consumer Kafka, lock theo entity để tránh race trong nghiệp vụ. Phản xạ đầu tiên của hầu hết người là quẳng Redis vào: SET key val NX EX 30. Đơn giản, quen tay.

Nhưng nếu hệ thống đã có Postgres và bạn đang phân vân có nên thêm Redis cho mỗi cái lock nhỏ này — câu trả lời thường là không cần. Postgres có sẵn pg_advisory_lock và nó tốt hơn bạn nghĩ.

Advisory lock là gì

Khác với row-level lock (SELECT ... FOR UPDATE khoá một dòng dữ liệu cụ thể), advisory lock là lock thuần ý niệm: bạn tự định nghĩa khoá là một số 64-bit nào đó, Postgres giúp bạn coordinate ai giữ khoá đó. Không cần bảng. Không khoá dữ liệu thật. Không cản queries khác chạy.

Hai họ hàm:

-- Session-level: giữ cho đến khi session đóng hoặc tự release
SELECT pg_advisory_lock(12345);
SELECT pg_advisory_unlock(12345);
SELECT pg_try_advisory_lock(12345);    -- non-blocking, trả về true/false

-- Transaction-level: tự release khi COMMIT/ROLLBACK
SELECT pg_advisory_xact_lock(12345);
SELECT pg_try_advisory_xact_lock(12345);

Khoá có thể là một bigint đơn (pg_advisory_lock(123)) hoặc cặp int + int (pg_advisory_lock(456, 789)) — cặp tiện khi bạn muốn phân namespace, ví dụ (job_type_id, entity_id).

Use case 1: chống cron chạy trùng

Đây là trường hợp tôi hay gặp nhất. Service deploy 3 pod trên Kubernetes. CronJob của K8s đôi khi sinh ra hai job song song vì retry, hoặc bạn dùng APScheduler bên trong app và quên rằng nó chạy trên cả 3 pod. Kết quả: aggregate query chạy 3 lần, log đẹp ra báo cáo gửi 3 lần.

Giải pháp truyền thống: leader election qua Redis hoặc Zookeeper. Giải pháp với Postgres: 5 dòng.

import psycopg
from contextlib import contextmanager

JOB_LOCK_KEY = 919001  # bất kỳ số nào, miễn unique trong scope

@contextmanager
def try_acquire_job_lock(conn, key: int):
    with conn.cursor() as cur:
        cur.execute("SELECT pg_try_advisory_lock(%s)", (key,))
        got = cur.fetchone()[0]
        if not got:
            yield False
            return
        try:
            yield True
        finally:
            cur.execute("SELECT pg_advisory_unlock(%s)", (key,))

def nightly_aggregate(conn):
    with try_acquire_job_lock(conn, JOB_LOCK_KEY) as got:
        if not got:
            log.info("another instance is running, skip")
            return
        # ... chạy aggregate ở đây
        run_heavy_aggregate(conn)

pg_try_advisory_lock non-blocking, trả về false ngay nếu lock đang bị giữ. Cron của 2 pod kia sẽ thoát ngay lập tức, log skip một dòng đẹp. Pod thắng lock chạy job đến hết, release.

Không cần Redis. Không cần thêm bảng. Nếu pod crash giữa chừng, connection đóng, Postgres tự release session-level lock.

Use case 2: lock theo entity trong nghiệp vụ

Tình huống: API POST /orders/{order_id}/confirm có thể bị bấm 2 lần trong vài giây (user double-click, hoặc retry từ frontend). Code bên trong làm nhiều thứ: validate, trừ kho, gọi payment, gửi event. Idempotency key thì có rồi nhưng vẫn muốn serialize hai request cùng một order_id để chúng không tranh nhau trong một transaction phức tạp.

Đây là chỗ pg_advisory_xact_lock toả sáng:

def confirm_order(conn, order_id: int):
    with conn.transaction():
        with conn.cursor() as cur:
            # Lock theo (namespace=1 cho "order_confirm", key=order_id)
            cur.execute("SELECT pg_advisory_xact_lock(%s, %s)", (1, order_id))

            # Từ đây cho đến hết transaction, không ai khác làm cùng order này
            order = fetch_order(cur, order_id)
            if order.status != "pending":
                return  # idempotent: ai đó vừa confirm xong rồi
            reserve_stock(cur, order)
            charge_payment(order)
            mark_confirmed(cur, order_id)
        # COMMIT release lock tự động

Khác biệt quan trọng so với SELECT ... FOR UPDATE:

  • Không phải khoá row → các query đọc khác (vd dashboard) vẫn chạy bình thường
  • Khoá theo logical key (1, order_id) → có thể lock cả một workflow trước khi đụng dữ liệu
  • Auto-release theo transaction, không lo quên unlock

Pattern cặp (namespace, entity_id) rất hữu ích để tránh va chạm: dùng (1, x) cho “confirm order”, (2, x) cho “cancel order”, (3, x) cho “refund order” — cùng x nhưng namespace khác nên không block lẫn nhau.

Use case 3: distributed lock có TTL ngầm

So với Redis SET NX EX, advisory lock có một property dễ chịu: TTL chính là vòng đời của connection. Worker chết → kernel đóng socket → Postgres dọn session → lock release. Không có vụ “lock bị stuck vì server crash mà TTL đặt 30 phút”.

Đổi lại, nếu bạn cố tình muốn TTL ngắn để worker sống mà giữ lock quá lâu cũng bị tước (ví dụ stuck I/O 5 phút), thì advisory lock không cho. Trường hợp đó vẫn cần Redis/etcd với heartbeat.

Hai sai lầm rất dễ mắc với connection pool

Đây là chỗ đa số người mới dùng advisory lock đụng phải. Bản chất: session-level lock gắn với connection, không phải với “application logic”. Khi dùng connection pool (PgBouncer, SQLAlchemy pool, HikariCP), connection bạn vừa lấy lock có thể bị trả về pool và lock vẫn còn đó.

Sai lầm 1: session-level lock + PgBouncer transaction-mode.

PgBouncer chạy mặc định ở pool_mode = transaction. Khi transaction kết thúc, PgBouncer trả backend connection về pool và phục vụ request khác. pg_advisory_lock session-level sẽ “đi cùng” backend connection — request tiếp theo, một client hoàn toàn không liên quan, có thể vô tình giữ lock của bạn.

Cách xử lý:

  • Dùng pg_advisory_xact_lock (transaction-level) — vì nó release theo COMMIT/ROLLBACK, an toàn với PgBouncer transaction mode
  • Hoặc bypass PgBouncer cho connection chuyên dụng làm cron lock (kết nối thẳng vào Postgres port 5432)

Sai lầm 2: SQLAlchemy pool_pre_ping không reset lock.

Pool ORM chỉ kiểm tra connection còn sống, không tự gọi pg_advisory_unlock_all(). Nếu code crash giữa chừng và quên unlock, connection vẫn còn lock khi được tái sử dụng. Một thói quen tốt là gọi pg_advisory_unlock_all() mỗi lần lấy connection ra khỏi pool, hoặc đơn giản hơn — chỉ dùng transaction-level lock cho mọi business logic, session-level chỉ cho cron có connection riêng dài hạn.

Một biến thể hữu ích: pg_try_advisory_xact_lock cho idempotency

Có cách dùng rất gọn cho idempotency-by-design:

def process_webhook_event(conn, event_id: int):
    with conn.transaction():
        with conn.cursor() as cur:
            cur.execute("SELECT pg_try_advisory_xact_lock(%s, %s)", (42, event_id))
            got = cur.fetchone()[0]
            if not got:
                # Webhook đang được xử lý ở request khác — bỏ qua
                return {"status": "in_progress"}
            # Xử lý event...

Hai webhook trùng event_id đến cùng lúc: một thắng lock và xử lý, một thua thì biết ngay đang có request khác lo. Đỡ phải làm SELECT ... FOR UPDATE NOWAIT rồi bắt exception.

Khi nào ĐỪNG dùng advisory lock

Vài tình huống advisory lock không phải lựa chọn tốt:

  • Lock kéo dài nhiều phút/giờ. Connection giữ lock không thể trả về pool. Đốt một slot trong pool suốt thời gian đó — pool nhỏ sẽ cạn.
  • Lock xuyên service. Nếu service A và B đều cần phối hợp một resource và chúng nối khác Postgres cluster, advisory lock vô dụng. Lúc đó Redis/etcd/Consul mới đúng.
  • Cần fairness / queue order. Advisory lock không FIFO. Ai may là thắng. Cần xếp hàng → dùng SELECT ... FOR UPDATE SKIP LOCKED (đã có bài về Postgres làm job queue).
  • Hot path latency-critical. Mỗi pg_advisory_lock tốn round-trip mạng. Với endpoint p99 sub-10ms, hai round-trip thêm là đáng kể. Cân nhắc Redis nếu Postgres không cùng VPC.

Quick debug

Khi cần biết ai đang giữ lock nào:

SELECT
  locktype, classid, objid, mode, granted, pid
FROM pg_locks
WHERE locktype = 'advisory';

Kết hợp với pg_stat_activity qua pid để tra ra query đang chạy. Cực hữu ích lúc 2 giờ sáng nghi ngờ cron stuck:

SELECT a.pid, a.usename, a.query, a.state, now() - a.query_start AS duration
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory';

Tóm lại

pg_advisory_lock là vũ khí cực kỳ tiện trong vali backend, và lý do duy nhất ít người dùng là vì… ít người biết. Nếu hệ thống của bạn đã có Postgres và đang định kéo Redis vào chỉ để làm lock — dừng lại một nhịp, xem có thể dùng advisory lock không. Một call SQL gọn gàng, auto-release theo transaction, transactional consistency cùng business data, không thêm moving part vào infra.

Quy tắc tôi đang dùng:

  • Cron có connection dài hạn riêng → pg_advisory_lock session-level
  • Lock trong business transaction → pg_advisory_xact_lock (lúc nào cũng vậy nếu đi qua PgBouncer)
  • Webhook/idempotency dạng “ai tới trước làm” → pg_try_advisory_xact_lock
  • Lock xuyên service hoặc cần TTL ngắn-dù-worker-còn-sống → Redis

Lần tới ai đó trong team đề xuất “kéo Redis vào để làm cron lock” — gửi họ link tới pg_advisory_lock trước.

// reactions


cat comments.log


hoatq@dev : ~/blog $