PostgreSQL advisory locks untuk alur kerja aman dari pemrosesan ganda
Pelajari advisory locks PostgreSQL untuk menghentikan pemrosesan ganda pada persetujuan, penagihan, dan scheduler dengan pola praktis, potongan SQL, dan pemeriksaan sederhana.

Masalah sebenarnya: dua proses melakukan pekerjaan yang sama
Pemrosesan ganda terjadi ketika item yang sama ditangani dua kali karena dua aktor berbeda sama-sama mengira mereka bertanggung jawab. Dalam aplikasi nyata, ini muncul sebagai pelanggan yang ditagih dua kali, persetujuan yang diterapkan dua kali, atau email “invoice siap” yang terkirim dua kali. Semua terlihat baik saat pengujian, lalu rusak di bawah beban lalu lintas nyata.
Biasanya terjadi ketika timing menjadi rapat dan lebih dari satu hal bisa bertindak:
Dua worker mengambil job yang sama pada waktu bersamaan. Retry dijalankan karena panggilan jaringan lambat, sementara percobaan pertama masih berjalan. Pengguna mengklik Approve dua kali karena UI sempat membeku. Dua scheduler saling tumpang tindih setelah deploy atau karena jam yang bergeser. Bahkan satu ketukan bisa menjadi dua permintaan jika aplikasi mobile mengirim ulang setelah timeout.
Bagian yang menyakitkan adalah setiap aktor berperilaku “masuk akal” sendiri-sendiri. Bug ada di celah antara mereka: tidak ada yang tahu bahwa yang lain sudah memproses record yang sama.
Tujuannya sederhana: untuk setiap item (pesanan, permintaan persetujuan, faktur), hanya satu aktor yang boleh melakukan pekerjaan kritis pada satu waktu. Yang lain harus menunggu sebentar atau mundur dan mencoba lagi.
PostgreSQL advisory locks bisa membantu. Mereka memberi cara ringan untuk mengatakan “saya sedang mengerjakan item X” menggunakan database yang sudah Anda percayai untuk konsistensi.
Tetapkan ekspektasi: lock bukanlah sistem antrean penuh. Ia tidak akan menjadwalkan job untuk Anda, menjamin urutan, atau menyimpan pesan. Ini adalah gerbang keselamatan di sekitar bagian alur kerja yang tidak boleh berjalan dua kali.
Apa itu dan bukan: advisory locks PostgreSQL
PostgreSQL advisory locks adalah cara untuk memastikan hanya satu worker yang melakukan sepotong kerja pada satu waktu. Anda memilih kunci lock (mis. “invoice 123”), meminta database mengunci, melakukan kerja, lalu melepaskannya.
Kata “advisory” penting. Postgres tidak tahu arti kunci Anda, dan tidak akan melindungi apa pun secara otomatis. Ia hanya melacak satu fakta: kunci ini terkunci atau tidak. Kode Anda harus sepakat pada format kunci dan harus mengambil lock sebelum menjalankan bagian yang berisiko.
Juga berguna untuk membandingkan advisory locks dengan row locks. Row locks (seperti SELECT ... FOR UPDATE) melindungi baris tabel nyata. Mereka bagus ketika pekerjaan cocok dengan satu baris. Advisory locks melindungi kunci yang Anda pilih, berguna ketika alur kerja menyentuh banyak tabel, memanggil layanan eksternal, atau dimulai sebelum baris ada.
Advisory locks berguna ketika Anda membutuhkan:
- Aksi satu per satu per entitas (satu persetujuan per permintaan, satu penagihan per invoice)
- Koordinasi di antara beberapa server aplikasi tanpa menambah layanan penguncian terpisah
- Perlindungan di sekitar langkah alur kerja yang lebih besar daripada pembaruan satu baris
Mereka bukan pengganti alat keselamatan lain. Mereka tidak membuat operasi idempoten, tidak menerapkan aturan bisnis, dan tidak akan menghentikan duplikasi jika jalur kode lupa mengambil lock.
Sering disebut “ringan” karena Anda dapat menggunakannya tanpa perubahan skema atau infrastruktur tambahan. Dalam banyak kasus, Anda bisa memperbaiki pemrosesan ganda dengan menambahkan satu panggilan lock di sekitar bagian kritis sambil menjaga desain lainnya tetap sama.
Tipe lock yang akan Anda gunakan
Saat orang mengatakan “PostgreSQL advisory locks,” mereka biasanya merujuk pada beberapa fungsi. Memilih yang tepat mengubah apa yang terjadi pada kesalahan, timeout, dan retry.
Session vs transaction locks
Session-level lock (pg_advisory_lock) bertahan selama koneksi database hidup. Itu berguna untuk worker yang berjalan lama, tapi juga berarti lock bisa tersisa jika aplikasi crash dengan cara yang meninggalkan koneksi pool menggantung.
Transaction-level lock (pg_advisory_xact_lock) terikat pada transaksi saat ini. Saat Anda commit atau rollback, PostgreSQL melepaskannya otomatis. Untuk kebanyakan workflow request-response (persetujuan, klik billing, aksi admin), ini default yang lebih aman karena lebih sulit lupa melepaskannya.
Blocking vs try-lock
Blocking calls menunggu sampai lock tersedia. Sederhana, tapi bisa membuat request web terasa macet jika sesi lain memegang lock.
Try-lock mengembalikan hasil segera:
pg_try_advisory_lock(session-level)pg_try_advisory_xact_lock(transaction-level)
Try-lock sering lebih baik untuk aksi UI. Jika kunci dipegang, Anda bisa mengembalikan pesan jelas seperti “Sudah diproses” dan minta pengguna mencoba lagi.
Shared vs exclusive
Exclusive locks adalah “satu per satu.” Shared locks mengizinkan beberapa pemegang tetapi memblokir exclusive lock. Sebagian besar masalah pemrosesan ganda menggunakan exclusive locks. Shared locks berguna ketika banyak pembaca bisa lanjut, tapi penulis yang jarang harus berjalan sendiri.
Cara lock dilepas
Pelepasan bergantung pada jenisnya:
- Session locks: dilepas saat disconnect, atau secara eksplisit dengan
pg_advisory_unlock - Transaction locks: dilepas otomatis ketika transaksi berakhir
Memilih kunci yang tepat
Advisory lock hanya bekerja jika setiap worker mencoba mengunci kunci yang persis sama untuk potongan kerja yang sama. Jika satu jalur kode mengunci “invoice 123” dan jalur lain mengunci “customer 45,” Anda masih bisa mendapat duplikasi.
Mulailah dengan menamai “benda” yang ingin Anda lindungi. Buat konkret: satu invoice, satu permintaan persetujuan, satu run tugas terjadwal, atau siklus penagihan bulanan satu pelanggan. Pilihan itu menentukan seberapa banyak konkurensi yang Anda izinkan.
Pilih scope yang sesuai risiko
Sebagian besar tim berakhir dengan salah satu ini:
- Per record: paling aman untuk persetujuan dan invoice (kunci berdasarkan invoice_id atau request_id)
- Per customer/account: berguna saat aksi harus diserialisasi per pelanggan (penagihan, perubahan kredit)
- Per langkah workflow: ketika langkah berbeda bisa berjalan paralel, tetapi setiap langkah harus satu-per-satu
Anggap scope sebagai keputusan produk, bukan detail database. “Per record” mencegah klik ganda membuat dua biaya. “Per customer” mencegah dua background job menghasilkan statement yang tumpang tindih.
Pilih strategi kunci yang stabil
Umumnya ada dua opsi: dua integer 32-bit (sering digunakan sebagai namespace + id), atau satu integer 64-bit (bigint), kadang dibuat dengan hashing string ID.
Kunci dua-int mudah distandarisasi: pilih nomor namespace tetap per workflow (mis. approvals vs billing), dan gunakan record ID sebagai nilai kedua.
Hashing berguna saat identifier Anda adalah UUID, tetapi Anda harus menerima risiko tabrakan kecil dan konsisten menggunakannya di mana-mana.
Apa pun yang Anda pilih, tuliskan formatnya dan tempatkan sentral. “Hampir sama” di dua tempat sering menjadi cara umum memperkenalkan kembali duplikasi.
Langkah demi langkah: pola aman untuk pemrosesan satu-per-satu
Workflow advisory-lock yang baik sederhana: lock, verifikasi, bertindak, catat, commit. Lock bukanlah aturan bisnis sendiri. Ia adalah pembatas yang membuat aturan itu andal saat dua worker mengenai record yang sama bersamaan.
Pola praktis:
- Buka transaksi ketika hasil harus atomik.
- Ambil lock untuk unit kerja spesifik. Lebih suka transaction-scoped lock (
pg_advisory_xact_lock) agar dilepas otomatis. - Periksa ulang status di database. Jangan anggap Anda yang pertama. Konfirmasi record masih memenuhi syarat.
- Lakukan kerja dan tulis penanda “selesai” yang tahan lama di database (update status, entri ledger, baris audit).
- Commit dan biarkan lock pergi. Jika Anda pakai session-level lock, unlock sebelum mengembalikan koneksi ke pool.
Contoh: dua server aplikasi menerima “Approve invoice #123” dalam detik yang sama. Keduanya mulai, tetapi hanya satu yang mendapat lock untuk 123. Pemenang memeriksa bahwa invoice #123 masih pending, menandainya approved, menulis catatan audit/pembayaran, dan commit. Server kedua baik gagal cepat (try-lock) atau menunggu sebentar, lalu memeriksa ulang dan keluar tanpa membuat duplikat. Dengan cara ini Anda menghindari pemrosesan ganda sambil menjaga UI responsif.
Di mana advisory locks cocok: persetujuan, penagihan, scheduler
Advisory locks paling cocok ketika aturannya sederhana: untuk hal tertentu, hanya satu proses boleh melakukan kerja “menang” pada satu waktu. Anda mempertahankan database dan kode aplikasi yang ada, tapi menambah gerbang kecil yang membuat kondisi balapan lebih sulit terjadi.
Persetujuan
Persetujuan adalah perangkap konkurensi klasik. Dua reviewer (atau orang yang sama mengklik dua kali) bisa menekan Approve dalam milidetik. Dengan lock yang dipetak ke request ID, hanya satu transaksi yang melakukan perubahan status. Yang lain cepat mengetahui hasil dan bisa menampilkan pesan jelas seperti “sudah disetujui” atau “sudah ditolak.”
Ini umum di portal pelanggan dan panel admin di mana banyak orang memantau antrean yang sama.
Penagihan
Penagihan biasanya membutuhkan aturan lebih ketat: satu percobaan pembayaran per invoice, bahkan ketika retry terjadi. Timeout jaringan bisa membuat pengguna mengklik Pay lagi, atau retry latar belakang bisa dijalankan sementara percobaan pertama masih berjalan.
Lock yang dipetak ke invoice ID memastikan hanya satu jalur yang berkomunikasi dengan penyedia pembayaran pada satu waktu. Percobaan kedua bisa mengembalikan “pembayaran sedang diproses” atau membaca status pembayaran terbaru. Itu mencegah kerja duplikat dan mengurangi risiko tagihan ganda.
Scheduler dan background worker
Dalam setup multi-instance, scheduler bisa tanpa sengaja menjalankan jendela yang sama secara paralel. Lock yang dipetak ke nama job plus jendela waktu (mis. “daily-settlement:2026-01-29”) memastikan hanya satu instance yang menjalankannya.
Pendekatan yang sama bekerja untuk worker yang menarik item dari tabel: kunci berdasarkan item ID sehingga hanya satu worker yang bisa memprosesnya.
Kunci umum yang sering digunakan termasuk ID permintaan persetujuan tunggal, ID invoice tunggal, nama job plus jendela waktu, customer ID untuk “satu ekspor pada satu waktu,” atau unique idempotency key untuk retry.
Contoh realistis: menghentikan double-approval di portal
Bayangkan permintaan persetujuan di portal: purchase order menunggu, dan dua manajer mengklik Approve dalam detik yang sama. Tanpa perlindungan, kedua request bisa membaca “pending” dan kedua menulis “approved,” membuat entri audit duplikat, notifikasi duplikat, atau pekerjaan downstream terpicu dua kali.
PostgreSQL advisory locks memberi cara langsung untuk membuat aksi ini satu-per-satu per approval.
Alurnya
Saat API menerima aksi approve, pertama ambil lock berdasarkan approval id (agar approval berbeda tetap bisa diproses paralel).
Pola umum: kunci pada approval_id, baca status saat ini, update status, lalu tulis entri audit, semua dalam satu transaksi.
BEGIN;
-- One-at-a-time per approval_id
SELECT pg_try_advisory_xact_lock($1) AS got_lock; -- $1 = approval_id
-- If got_lock = false, return "someone else is approving, try again".
SELECT status FROM approvals WHERE id = $1 FOR UPDATE;
-- If status != 'pending', return "already processed".
UPDATE approvals
SET status = 'approved', approved_by = $2, approved_at = now()
WHERE id = $1;
INSERT INTO approval_audit(approval_id, actor_id, action, created_at)
VALUES ($1, $2, 'approved', now());
COMMIT;
Pengalaman klik kedua
Request kedua tidak bisa mendapat lock (jadi cepat mengembalikan “Sedang diproses”) atau mendapat lock setelah yang pertama selesai lalu melihat status sudah approved dan keluar tanpa merubah apa pun. Dengan kedua cara, Anda menghindari pemrosesan ganda sambil menjaga UI responsif.
Untuk debugging, catat cukup banyak informasi untuk melacak setiap percobaan: request id, approval id dan kunci yang dihitung, actor id, hasil (lock_busy, already_approved, approved_ok), dan waktu.
Menangani tunggu, timeout, dan retry tanpa membekukan aplikasi
Menunggu lock terdengar aman sampai berubah menjadi tombol yang berputar, worker yang macet, atau backlog yang tak pernah bersih. Ketika Anda tidak bisa mendapat lock, gagal cepat di jalur yang ada manusia menunggu dan tunggu hanya di tempat yang aman.
Untuk aksi pengguna: try-lock dan beri respons jelas
Jika seseorang mengklik Approve atau Charge, jangan blokir request mereka selama beberapa detik. Gunakan try-lock agar aplikasi bisa menjawab segera.
Pendekatan praktis: coba ambil lock, dan jika gagal, kembalikan respons jelas “sibuk, coba lagi” (atau refresh status item). Itu mengurangi timeout dan mencegah klik berulang.
Jaga bagian yang dikunci agar singkat: validasi status, terapkan perubahan status, commit.
Untuk background job: blocking boleh, tapi batasi
Untuk scheduler dan worker, blocking bisa diterima karena tidak ada manusia yang menunggu. Tapi Anda tetap perlu batas agar satu job lambat tidak menunda seluruh grup.
Gunakan timeout sehingga worker bisa menyerah dan melanjutkan:
SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);
Juga tetapkan waktu eksekusi maksimum yang diharapkan untuk job itu sendiri. Jika penagihan biasanya selesai di bawah 10 detik, anggap 2 menit sebagai insiden. Lacak waktu mulai, job id, dan berapa lama lock dipegang. Jika runner tugas Anda mendukung pembatalan, batalkan tugas yang melebihi batas sehingga sesi berakhir dan lock dilepas.
Rencanakan retry secara sengaja. Ketika kunci tidak didapat, putuskan apa yang terjadi selanjutnya: jadwalkan ulang segera dengan backoff (dan sedikit randomness), lewati pekerjaan best-effort untuk siklus ini, atau tandai item sebagai contended jika kegagalan berulang perlu perhatian.
Kesalahan umum yang menyebabkan lock tersangkut atau duplikasi
Kejutan paling umum adalah session-level lock yang tidak pernah dilepas. Connection pool menjaga koneksi tetap terbuka, jadi session bisa hidup lebih lama dari request. Jika Anda mengambil session lock dan lupa unlock, lock bisa tetap dipegang sampai koneksi itu direcycle. Worker lain akan menunggu (atau gagal) dan bisa sulit mengetahui penyebabnya.
Sumber duplikasi lain adalah mengunci tapi tidak memeriksa status. Lock hanya memastikan satu worker menjalankan bagian kritis pada satu waktu. Ia tidak menjamin record masih cocok. Selalu cek ulang di dalam transaksi yang sama (mis. konfirmasi pending sebelum Anda ubah menjadi approved).
Kunci juga membuat tim tersandung. Jika satu layanan mengunci berdasarkan order_id dan layanan lain mengunci kunci yang dihitung berbeda untuk sumber daya yang sama, sekarang Anda punya dua kunci. Kedua jalur bisa berjalan bersamaan, yang menciptakan rasa aman palsu.
Memegang lock lama biasanya karena diri sendiri. Jika Anda melakukan panggilan jaringan lambat saat memegang lock (payment provider, email/SMS, webhook), penjagaan pendek bisa menjadi bottleneck. Jaga bagian yang dikunci agar fokus pada pekerjaan database yang cepat: validasi status, tulis status baru, catat apa yang harus dilakukan selanjutnya. Lalu picu efek samping setelah transaksi commit.
Terakhir, advisory locks tidak menggantikan idempotency atau constraint database. Perlakukan mereka sebagai lampu lalu lintas, bukan bukti sistem. Gunakan unique constraint di tempat yang cocok, dan gunakan idempotency key untuk panggilan eksternal.
Daftar periksa cepat sebelum rilis
Perlakukan advisory locks seperti kontrak kecil: semua orang di tim harus tahu apa arti lock, apa yang dilindungi, dan apa yang boleh terjadi saat lock dipegang.
Daftar singkat yang menangkap sebagian besar masalah:
- Satu kunci jelas per resource, ditulis dan digunakan kembali di mana-mana
- Ambil lock sebelum sesuatu yang tidak dapat diubah (pembayaran, email, panggilan API eksternal)
- Periksa ulang status setelah lock dipegang dan sebelum menulis perubahan
- Jaga bagian yang dikunci singkat dan terukur (log waktu tunggu lock dan waktu eksekusi)
- Putuskan apa arti “lock busy” untuk tiap jalur (pesan UI, retry dengan backoff, lewati)
Langkah berikutnya: terapkan pola dan jaga agar mudah dipelihara
Pilih satu tempat di mana duplikasi paling merugikan dan mulai dari sana. Target awal yang baik adalah aksi yang biayanya uang atau mengubah status secara permanen, seperti “charge invoice” atau “approve request.” Bungkus hanya bagian kritis itu dengan advisory lock, lalu perluas ke langkah terkait setelah Anda mempercayai perilakunya.
Tambahkan observability dasar sejak awal. Catat saat worker tidak dapat mengambil lock, dan berapa lama pekerjaan yang dikunci berlangsung. Jika waktu tunggu lock melonjak, biasanya itu berarti bagian kritis terlalu besar atau ada query lambat di dalamnya.
Lock paling efektif sebagai lapisan di atas keselamatan data, bukan pengganti. Jaga field status yang jelas (pending, processing, done, failed) dan dukung dengan constraint ketika memungkinkan. Jika retry terjadi pada momen terburuk, unique constraint atau idempotency key bisa menjadi garis pertahanan kedua.
Jika Anda membangun workflow di AppMaster (appmaster.io), Anda bisa menerapkan pola yang sama dengan menjaga perubahan status kritis di dalam satu transaksi dan menambahkan langkah SQL kecil untuk mengambil transaction-level advisory lock sebelum langkah “finalize”.
Advisory locks cocok sampai Anda benar-benar butuh fitur antrean (prioritas, delayed jobs, dead-letter handling), mengalami kontensi berat dan butuh paralelisme yang lebih cerdas, harus berkoordinasi antar database tanpa Postgres bersama, atau memerlukan aturan isolasi yang lebih ketat. Tujuannya adalah reliabilitas yang membosankan: jaga pola kecil, konsisten, terlihat di log, dan didukung oleh constraint.
FAQ
Gunakan advisory lock ketika Anda membutuhkan “hanya satu aktor pada satu waktu” untuk unit kerja tertentu, seperti menyetujui permintaan, menagih invoice, atau menjalankan jendela terjadwal. Ini sangat berguna saat beberapa instance aplikasi dapat mengakses item yang sama dan Anda tidak ingin menambah layanan penguncian terpisah.
Row lock melindungi baris nyata yang Anda pilih dan sangat bagus ketika seluruh operasi cocok dengan pembaruan satu baris. Advisory lock melindungi kunci yang Anda tentukan sendiri, jadi berguna ketika alur kerja menyentuh banyak tabel, memanggil layanan eksternal, atau dimulai sebelum baris akhir ada.
Secara default gunakan pg_advisory_xact_lock (transaction-level) untuk tindakan request/response karena akan dilepas otomatis saat commit atau rollback. Gunakan pg_advisory_lock (session-level) hanya jika Anda benar-benar perlu agar kunci bertahan lebih lama dari transaksi dan Anda yakin selalu memanggil unlock sebelum mengembalikan koneksi ke pool.
Untuk aksi yang dipicu UI, lebih baik gunakan try-lock (pg_try_advisory_xact_lock) sehingga permintaan bisa gagal cepat dan mengembalikan respons “sudah diproses” yang jelas. Untuk worker latar belakang, blocking lock bisa diterima, tapi batasi dengan lock_timeout agar satu tugas yang macet tidak menahan semuanya.
Kunci hal terkecil yang tidak boleh dijalankan dua kali, biasanya “satu invoice” atau “satu permintaan persetujuan.” Jika Anda mengunci terlalu luas (mis. per customer) throughput turun; jika terlalu sempit, Anda masih bisa mendapatkan duplikasi.
Pilih satu format kunci yang stabil dan gunakan di mana saja yang bisa melakukan aksi kritis yang sama. Pendekatan umum adalah dua integer: satu namespace tetap untuk workflow dan entity ID sebagai nilai kedua, sehingga workflow yang berbeda tidak saling memblokir tapi tetap berkoordinasi.
Tidak. Lock hanya mencegah eksekusi bersamaan; tidak membuktikan operasi aman untuk diulang. Anda tetap harus mengecek ulang status dalam transaksi (mis. verifikasi item masih pending) dan mengandalkan constraint unik atau idempotency ketika perlu.
Jaga bagian yang dikunci agar singkat dan fokus ke database: akuisisi kunci, cek kelayakan, tulis status baru, lalu commit. Lakukan efek samping yang lambat (pembayaran, email, webhook) setelah commit atau lewat mekanisme outbox sehingga Anda tidak menahan kunci saat menunggu jaringan.
Penyebab paling umum adalah session-level lock yang dipegang oleh koneksi pooled yang tidak pernah di-unlock karena bug. Pilih transaction-level locks jika memungkinkan; kalau pakai session lock, pastikan pg_advisory_unlock selalu dipanggil sebelum koneksi kembali ke pool.
Catat ID entitas dan kunci yang dihitung, apakah kunci berhasil diambil, berapa lama menunggu untuk mendapatkan kunci, dan berapa lama transaksi berjalan. Juga catat outcome seperti lock_busy, already_processed, atau processed_ok agar Anda bisa membedakan kontensi dari duplikasi nyata.


