Kembali ke jurnalCATATAN FAJAR
Software Engineering6 menit baca

PostgreSQL Advisory Lock: Fungsinya dan Kapan Memakainya

Pakai PostgreSQL advisory lock untuk mengoordinasikan pekerjaan aplikasi, dengan pilihan yang eksplisit soal kepemilikan dan masa hidup lock.

Di artikel ini 15 bagian

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.

sql
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:

mermaid
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.

sql
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

sql
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.

mermaid
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

sql
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):

sql
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:

sql
-- 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:

sql
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:

sql
-- 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
typescript
// 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:

typescript
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:

typescript
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:

mermaid
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.

typescript
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.

typescript
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:

mermaid
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:

typescript
// 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.

TOPIK

MAKASIH UDAH BACA

Gimana menurutmu?

Reaksi atau obrolan, dua-duanya selalu ditunggu.

Memuat reaksi…

Bagikan

Memuat komentar...

LANJUT JELAJAH

Satu pikiran bawa ke pikiran lain.

Semua tulisan
Kembali ke semua tulisanSatu catatan, pelan-pelan.