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
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”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ì?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| 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.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”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 |
Key: bigint hay (int, int)
Phần tiêu đề “Key: bigint hay (int, int)”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.
Băm chuỗi thành key
Phần tiêu đề “Băm chuỗi thành key”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.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”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ả?
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):
- Session-level bỏ qua transaction: lock lấy trong transaction rồi
ROLLBACKvẫn được giữ. - Đế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.
- Transaction-level nhả khi
COMMIT/ROLLBACK; hai loại trên cùng key chặn nhau bình thường. - Session kết thúc, kể cả client ngắt đột ngột, thì mọi session lock được nhả.
Quan sát trong pg_locks
Phần tiêu đề “Quan sát trong pg_locks”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 keyFROM 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ữ.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Advisory lock và lock thường dùng chung bảng lock trong shared memory, cỡ theo
max_locks_per_transaction(mặc định64, đổ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
LIMITcùng cấp có thể lấy lock thừa; đặtLIMITtrong subquery. pg_advisory_lock(bản chờ) tuân theolock_timeout; bảntrykhô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:
- pod-a lấy K trên conn 1; transaction xong, conn 1 về pool vẫn giữ K.
- pod-a unlock trên conn 2:
false. K kẹt đến khi conn 1 đóng. - pod-b được cấp conn 1:
try_lock(K)trảtruevì 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.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”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ó lockSELECT 1 FROM accounts WHERE lower(email) = lower($1)UNION ALLSELECT 1 FROM pending_signups WHERE lower(email) = lower($1);-- Rỗng thì INSERT; có thì ROLLBACK, trả 409INSERT 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:
npx --prefix examples ts-node examples/src/advisory-locks/demo.ts1. 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 -> false2. pod-a lock trong BEGIN rồi ROLLBACK; pod-b try_lock -> false3. 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 -> true4. xact: pod-a -> true; pod-b trong lúc A mở tx -> false; pod-b sau COMMIT -> true5. A giữ bigint 1; B try (int 0, int 1) -> true6. signup: lock đang chờ = 1; sau khi A commit, B thấy 1 tài khoản -> không INSERT7. Repeatable Read: sau khi có lock, B thấy 0 tài khoản -> sẽ INSERT trùngDò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.
Trade-offs
Phần tiêu đề “Trade-offs”Ư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ằngpg_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
Checklist production
Phần tiêu đề “Checklist production”- Ưu tiên
pg_advisory_xact_lock; session-level chỉ cho việc dài như cron, trên connection riêng. - Session-level: lock và unlock trên cùng connection, hoặc đóng connection thay vì trả về pool.
- Qua PgBouncer transaction mode thì cấm session-level.
- Key:
hashtextextendedkèm tiền tố, hoặc(int, int); ghi danh sách key vào tài liệu. - Job dưới lock phải idempotent, có bảng
job_runs. - Bản chờ đặt
lock_timeout; bảntryxử lýfalse. - Giám sát
pg_locksvớilocktype = 'advisory'theoapplication_name.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”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ể falseNê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 |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- pod-a gọi
pg_advisory_lock(K)trongBEGINrồiROLLBACK. pod-btry_lock(K)nhận gì, vì sao? - Vì sao qua PgBouncer transaction mode, pod-b có thể nhận
truecho key mà pod-a đang giữ? - Vì sao mẫu đăng ký ở Repeatable Read vẫn tạo trùng?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — Advisory Locks: session vs transaction, rollback, reentrant, cảnh báo
LIMIT. - PostgreSQL 17 — Advisory Lock Functions: chữ ký hàm, hai không gian key.
- PostgreSQL 17 — pg_locks: key hiện ở cột nào.
- PostgreSQL 17 — max_locks_per_transaction: kích thước bảng lock.
- PgBouncer — Features: tính năng không dùng được ở transaction pooling.