PostgreSQL advisory lock mengoordinasikan operasi lewat key yang dipilih oleh aplikasi. Advisory lock nggak mengunci row di tabel. Saya ketemu fitur ini di kerjaan waktu pakai PgBoss, dan saya pengin paham bedanya dengan FOR UPDATE.
Pengingat Singkat: Transaction dan Batasannya
Pengingat singkat soal transaction membantu memberi konteks untuk advisory lock.
Transaction menjamin atomicity: semua statement dalam satu grup berhasil, atau nggak ada satu pun yang berhasil.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Transaction ini meng-commit kedua update, atau nggak sama sekali.
Transaction cuma mengoordinasikan pekerjaan di database. Kalau transaction kamu memanggil API eksternal (mengirim email, memanggil payment gateway), panggilan eksternal itu bukan bagian dari transaction. Kalau transaction-nya rollback setelah API dipanggil, email-nya udah terkirim. Database nggak bisa membatalkan itu.
Race Condition Klasik
Saya pernah lihat race ini di production. Dua worker membaca saldo akun yang sama di waktu yang sama:
sequenceDiagram
participant W1 as Worker 1
participant DB as Database
participant W2 as Worker 2
W1->>DB: SELECT balance WHERE id=1 (gets $40)
W2->>DB: SELECT balance WHERE id=1 (gets $40)
W1->>DB: UPDATE balance = 40 - 30 = $10
W2->>DB: UPDATE balance = 40 - 30 = $10
Note over DB: Balance is $10, but $60 was withdrawn from $40!
Kedua worker membaca $40, keduanya menyetujui debit $30, keduanya menulis $10. Seharusnya saldo akunnya -$20 (atau transaction kedua seharusnya ditolak), tapi malah $10. Race condition check then act yang klasik.
Solusinya: pindahkan pengecekan invariant ke dalam SQL-nya sendiri.
UPDATE accounts
SET balance = balance - 30
WHERE id = 1 AND balance >= 30;
Sekarang pengecekan dan penulisan jadi satu operasi atomic. Kalau saldonya di bawah $30 waktu update kedua jalan, update itu nggak mengenai row apa pun. Aplikasinya bisa menolak debit itu.
Row Level Lock
Kadang kamu butuh kontrol yang lebih dari sekadar atomic update. Di situlah explicit row locking berperan.
FOR UPDATE
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
Query ini mengambil exclusive lock di row yang dipilih. Transaction lain yang mencoba SELECT ... FOR UPDATE atau UPDATE row yang sama bakal block sampai transaction pertama commit atau rollback.
sequenceDiagram
participant T1 as Transaction 1
participant DB as Database
participant T2 as Transaction 2
T1->>DB: SELECT ... FOR UPDATE (row locked)
T2->>DB: SELECT ... FOR UPDATE (BLOCKED)
T1->>DB: UPDATE ... SET balance = 10
T1->>DB: COMMIT
T2->>DB: Lock acquired, gets fresh data
T2->>DB: UPDATE ... SET balance = ...
T2->>DB: COMMIT
Row lock menjaga kebenaran data, tapi membatasi throughput. Setiap writer yang jalan bersamaan harus antre.
FOR SHARE
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
Beberapa transaction bisa memegang shared lock bersamaan karena semuanya cuma membaca. Tapi nggak ada transaction yang bisa menulis selama shared lock masih dipegang.
Shared row lock bisa menunda write yang konflik sampai transaction-nya selesai. Jaga transaction ini tetap pendek dan ukur waktu tunggu lock-nya. Lihat aturan konflik lock di PostgreSQL.
SKIP LOCKED
Yang satu ini terutama relevan untuk job queue (dan begini cara kerja PgBoss di dalamnya):
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
Opsi SKIP LOCKED melewati row yang punya row lock yang konflik. Jadi beberapa worker bisa mengklaim job yang berbeda dari tabel yang sama. Opsi ini nggak menghilangkan table lock atau semua contention.
Advisory Lock
Advisory lock dipakai kalau hal yang dikoordinasikan bukan row di tabel. Advisory lock bisa merepresentasikan:
- Panggilan API eksternal yang unik dan nggak boleh terduplikasi
- Pipeline pemrosesan untuk customer tertentu
- Migration atau perubahan schema yang cuma boleh dijalankan oleh satu proses
- Proses aplikasi yang harus mengoordinasikan pekerjaan antar instance
Aplikasi yang memilih integer key untuk lock ini. PostgreSQL nggak otomatis menerapkannya ke data. Koordinasinya cuma jalan kalau semua kode yang relevan mengambil lock yang sama.
Scope Session vs Transaction
Lock level session dipegang sampai kamu melepasnya secara eksplisit atau koneksinya ditutup:
-- Acquire
SELECT pg_advisory_lock(42);
-- Do your work...
-- Release
SELECT pg_advisory_unlock(42);
Kalau kodenya gagal di antara pengambilan dan pelepasan lock, session-nya bisa tetap memegang lock sampai koneksinya ditutup.
Lock level transaction otomatis dilepas waktu transaction-nya commit atau rollback:
BEGIN;
SELECT pg_advisory_xact_lock(42);
-- Do your work...
COMMIT; -- Lock automatically released
Utamakan advisory lock dengan scope transaction kalau pekerjaannya muat dalam satu transaction. PostgreSQL melepasnya waktu transaction selesai.
Varian Try yang Non Blocking
Kadang kamu nggak mau menunggu lock. Kamu cuma mau mencoba, lalu lanjut kalau lock-nya udah diambil:
-- Returns true if lock acquired, false if not
SELECT pg_try_advisory_xact_lock(42);
Pakai varian try untuk:
- Proses worker yang harus melewati pekerjaan yang udah dikerjakan worker lain
- Endpoint health check yang nggak boleh block
- Graceful degradation waktu contention tinggi
// Non-blocking advisory lock in application code
async function processCustomerExclusive(customerId: number): Promise<boolean> {
const result = await db.query(
"SELECT pg_try_advisory_xact_lock($1) as acquired",
[customerId]
);
if (!result.rows[0].acquired) {
// Another worker is already processing this customer
return false;
}
// We have the lock, safe to process
await processCustomerData(customerId);
return true;
}
Key Packing
Advisory lock menerima satu bigint atau sepasang nilai int sebagai key. Tapi sering kali key lock kamu berupa string (kayak customer ID atau job type). Kamu perlu mengubahnya jadi integer:
import { createHash } from "crypto";
function advisoryKey(namespace: string): bigint {
const hash = createHash("sha256").update(namespace).digest();
// Read first 8 bytes as a signed 64-bit integer
return hash.readBigInt64BE(0);
}
// Usage
const lockKey = advisoryKey("process-customer:cust_12345");
await db.query("SELECT pg_advisory_xact_lock($1)", [lockKey]);
Hash collision mungkin terjadi, tapi kecil kemungkinannya. Collision berarti dua operasi yang nggak berhubungan saling menunggu, yang menurunkan throughput tapi nggak merusak data.
Untuk composite key, kamu bisa memadatkan dua ID 32-bit ke dalam satu key 64-bit:
function packKeys(a: number, b: number): bigint {
return (BigInt(a) << 32n) | (BigInt(b) & 0xFFFFFFFFn);
}
// Lock on (tenantId=5, resourceId=42)
const lockKey = packKeys(5, 42);
Mencegah Deadlock: Total Ordering
Kalau kamu perlu mengambil beberapa advisory lock, kamu harus selalu mengambilnya dengan urutan yang sama. Kalau nggak, kamu bakal kena deadlock:
graph LR
T1[Transaction 1] -->|holds Lock A| LA[Lock A]
T1 -->|wants Lock B| LB[Lock B]
T2[Transaction 2] -->|holds Lock B| LB
T2 -->|wants Lock A| LA
style LA fill:#fecaca
style LB fill:#fecaca
Transaction 1 memegang A, butuh B. Transaction 2 memegang B, butuh A. Nggak ada yang bisa lanjut. Deadlock.
Solusinya: urutkan key lock kamu dan selalu ambil dengan urutan menaik.
async function acquireLocksInOrder(keys: bigint[]): Promise<void> {
// Sort to prevent deadlocks
const sorted = [...keys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
for (const key of sorted) {
await db.query("SELECT pg_advisory_xact_lock($1)", [key]);
}
}
Pola Fetch Lock Refetch
Pola fetch lock refetch penting kalau kamu perlu mengunci berdasarkan hasil query. Datanya bisa berubah di antara query awal dan pengambilan lock.
Fetch dulu, kunci key-nya, fetch ulang row-nya, lalu bandingkan.
async function processReadyJobs(): Promise<void> {
await db.query("BEGIN");
try {
// 1. Unsafe fetch: get candidate rows
const candidates = await db.query(
"SELECT id FROM jobs WHERE status = 'ready' LIMIT 10"
);
if (candidates.rows.length === 0) {
await db.query("COMMIT");
return;
}
// 2. Acquire locks in sorted order
const ids = candidates.rows.map((r) => r.id).sort();
for (const id of ids) {
await db.query("SELECT pg_advisory_xact_lock($1)", [id]);
}
// 3. Refetch with locks held
const confirmed = await db.query(
"SELECT id FROM jobs WHERE id = ANY($1) AND status = 'ready'",
[ids]
);
// 4. Compare: only process jobs that are still ready
for (const job of confirmed.rows) {
await processJob(job.id);
}
await db.query("COMMIT");
} catch (error) {
await db.query("ROLLBACK");
throw error;
}
}
Di antara langkah 1 dan langkah 2, transaction lain mungkin udah memproses job-nya. Pengambilan lock bisa menunggu tanpa batas kecuali dibatasi oleh timeout. Baca ulang row-nya setelah lock didapat untuk mengecek state terbarunya.
Kapan Pakai yang Mana
Decision tree-nya seperti ini:
graph TD
A[Need to coordinate concurrent access?] --> B{Operating on existing rows?}
B -->|Yes| C{Need exclusive write access?}
C -->|Yes| D[FOR UPDATE]
C -->|No, readers are fine| E[FOR SHARE]
B -->|No| F{Need to lock a logical concept?}
F -->|Yes| G[Advisory Lock]
F -->|No| H[Maybe you don't need locking]
G --> I{Inside a transaction?}
I -->|Yes| J[pg_advisory_xact_lock]
I -->|No| K[pg_advisory_lock + careful cleanup]
Pakai row lock (FOR UPDATE, FOR SHARE) kalau kamu melindungi data yang beneran ada di tabel.
Pakai advisory lock kalau kamu mengoordinasikan sebuah konsep: job type, pipeline customer, proses migration, panggilan API eksternal.
Pakai SKIP LOCKED kalau kamu butuh semantik job queue: beberapa worker mengambil dari pool yang sama tanpa saling block.
Lock schema di PgBoss dan contoh maintenance
Kerja dengan PgBoss di Xendit membantu saya memahami gimana PgBoss pakai advisory lock di dalamnya.
Dokumentasi PgBoss saat ini menyebut pg_advisory_xact_lock sebagai koordinasi untuk pembuatan schema dan migration. Lihat dokumentasi database backend.
Snippet berikut menggambarkan pola maintenance leader yang terpisah. Ini bukan implementasi PgBoss saat ini yang terdokumentasi:
// Simplified version of what PgBoss does internally
const result = await db.query(
"SELECT pg_try_advisory_lock($1) as acquired",
[MAINTENANCE_LOCK_KEY]
);
if (result.rows[0].acquired) {
// This instance owns maintenance: archive old jobs,
// expire stalled jobs, schedule cron jobs
await runMaintenance();
}
Di pola ilustrasi ini, instance-instance yang bekerja sama pakai key lock yang sama. Satu instance menjalankan maintenance selama memegang lock. Instance lain tetap memproses job.
Contohnya pakai session lock karena maintenance berjalan di beberapa transaction. PostgreSQL melepas lock-nya waktu session berakhir. Setelah itu instance lain bisa mengambilnya.
Saya juga pernah menyelidiki maintenance PgBoss yang tertunda di production. Di setup itu, pemrosesan job membebani instance yang bertanggung jawab atas maintenance, sehingga tugas seperti job expiration jadi tertunda. Kami memisahkan maintenance dari worker pool. Pengalaman itu menggambarkan deployment kami, bukan jaminan soal internal setiap versi PgBoss.
Penutup
Coba saya paham advisory lock dari dulu. Di kantor-kantor sebelumnya, kami membangun koordinasi pakai Redis lock atau mutex di aplikasi, padahal PostgreSQL udah tersedia. Advisory lock-nya bisa jadi opsi lain.
Sekarang saya mempertimbangkan advisory lock kalau worker-worker yang bekerja sama butuh akses eksklusif ke sebuah operasi. Key lock-nya bisa mengidentifikasi customer, migration, atau panggilan API eksternal.
Kalau kamu udah pakai PostgreSQL, advisory lock bisa mengoordinasikan job type, pipeline customer, migration, atau panggilan eksternal. Untuk perbandingan PgBoss dan BullMQ, lihat PgBoss vs BullMQ.

Memuat komentar...