Dua teller yang sama-sama transfer uang antar rekening Budi dan Siti — tapi dengan urutan kunci yang berlawanan — bisa berakhir dalam kebuntuan yang paling klasik di dunia database: deadlock. Teller pertama memegang kunci rekening Budi dan menunggu rekening Siti; teller kedua memegang kunci rekening Siti dan menunggu rekening Budi. Keduanya menunggu selamanya, karena yang mereka tunggu justru satu sama lain. Kabar baiknya: PostgreSQL tidak membiarkan kebuntuan ini berlarut. Server secara berkala memeriksa graf penungguan, dan begitu menemukan siklus, satu transaksi dibatalkan sebagai korban agar transaksi lainnya bisa lanjut. Chapter ini mereproduksi kejadian itu, membaca isi pesan errornya, dan yang terpenting: mencegahnya dengan satu aturan sederhana — urutan akses yang konsisten.
Menyiapkan Tabel Latihan #
Demo memakai tabel rekening yang sudah akrab, dibuat ulang agar chapter ini mandiri:
CREATE TABLE rekening (
id integer PRIMARY KEY,
nama text NOT NULL,
jenis text NOT NULL,
saldo numeric(12, 2) NOT NULL CHECK (saldo >= 0),
CONSTRAINT saldo_ewallet_maks
CHECK (jenis <> 'e-wallet' OR saldo <= 1000000)
);
INSERT INTO rekening (id, nama, jenis, saldo) VALUES
(1, 'Budi Santoso', 'tabungan', 500000),
(2, 'Siti Aminah', 'tabungan', 300000),
(3, 'Dewi Lestari', 'e-wallet', 950000);
SELECT id, nama, jenis, saldo FROM rekening ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
id | nama | jenis | saldo
----+--------------+----------+-----------
1 | Budi Santoso | tabungan | 500000.00
2 | Siti Aminah | tabungan | 300000.00
3 | Dewi Lestari | e-wallet | 950000.00
(3 rows)Anatomi Siklus Deadlock #
Deadlock lahir dari dua keputusan yang masing-masing masuk akal. Terminal A memproses transfer Budi ke Siti, maka A mengunci dulu baris sumbernya: rekening id = 1. Terminal B memproses transfer Siti ke Budi, maka B mengunci dulu baris sumbernya juga: rekening id = 2. Baru ketika keduanya melangkah ke rekening kedua, kunci yang dibutuhkan sudah ada di tangan lawan. Kronologi lengkapnya tergambar di diagram berikut:
Kedua terminal menunggu kunci baris — bukan kunci tabel — karena UPDATE mengunci baris yang diubahnya. Penunggu kunci baris selalu mengantre pada nomor transaksi pemegang kunci, dan itulah benang yang ditarik PostgreSQL saat mendeteksi: sebuah proses yang sudah menunggu lebih lama dari deadlock_timeout (default 1000 milidetik) bangkit dan menelusuri rantai “menunggu siapa” sampai ujung. Jika rantai itu kembali ke dirinya sendiri, siklus ditemukan — dan sang pendeteksi membatalkan transaksinya sendiri sebagai korban.
Pemilihan korban tidak diumumkan ke sesi yang selamat. Terminal B sama sekali tidak mendapat error; UPDATE keduanya tiba-tiba lancar begitu korban A dibatalkan. Aplikasi yang sehat karenanya harus siap di DUA tempat: menangani error deadlock pada sesi korban, dan menyadari bahwa kesuksesan sesi selamat tidak berarti pasangannya juga sukses.
Reproduksi Deadlock #
Koreografi lengkapnya perlu dua transaksi yang hidup bersamaan, jadi demo berikut mensimulasikan dua terminal lewat skrip: terminal A menjalankan BEGIN dan UPDATE pertamanya, skrip memastikan kunci benar-benar terpasang lewat pg_stat_activity sebelum terminal B melangkah, dan seterusnya hingga siklus terbentuk:
#!/bin/bash
# Simulasi dua terminal yang saling mengunci (deadlock):
# - Terminal A transfer Budi -> Siti : kunci id 1 dulu, lalu id 2.
# - Terminal B transfer Siti -> Budi : kunci id 2 dulu, lalu id 1.
# PostgreSQL mendeteksi siklus dan membatalkan salah satunya.
: "${PGDATABASE:=latihan}"
export PGDATABASE
TMPD=$(mktemp -d)
mkfifo "$TMPD/komando-a.fifo" "$TMPD/komando-b.fifo"
PGAPPNAME=terminal-a psql -X < "$TMPD/komando-a.fifo" > "$TMPD/a.log" 2>&1 &
SESIA=$!
PGAPPNAME=terminal-b psql -X < "$TMPD/komando-b.fifo" > "$TMPD/b.log" 2>&1 &
SESIB=$!
exec 3>"$TMPD/komando-a.fifo" 4>"$TMPD/komando-b.fifo"
# Terminal A mengunci baris id = 1
printf 'BEGIN;\nUPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;\n' >&3
for i in $(seq 1 100); do
n=$(psql -X -tA -c "SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND application_name = 'terminal-a'
AND state = 'idle in transaction'")
[ "$n" = "1" ] && break
sleep 0.05
done
# Terminal B mengunci baris id = 2
printf 'BEGIN;\nUPDATE rekening SET saldo = saldo - 50000 WHERE id = 2;\n' >&4
for i in $(seq 1 100); do
n=$(psql -X -tA -c "SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND application_name = 'terminal-b'
AND state = 'idle in transaction'")
[ "$n" = "1" ] && break
sleep 0.05
done
# Terminal A minta baris id = 2 -> terblokir, menunggu B
printf 'UPDATE rekening SET saldo = saldo + 100000 WHERE id = 2;\n' >&3
for i in $(seq 1 200); do
n=$(psql -X -tA -c "SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND application_name = 'terminal-a'
AND state = 'active' AND wait_event_type = 'Lock'")
[ "$n" = "1" ] && break
sleep 0.05
done
# Terminal B minta baris id = 1 -> terblokir juga; siklus terbentuk,
# sekitar satu detik kemudian PostgreSQL membatalkan terminal A
printf 'UPDATE rekening SET saldo = saldo + 50000 WHERE id = 1;\n' >&4
printf 'COMMIT;\n' >&3
printf 'COMMIT;\n' >&4
exec 3>&- 4>&-
wait $SESIA $SESIB
echo "== Terminal A (korban: dibatalkan PostgreSQL) =="
cat "$TMPD/a.log"
echo
echo "== Terminal B (selamat: transaksinya lanjut dan commit) =="
cat "$TMPD/b.log"
echo
echo "== Isi rekening setelah kejadian =="
psql -X -c "SELECT id, nama, saldo FROM rekening ORDER BY id;"
rm -rf "$TMPD"Output yang Dihasilkan:
== Terminal A (korban: dibatalkan PostgreSQL) ==
BEGIN
UPDATE 1
ERROR: deadlock detected
DETAIL: Process 369 waits for ShareLock on transaction 9645; blocked by process 371.
Process 371 waits for ShareLock on transaction 9644; blocked by process 369.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,5) in relation "rekening"
ROLLBACK
== Terminal B (selamat: transaksinya lanjut dan commit) ==
BEGIN
UPDATE 1
UPDATE 1
COMMIT
== Isi rekening setelah kejadian ==
id | nama | saldo
----+--------------+-----------
1 | Budi Santoso | 450000.00
2 | Siti Aminah | 350000.00
3 | Dewi Lestari | 950000.00
(3 rows)Hasil akhirnya menjorok ke pelajaran penting: saldo Budi Rp450.000 dan saldo Siti Rp350.000. Transfer B (tarik Rp50.000 dari Siti, setor Rp50.000 ke Budi) tersimpan utuh, sementara transfer A lenyap bersama rollback-nya — padahal A sudah sempat mengurangi saldo Budi Rp100.000 di tengah jalan. Pembatalan korban mengembalikan SEMUA efek transaksinya, bukan hanya perintah yang keburu jalan.
Membaca Pesan Error dan Log Server #
Empat baris error pada terminal korban masing-masing membawa informasi berbeda dan semuanya berguna saat investigasi:
ERROR: deadlock detected— pemberitahuan resmi bahwa transaksi ini dipilih sebagai korban siklus.DETAIL: Process ... waits for ShareLock on transaction ...— siklus itu sendiri dalam bentuk teks: proses A menunggu transaksi milik proses B, dan proses B menunggu transaksi milik proses A. Dua nomor proses ini adalah alamat sesi yang bisa Anda cocokkan denganpg_stat_activity.HINT: See server log for query details.— pesan error ke klien tidak memuat teks query yang saling mengunci; teks lengkapnya ditulis ke log server. Di situlah satu-satunya tempat Anda bisa melihat pernyataan SQL apa yang menjadi aktor deadlock.CONTEXT: while updating tuple (0,5) in relation "rekening"— baris fisik yang sedang di-update korban saat kebuntuan terjadi.
Pengaturan server yang mengurus mekanisme ini bisa dibaca langsung dari catalog pg_settings:
-- Pengaturan server yang mengurus deadlock.
-- Nilainya spesifik per server; ini pembacaan dari server latihan.
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('deadlock_timeout', 'log_lock_waits', 'log_destination', 'logging_collector')
ORDER BY name;Output yang Dihasilkan:
name | setting | unit
-------------------+---------+------
deadlock_timeout | 1000 | ms
log_destination | stderr |
log_lock_waits | off |
logging_collector | off |
(4 rows)deadlock_timeout = 1000 ms adalah jeda sebelum deteksi dijalankan — sengaja tidak seketika karena pemeriksaan siklus memakan sumber daya, dan sebagian besar penungguan memang selesai sendiri dalam hitungan milidetik. log_lock_waits = off berarti hanya kejadian deadlock yang dicatat; menyalakannya membuat setiap penungguan kunci yang melewati deadlock_timeout ikut tercatat di log — senjata utama saat memburu antrean lambat. log_destination = stderr dengan logging_collector = off berarti log server mengalir ke keluaran kesalahan proses server; di produksi umumnya collector dinyalakan atau stderr diarahkan ke file. Bagaimanapun arahnya, di situlah teks query kedua proses yang bertikam ditulis.
Cegah dengan Urutan Akses Konsisten #
Pencegahan deadlock yang paling ampuh sekaligus paling sederhana: pastikan semua transaksi mengunci sumber daya dalam urutan yang sama. Bila setiap jalur kode mengunci rekening selalu dari id terkecil ke terbesar, siklus tidak mungkin terbentuk — dua transaksi yang bersaing hanya akan berjalan bergantian, bukan saling mencekik. File berikut memperagakan polanya untuk transfer Budi ke Siti:
-- Pola transfer yang AMAN dari deadlock:
-- kedua rekening dikunci dengan urutan id yang sama (kecil ke besar).
-- Berapa pun transaksi yang berpacu, yang terjadi hanyalah antrean singkat.
BEGIN;
-- Langkah 1: kunci rekening dengan id terkecil lebih dulu
SELECT id, nama, saldo FROM rekening WHERE id = 1 FOR UPDATE;
-- Langkah 2: baru rekening dengan id lebih besar
SELECT id, nama, saldo FROM rekening WHERE id = 2 FOR UPDATE;
UPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;
UPDATE rekening SET saldo = saldo + 100000 WHERE id = 2;
COMMIT;
SELECT id, nama, saldo FROM rekening ORDER BY id;Output yang Dihasilkan:
BEGIN
id | nama | saldo
----+--------------+-----------
1 | Budi Santoso | 500000.00
(1 row)
id | nama | saldo
----+-------------+-----------
2 | Siti Aminah | 300000.00
(1 row)
UPDATE 1
UPDATE 1
COMMIT
id | nama | saldo
----+--------------+-----------
1 | Budi Santoso | 400000.00
2 | Siti Aminah | 400000.00
3 | Dewi Lestari | 950000.00
(3 rows)Dua SELECT ... FOR UPDATE mengunci kedua rekening di awal transaksi, keduanya dalam urutan id naik, sebelum satu rupiah pun berpindah. Transfer berlawanan yang menjalankan pola sama akan mengunci id = 1 lebih dulu juga — salah satunya menunggu sejenak di SELECT pertama, lalu melanjutkan setelah pemegang sebelumnya commit. Aturan ini berlaku lintas bentuk sumber daya: kalau transaksi Anda menyentuh tabel dan baris sekaligus, tetapkan satu konvensi urutan (misalnya tabel dulu baru baris, atau selalu urut nama tabel) dan patuhi di semua jalur kode. Rangkuman aturan pencegahan yang terbukti:
| Aturan | Alasan |
|---|---|
Urutan akses konsisten (mis. selalu urut id naik) | Siklus butuh dua arah yang berlawanan; urutan seragam memutus semua kemungkinannya |
| Kunci semua sumber daya di awal transaksi | Kunci yang ditambahkan setelah menunggu adalah bahan bakar siklus |
| Transaksi sesingkat mungkin | Semakin cepat commit, semakin sempit jendela dua transaksi tumpang tindih |
| Jangan menunggu input pengguna dalam transaksi terbuka | Kunci yang menggantung berjam-jam adalah pabrik deadlock |
| Jalankan ulang transaksi korban | Deadlock tidak merusak data apa pun; unit kerja korban tinggal diulang dari awal |
Kunci baris dan kunci tabel selalu terikat pada objek database — baris, tabel, transaksi. Kadang aplikasi butuh kesepakatan yang lebih bebas: “hanya satu instance maintenance yang boleh jalan”, “hanya satu proses yang boleh membangun ulang cache”. Untuk itu PostgreSQL menyediakan advisory lock — kunci yang tidak menempel pada data apa pun — topik chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
aturan-server.sql
Unduh berkas-- Pengaturan server yang mengurus deadlock.
-- Nilainya spesifik per server; ini pembacaan dari server latihan.
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('deadlock_timeout', 'log_lock_waits', 'log_destination', 'logging_collector')
ORDER BY name;
buat-tabel.sql
Unduh berkasCREATE TABLE rekening (
id integer PRIMARY KEY,
nama text NOT NULL,
jenis text NOT NULL,
saldo numeric(12, 2) NOT NULL CHECK (saldo >= 0),
CONSTRAINT saldo_ewallet_maks
CHECK (jenis <> 'e-wallet' OR saldo <= 1000000)
);
INSERT INTO rekening (id, nama, jenis, saldo) VALUES
(1, 'Budi Santoso', 'tabungan', 500000),
(2, 'Siti Aminah', 'tabungan', 300000),
(3, 'Dewi Lestari', 'e-wallet', 950000);
SELECT id, nama, jenis, saldo FROM rekening ORDER BY id;
deadlock-dua-sesi.sh
Unduh berkas#!/bin/bash
# Simulasi dua terminal yang saling mengunci (deadlock):
# - Terminal A transfer Budi -> Siti : kunci id 1 dulu, lalu id 2.
# - Terminal B transfer Siti -> Budi : kunci id 2 dulu, lalu id 1.
# PostgreSQL mendeteksi siklus dan membatalkan salah satunya.
: "${PGDATABASE:=latihan}"
export PGDATABASE
TMPD=$(mktemp -d)
mkfifo "$TMPD/komando-a.fifo" "$TMPD/komando-b.fifo"
PGAPPNAME=terminal-a psql -X < "$TMPD/komando-a.fifo" > "$TMPD/a.log" 2>&1 &
SESIA=$!
PGAPPNAME=terminal-b psql -X < "$TMPD/komando-b.fifo" > "$TMPD/b.log" 2>&1 &
SESIB=$!
exec 3>"$TMPD/komando-a.fifo" 4>"$TMPD/komando-b.fifo"
# Terminal A mengunci baris id = 1
printf 'BEGIN;\nUPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;\n' >&3
for i in $(seq 1 100); do
n=$(psql -X -tA -c "SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND application_name = 'terminal-a'
AND state = 'idle in transaction'")
[ "$n" = "1" ] && break
sleep 0.05
done
# Terminal B mengunci baris id = 2
printf 'BEGIN;\nUPDATE rekening SET saldo = saldo - 50000 WHERE id = 2;\n' >&4
for i in $(seq 1 100); do
n=$(psql -X -tA -c "SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND application_name = 'terminal-b'
AND state = 'idle in transaction'")
[ "$n" = "1" ] && break
sleep 0.05
done
# Terminal A minta baris id = 2 -> terblokir, menunggu B
printf 'UPDATE rekening SET saldo = saldo + 100000 WHERE id = 2;\n' >&3
for i in $(seq 1 200); do
n=$(psql -X -tA -c "SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND application_name = 'terminal-a'
AND state = 'active' AND wait_event_type = 'Lock'")
[ "$n" = "1" ] && break
sleep 0.05
done
# Terminal B minta baris id = 1 -> terblokir juga; siklus terbentuk,
# sekitar satu detik kemudian PostgreSQL membatalkan terminal A
printf 'UPDATE rekening SET saldo = saldo + 50000 WHERE id = 1;\n' >&4
printf 'COMMIT;\n' >&3
printf 'COMMIT;\n' >&4
exec 3>&- 4>&-
wait $SESIA $SESIB
echo "== Terminal A (korban: dibatalkan PostgreSQL) =="
cat "$TMPD/a.log"
echo
echo "== Terminal B (selamat: transaksinya lanjut dan commit) =="
cat "$TMPD/b.log"
echo
echo "== Isi rekening setelah kejadian =="
psql -X -c "SELECT id, nama, saldo FROM rekening ORDER BY id;"
rm -rf "$TMPD"
urutan-konsisten.sql
Unduh berkas-- Pola transfer yang AMAN dari deadlock:
-- kedua rekening dikunci dengan urutan id yang sama (kecil ke besar).
-- Berapa pun transaksi yang berpacu, yang terjadi hanyalah antrean singkat.
BEGIN;
-- Langkah 1: kunci rekening dengan id terkecil lebih dulu
SELECT id, nama, saldo FROM rekening WHERE id = 1 FOR UPDATE;
-- Langkah 2: baru rekening dengan id lebih besar
SELECT id, nama, saldo FROM rekening WHERE id = 2 FOR UPDATE;
UPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;
UPDATE rekening SET saldo = saldo + 100000 WHERE id = 2;
COMMIT;
SELECT id, nama, saldo FROM rekening ORDER BY id;