Bỏ qua để đến nội dung
Knowledge Hub

Advisory lock: khoá theo key nghiệp vụ khi không có row để khoá Trung cấp

Thời gian đọc ước tính: 9 phút

Cron job tính lại điểm thưởng chạy lúc 02:00 trên cả 3 pod (pod-a, pod-b, pod-c). Chạy hai lần thì điểm bị cộng đôi.

Tình huống thứ hai: hai request đăng ký an@example.com cùng lúc, cả hai thấy “email chưa tồn tại” rồi INSERT. Row-level lock không giúp được: row chưa có thì không có gì để khoá.

Khoá một key nghiệp vụ (job, email) thay vì một row bằng cách nào, và lock đó bảo đảm được gì?

Tình huống Chọn Điều kiện
Cron chỉ một pod chạy pg_try_advisory_lock + pg_advisory_unlock Connection riêng suốt job; job idempotent
Chống trùng theo key khi row chưa có pg_advisory_xact_lock Mọi đường ghi lấy cùng key
Báo bận thay vì chờ pg_try_advisory_xact_lock App xử lý false
Qua PgBouncer transaction mode Chỉ transaction-level Session-level không tương thích
“Chỉ một bên ghi” khi mạng chập chờn Lease có fencing token Advisory lock không đủ

Email không trùng trong một bảng thì UNIQUE trên lower(email) chắc hơn. Advisory lock dành cho rule constraint không diễn đạt được, như email không trùng giữa accounts và pending_signups.

Advisory lock là lock do ứng dụng quy ước ý nghĩa: PostgreSQL chỉ cấp và xếp chờ trên một con số, không gắn với bảng hay row. Code quên lấy lock vẫn ghi được.

Bảng chọn hàm (PostgreSQL 17). Session-level giữ đến khi unlock hoặc session kết thúc; transaction-level tự nhả khi transaction kết thúc và không có hàm unlock:

Hàm Phạm vi Chờ? Trả về
pg_advisory_lock(key) session có void
pg_try_advisory_lock(key) session không boolean
pg_advisory_unlock(key) session true; false + WARNING nếu không giữ
pg_advisory_unlock_all() session void, nhả mọi session lock
pg_advisory_xact_lock(key) transaction có void
pg_try_advisory_xact_lock(key) transaction không boolean
..._shared của từng hàm như trên như trên Chỉ xung đột với exclusive

Mỗi hàm nhận một key bigint hoặc hai key integer; hai không gian key không chồng nhau: pg_advisory_lock(1) khác pg_advisory_lock(0, 1). Dạng (int, int) hợp để tách loại tài nguyên và id; bigint hợp với key băm từ chuỗi.

Cách hay gặp: hashtext('signup:' || lower(email)) (32-bit) hoặc hashtextextended(..., 0) (64-bit, tham số thứ hai là seed).

  • Va chạm: hai chuỗi khác nhau có thể cùng key nên chặn nhau (chờ thừa, không mất loại trừ). Với 32-bit, theo nghịch lý ngày sinh, xác suất có cặp trùng vượt 50% ở khoảng 77.000 key. Dùng bản 64-bit.
  • Đây là hàm nội bộ, không có trong danh mục hàm của tài liệu: không lưu key băm.
  • Thêm tiền tố (signup:, cron:) để các nhóm key không đụng nhau.

Ba pod cùng gọi pg_try_advisory_lock với key của job: ai giữ, ai bỏ lượt, và lock nhớ gì sau khi nhả?

Ba pod tranh cron: chỉ pod-a nhận true, nhưng lock không ngăn một lần chạy trễ sau unlock
Ba pod tranh cron: chỉ pod-a nhận true, nhưng lock không ngăn một lần chạy trễ sau unlockBước 1/5: pod-a gọi pg_try_advisory_lock(key) trên connection riêng, nhận true và giữ lock theo session.thời giangiữ advisory lock …try_lock(key)→ truepod-acron 02:00pod-bcron 02:00pod-ccron 02:00
Bước 1/5

pod-a gọi pg_try_advisory_lock(key) trên connection riêng, nhận true và giữ lock theo session.

Bước Ai giữ lock Thay đổi
1 pod-a true, lock gắn với session
2–3 pod-a pod-b, pod-c nhận false, bỏ lượt
4 không ai unlock trên cùng connection
5 pod-c chạy trễ vẫn lấy được: cần bảng job_runs

Quy tắc hành vi (theo tài liệu, kiểm chứng ở demo):

  1. Session-level bỏ qua transaction: lock lấy trong transaction rồi ROLLBACK vẫn được giữ.
  2. Đếm reentrant: lấy cùng key 3 lần thì phải unlock 3 lần. Session đang giữ xin thêm luôn thành công.
  3. Transaction-level nhả khi COMMIT/ROLLBACK; hai loại trên cùng key chặn nhau bình thường.
  4. Session kết thúc, kể cả client ngắt đột ngột, thì mọi session lock được nhả.

Dòng có locktype = 'advisory'. Key bigint tách ở classid (nửa cao) và objid (nửa thấp), objsubid = 1; key (int, int) có objsubid = 2. Lock chỉ có hiệu lực trong một database.

SELECT a.application_name, l.granted,
(l.classid::bigint << 32) | l.objid::bigint AS key
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory';

Đặt application_name cho từng pod để biết pod nào đang giữ.

  • Advisory lock và lock thường dùng chung bảng lock trong shared memory, cỡ theo max_locks_per_transaction (mặc định 64, đổi phải restart) và max_connections. Lấy quá nhiều key có thể làm server không cấp được lock nào.
  • Gọi hàm lock kèm LIMIT cùng cấp có thể lấy lock thừa; đặt LIMIT trong subquery.
  • pg_advisory_lock (bản chờ) tuân theo lock_timeout; bản try không cần.
  • Lấy hai key theo thứ tự ngược nhau vẫn có thể deadlock.

Bẫy lớn nhất: session-level lock gắn với connection server, không với request hay pod.

Đang tải diagram…

Ở PgBouncer transaction mode, mỗi transaction có thể chạy trên connection server khác:

  1. pod-a lấy K trên conn 1; transaction xong, conn 1 về pool vẫn giữ K.
  2. pod-a unlock trên conn 2: false. K kẹt đến khi conn 1 đóng.
  3. pod-b được cấp conn 1: try_lock(K) trả true vì cùng session.

PgBouncer ghi rõ session-level advisory lock không dùng được ở transaction pooling. DISCARD ALL (gọi pg_advisory_unlock_all()) chỉ chạy khi trả connection ở session pooling.

Mẫu đăng ký khi rule trải trên hai bảng:

BEGIN;
SELECT pg_advisory_xact_lock(hashtextextended('signup:' || lower($1), 0));
-- Kiểm tra SAU khi có lock
SELECT 1 FROM accounts WHERE lower(email) = lower($1)
UNION ALL
SELECT 1 FROM pending_signups WHERE lower(email) = lower($1);
-- Rỗng thì INSERT; có thì ROLLBACK, trả 409
INSERT INTO accounts (email) VALUES ($1);
COMMIT;

Ở Read Committed, mỗi câu lấy snapshot mới nên câu kiểm tra thấy row vừa commit. Ở Repeatable Read, snapshot lấy từ câu lấy lock nên không thấy (demo dòng 7); giữ Read Committed.

Demo: ba connection trên PostgreSQL 17

Schema demo_advisory_locks, xoá khi xong. Output thật, bỏ các dòng in pg_locks:

Terminal window
npx --prefix examples ts-node examples/src/advisory-locks/demo.ts
1. key = hashtextextended('cron:recalc-reward-points', 0) = 6110350855857795459
pod-a: pg_try_advisory_lock -> true
pod-b: pg_try_advisory_lock -> false
pod-c: pg_try_advisory_lock -> false
2. pod-a lock trong BEGIN rồi ROLLBACK; pod-b try_lock -> false
3. lock 2 lần; unlock #1 -> true, pod-b try -> false; unlock #2 -> true; unlock #3 -> false
sau 2 lần unlock, pod-b try -> true
4. xact: pod-a -> true; pod-b trong lúc A mở tx -> false; pod-b sau COMMIT -> true
5. A giữ bigint 1; B try (int 0, int 1) -> true
6. signup: lock đang chờ = 1; sau khi A commit, B thấy 1 tài khoản -> không INSERT
7. Repeatable Read: sau khi có lock, B thấy 0 tài khoản -> sẽ INSERT trùng

Dòng 3: pod-a còn giữ một lần từ bước 2. Unlock thừa in WARNING: you don't own a lock of type ExclusiveLock.

Ưu điểm

  • Khoá được thứ không phải row: job, email chưa tồn tại.

  • Không ghi bảng nên không bloat; server tự dọn khi session kết thúc.

  • Có try để bỏ lượt; quan sát được bằng pg_locks.

  • < slot="cons">
  • Chỉ là quy ước: đường ghi quên lấy lock không được bảo vệ.

  • Session-level xung đột với pool và PgBouncer transaction mode.

  • Không phải lease: không thời hạn, không fencing token.

Nhược điểm

  1. Ưu tiên pg_advisory_xact_lock; session-level chỉ cho việc dài như cron, trên connection riêng.
  2. Session-level: lock và unlock trên cùng connection, hoặc đóng connection thay vì trả về pool.
  3. Qua PgBouncer transaction mode thì cấm session-level.
  4. Key: hashtextextended kèm tiền tố, hoặc (int, int); ghi danh sách key vào tài liệu.
  5. Job dưới lock phải idempotent, có bảng job_runs.
  6. Bản chờ đặt lock_timeout; bản try xử lý false.
  7. Giám sát pg_locks với locktype = 'advisory' theo application_name.

Lỗi hay gặp nhất là trộn session lock với pool của driver:

Lock và unlock qua pool.query: có thể chạy trên hai connection khác nhau

Hay gặp

await pool.query('SELECT pg_advisory_lock($1)', [key]);
await recalcRewardPoints();
await pool.query('SELECT pg_advisory_unlock($1)', [key]); // có thể false

Nên viết

const client = await pool.connect();
try {
const { rows } = await client.query('SELECT pg_try_advisory_lock($1) AS ok', [key]);
if (rows[0].ok) await recalcRewardPoints();
} finally {
client.release(true); // huỷ connection: session lock nhả theo
}

Vì sao

Mỗi pool.query mượn connection bất kỳ. Lock nằm lại trên connection đầu; request khác mượn nó “thừa hưởng” lock.

Sai lầm thứ hai là coi advisory lock như lease phân tán:

Cách hiểu sai Hệ quả Cần làm
ROLLBACK nhả session lock Lock kẹt đến khi đóng connection Unlock tường minh hoặc dùng xact lock
Unlock một lần là đủ Lấy N lần còn giữ N−1 Đếm cặp lock/unlock
Lock bảo vệ bảng Code khác ghi thẳng Mọi đường ghi lấy cùng key
try_lock = true là lượt này chưa ai chạy Pod trễ chạy lần hai Bảng job_runs
  1. pod-a gọi pg_advisory_lock(K) trong BEGIN rồi ROLLBACK. pod-b try_lock(K) nhận gì, vì sao?
  2. Vì sao qua PgBouncer transaction mode, pod-b có thể nhận true cho key mà pod-a đang giữ?
  3. Vì sao mẫu đăng ký ở Repeatable Read vẫn tạo trùng?