MVCC dari chapter sebelumnya membuat pembaca dan penulis hidup damai: pembaca tidak pernah mengantre di belakang penulis, dan penulis tidak mengganggu pembaca. Damai itu berakhir ketika dua penulis menyasar baris yang sama pada saat bersamaan — dua teller sama-sama meng-update saldo rekening Budi, misalnya. Tanpa kendali, keduanya menghitung dari saldo yang sama dan hasil yang di-commit belakangan bisa menimpa hitungan yang tidak lagi valid. PostgreSQL menyediakan SELECT ... FOR UPDATE untuk situasi ini: perintah membaca baris sekaligus menguncinya, sehingga penulis lain tertahan sampai transaksi pemegang kunci selesai. Chapter ini membedah empat mode kunci baris, opsi NOWAIT dan SKIP LOCKED, sampai pola antrian pekerja yang menjadi standar untuk aplikasi background.

Menyiapkan Tabel Latihan #

Demo memakai tabel rekening yang sudah akrab dari chapter-chapter sebelumnya, ditambah satu tabel baru: antrian_transfer berisi permintaan transfer yang menunggu diproses pekerja background. Tabel kedua inilah bahan utama untuk demo SKIP LOCKED di bagian akhir chapter:

sql buat-tabel.sql
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)
);

CREATE TABLE antrian_transfer (
    id          integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    rekening_id integer NOT NULL REFERENCES rekening(id),
    jumlah      numeric(12, 2) NOT NULL CHECK (jumlah > 0),
    status      text NOT NULL DEFAULT 'menunggu'
                CHECK (status IN ('menunggu', 'diproses', 'selesai'))
);

INSERT INTO rekening (id, nama, jenis, saldo) VALUES
    (1, 'Budi Santoso', 'tabungan', 500000),
    (2, 'Siti Aminah', 'tabungan', 300000),
    (3, 'Dewi Lestari', 'e-wallet', 950000);

INSERT INTO antrian_transfer (rekening_id, jumlah) VALUES
    (1, 100000),
    (2, 50000),
    (1, 250000),
    (3, 75000),
    (2, 150000);

SELECT id, nama, jenis, saldo FROM rekening ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 5
 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)

Siklus Lengkap: Baca, Kunci, Ubah, Commit #

Transfer Rp100.000 dari Budi ke Siti memerlukan dua UPDATE. Agar tidak berpacu dengan penulis lain, kedua baris rekening dikunci lebih dulu lewat SELECT ... FOR UPDATE, baru diubah, lalu transaksi ditutup:

BEGIN;

SELECT id, nama, saldo FROM rekening
WHERE id IN (1, 2)
ORDER BY id
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:

text
BEGIN
 id |     nama     |   saldo   
----+--------------+-----------
  1 | Budi Santoso | 500000.00
  2 | Siti Aminah  | 300000.00
(2 rows)

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)

Mari kita bedah peran setiap bagian:

  1. BEGIN membuka transaksi — kunci baris hanya ada di dalam transaksi; tanpa BEGIN, perintah berjalan dalam autocommit dan kuncinya lepas seketika setelah perintah selesai.
  2. SELECT ... FOR UPDATE membaca dua baris rekening sekaligus menguncinya di tempat. Mulai titik ini, penulis lain yang menyasar baris id = 1 atau id = 2 akan menunggu, sementara pembaca biasa tetap bebas berkat MVCC.
  3. Dua UPDATE menggerakkan saldo sesuai hitungan aman: Rp400.000 untuk Budi, Rp400.000 untuk Siti.
  4. COMMIT mengunci permanen perubahan sekaligus melepas semua kunci baris — penulis lain yang menunggu baru bisa melanjutkan setelah titik ini.

Perhatikan urutannya: baca-dan-kunci lebih dulu, hitung kemudian. Aplikasi yang membalik urutan itu — membaca tanpa kunci, menghitung di memori, lalu menulis hasil absolut — rentan terhadap masalah lost update yang dibedah tuntas di chapter Penanganan Konkurensi .

NOWAIT: Gagal Cepat Daripada Mengantre #

Perilaku default FOR UPDATE adalah sabar: jika baris sedang dipegang transaksi lain, perintah menunggu tanpa batas waktu sampai baris bebas. Sebagian aplikasi justru ingin sebaliknya — layanan notifikasi, misalnya, lebih baik gagal cepat dan dicoba ulang belakangan ketimbang menumpuk koneksi yang menggantung. Opsi NOWAIT mengubah perilaku itu: jika baris terkunci, PostgreSQL langsung menolak dengan error.

Karena penolakan ini hanya hidup saat dua sesi berjalan bersamaan, demo berikut mensimulasikan dua terminal lewat skrip: terminal A mengunci baris id = 1 dan membiarkan transaksinya terbuka; terminal B meminta baris yang sama dengan NOWAIT:

bash nowait-gagal.sh
#!/bin/bash
# Simulasi dua terminal: terminal A mengunci baris, terminal B menolak mengantre.
# Skrip menjalankan dua sesi psql terpisah, lalu mencetak keluaran masing-masing.

: "${PGDATABASE:=latihan}"
export PGDATABASE

TMPD=$(mktemp -d)
mkfifo "$TMPD/komando.fifo"

# Terminal A: sesi psql yang tetap hidup, perintah dikirim lewat fifo
PGAPPNAME=terminal-a psql -X < "$TMPD/komando.fifo" > "$TMPD/a.log" 2>&1 &
SESIA=$!
exec 3>"$TMPD/komando.fifo"

printf 'BEGIN;\nSELECT saldo FROM rekening WHERE id = 1 FOR UPDATE;\n' >&3

# Tunggu sampai transaksi A benar-benar memegang kunci (status idle in transaction)
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: minta baris yang sama tanpa mau mengantre
PGAPPNAME=terminal-b psql -X -c "SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE NOWAIT;" \
  > "$TMPD/b.log" 2>&1

# Terminal A menutup transaksinya
printf 'COMMIT;\n' >&3
exec 3>&-
wait $SESIA

echo "== Terminal A: kunci dipegang, transaksi masih terbuka =="
cat "$TMPD/a.log"
echo
echo "== Terminal B: NOWAIT langsung ditolak =="
cat "$TMPD/b.log"

rm -rf "$TMPD"

Output yang Dihasilkan:

text
== Terminal A: kunci dipegang, transaksi masih terbuka ==
BEGIN
   saldo   
-----------
 400000.00
(1 row)

COMMIT

== Terminal B: NOWAIT langsung ditolak ==
ERROR:  could not obtain lock on row in relation "rekening"

Terminal B ditolak seketika dengan error could not obtain lock on row in relation "rekening" — tidak ada penungguan sedetik pun. Aplikasi yang menerima error ini bebas memilih: mencoba ulang setelah jeda, mengambil baris lain, atau melaporkan kondisi sibuk ke pengguna. Skrip demo menunggu terminal A mencapai status idle in transaction lewat pg_stat_activity sebelum menjalankan terminal B — itulah cara skrip memastikan kunci sudah benar-benar dipegang saat NOWAIT diuji.

Empat Mode Kunci Baris #

FOR UPDATE bukan satu-satunya mode. PostgreSQL menyediakan empat mode kunci baris dengan kekuatan berbeda — makin kuat kuncinya, makin banyak yang ditolak berdampingan dengannya. Keempatnya dipasang lewat klausa pada SELECT:

sql mode-lock.sql
BEGIN;

-- Paling ringan: hanya menjaga baris tidak dihapus atau diganti kunci primernya
SELECT saldo FROM rekening WHERE id = 1 FOR KEY SHARE;

-- Sekelas FOR KEY SHARE ditambah tolakan terhadap UPDATE non-kunci dari sesi lain
SELECT saldo FROM rekening WHERE id = 1 FOR SHARE;

-- Kunci penulisan ringan: masih boleh berdampingan dengan FOR KEY SHARE
SELECT saldo FROM rekening WHERE id = 1 FOR NO KEY UPDATE;

-- Kunci penulisan paling kuat: bertentangan dengan semua mode lain
SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE;

COMMIT;

Output yang Dihasilkan:

text
BEGIN
   saldo   
-----------
 400000.00
(1 row)

   saldo   
-----------
 400000.00
(1 row)

   saldo   
-----------
 400000.00
(1 row)

   saldo   
-----------
 400000.00
(1 row)

COMMIT

Keempat SELECT mengembalikan nilai saldo yang sama persis — perbedaan mode tidak tampak dari hasil bacaan, melainkan dari apa yang boleh dilakukan sesi lain pada baris yang sama. Matriks benturan antar mode dirangkum tabel berikut; baris adalah mode yang diminta, kolom adalah mode yang sudah dipegang transaksi lain:

Mode yang dimintaFOR KEY SHAREFOR SHAREFOR NO KEY UPDATEFOR UPDATE
FOR KEY SHAREamanamanamanbentrok
FOR SHAREamanamanbentrokbentrok
FOR NO KEY UPDATEamanbentrokbentrokbentrok
FOR UPDATEbentrokbentrokbentrokbentrok

Tiga mode diambil PostgreSQL secara otomatis tanpa Anda menulis SELECT ... FOR UPDATE sekalipun. UPDATE biasa mengambil mode FOR NO KEY UPDATE, dan naik menjadi FOR UPDATE hanya bila perubahannya menyentuh kolom kunci seperti primary key. DELETE selalu mengambil FOR UPDATE. Pemeriksaan foreign key — misalnya INSERT ke antrian_transfer yang mereferensikan rekening(id) — mengambil FOR KEY SHARE pada baris induk, mode paling ringan itulah sebabnya UPDATE yang tidak menyentuh kolom kunci masih boleh berjalan berdampingan dengan penyisipan baris anak.

Dua mode ringan (FOR SHARE, FOR KEY SHARE) terutama dipakai aplikasi yang membaca data untuk diverifikasi lalu mengandalkan baris itu tidak berubah bentuk. Praktik sehari-hari kebanyakan berputar di FOR UPDATE dan FOR NO KEY UPDATE, sedangkan dua mode ringan itu kerap diambil otomatis oleh constraint seperti ditunjukkan di atas.

FOR UPDATE Tidak Bisa untuk Hasil Agregat #

Klausa FOR UPDATE mengunci baris-baris nyata yang dihasilkan query — bukan angka hasil olahan. Saat query mengembalikan hasil agregat, tidak ada baris konkret untuk dikunci, dan PostgreSQL menolaknya:

SELECT jenis, sum(saldo) AS total_saldo
FROM rekening
GROUP BY jenis
ORDER BY jenis
FOR UPDATE;

Output yang Dihasilkan:

text
psql:for-update-error.sql:5: ERROR:  FOR UPDATE is not allowed with GROUP BY clause

Error FOR UPDATE is not allowed with GROUP BY clause itu juga berlaku untuk DISTINCT, fungsi agregat tanpa GROUP BY, dan window function — semuanya menghasilkan baris olahan, bukan baris tabel. Jika maksud Anda mengunci semua baris yang dijumlahkan, kunci dulu barisnya dengan SELECT ... FOR UPDATE pada baris asli, baru hitung agregatnya di dalam transaksi yang sama.

SKIP LOCKED dan Pola Antrian Pekerja #

Bayangkan beberapa pekerja background memproses antrian_transfer secara bersamaan. Setiap pekerja mengambil beberapa baris tertua berstatus menunggu, menandainya diproses, lalu mengeksekusi transfernya. Jika semua pekerja memakai FOR UPDATE polos, pekerja kedua akan mengantre di belakang pilihan pekerja pertama padahal baris lain bebas — saling menghalangi tanpa perlu. Opsi SKIP LOCKED membalik perilaku: baris yang sedang terkunci dilewati, bukan ditunggu.

Demo berikut menjalankan dua pekerja bersamaan. Pekerja A mengambil dua baris tertua dan sengaja menahan transaksinya terbuka; pekerja B menjalankan perintah yang persis sama dan berakhir mengambil baris nomor tiga dan empat:

#!/bin/bash
# Simulasi dua pekerja memanen antrian transfer yang sama.
# Pekerja A mengambil dua pekerjaan dan menahan transaksinya terbuka;
# pekerja B menjalankan perintah yang sama dan mendapat pekerjaan yang bebas.

: "${PGDATABASE:=latihan}"
export PGDATABASE

TMPD=$(mktemp -d)
mkfifo "$TMPD/komando.fifo"

# Pekerja A: sesi psql yang tetap hidup, perintah dikirim lewat fifo
PGAPPNAME=pekerja-a psql -X < "$TMPD/komando.fifo" > "$TMPD/a.log" 2>&1 &
SESIA=$!
exec 3>"$TMPD/komando.fifo"

printf "BEGIN;
WITH diambil AS (
    SELECT id
    FROM antrian_transfer
    WHERE status = 'menunggu'
    ORDER BY id
    LIMIT 2
    FOR UPDATE SKIP LOCKED
)
UPDATE antrian_transfer a
SET status = 'diproses'
FROM diambil d
WHERE a.id = d.id
RETURNING a.id, a.rekening_id, a.jumlah, a.status;
" >&3

# Tunggu sampai pekerja A memegang kunci dua baris pertama
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 = 'pekerja-a'
                        AND state = 'idle in transaction'")
  [ "$n" = "1" ] && break
  sleep 0.05
done

# Pekerja B: perintah yang sama, dijalankan saat baris 1-2 masih terkunci pekerja A
PGAPPNAME=pekerja-b psql -X > "$TMPD/b.log" 2>&1 <<'PEKERJA'
BEGIN;
WITH diambil AS (
    SELECT id
    FROM antrian_transfer
    WHERE status = 'menunggu'
    ORDER BY id
    LIMIT 2
    FOR UPDATE SKIP LOCKED
)
UPDATE antrian_transfer a
SET status = 'diproses'
FROM diambil d
WHERE a.id = d.id
RETURNING a.id, a.rekening_id, a.jumlah, a.status;
COMMIT;
PEKERJA

# Pekerja A menutup transaksinya
printf 'COMMIT;\n' >&3
exec 3>&-
wait $SESIA

echo "== Pekerja A (transaksi masih terbuka saat B berjalan) =="
cat "$TMPD/a.log"
echo
echo "== Pekerja B (baris 1-2 dilewati karena terkunci) =="
cat "$TMPD/b.log"
echo
echo "== Isi antrian setelah kedua pekerja commit =="
psql -X -c "SELECT id, rekening_id, jumlah, status FROM antrian_transfer ORDER BY id;"

rm -rf "$TMPD"

Output yang Dihasilkan:

text
== Pekerja A (transaksi masih terbuka saat B berjalan) ==
BEGIN
 id | rekening_id |  jumlah   |  status  
----+-------------+-----------+----------
  1 |           1 | 100000.00 | diproses
  2 |           2 |  50000.00 | diproses
(2 rows)

UPDATE 2
COMMIT

== Pekerja B (baris 1-2 dilewati karena terkunci) ==
BEGIN
 id | rekening_id |  jumlah   |  status  
----+-------------+-----------+----------
  3 |           1 | 250000.00 | diproses
  4 |           3 |  75000.00 | diproses
(2 rows)

UPDATE 2
COMMIT

== Isi antrian setelah kedua pekerja commit ==
 id | rekening_id |  jumlah   |  status  
----+-------------+-----------+----------
  1 |           1 | 100000.00 | diproses
  2 |           2 |  50000.00 | diproses
  3 |           1 | 250000.00 | diproses
  4 |           3 |  75000.00 | diproses
  5 |           2 | 150000.00 | menunggu
(5 rows)

Pola satu perintah itulah intinya. CTE diambil memilih id baris menunggu tertua sebanyak dua baris dengan FOR UPDATE SKIP LOCKED; UPDATE di luar CTE lalu menandai baris-baris pilihan itu menjadi diproses dan mengembalikan detailnya lewat RETURNING — pekerja tahu persis pekerjaan mana yang menjadi miliknya tanpa komunikasi antar pekerja. Kombinasi ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED menjamin tidak ada dua pekerja yang mengambil pekerjaan yang sama, sekaligus tidak ada pekerja yang mengantre hanya karena antrian milik orang lain sedang diproses.

Pessimistic vs Optimistic #

Semua contoh di atas memakai strategi pessimistic: asumsikan konflik pasti terjadi, kunci lebih dulu, biarkan yang lain mengantre. Lawannya adalah optimistic: tidak mengunci apa pun saat membaca, lalu mendeteksi tabrakan saat menulis — misalnya lewat kolom versi yang harus cocok, atau membiarkan isolation level Serializable yang menolak transaksi yang bertabrakan. Perbandingan ringkasnya:

AspekPessimistic (FOR UPDATE)Optimistic (versi / retry)
Kunci saat membacaYa, baris terkunci seketikaTidak ada
Sesi lain saat tabrakanMengantreTetap jalan, kegagalan terdeteksi saat tulis
Biaya saat tidak konflikKunci tetap dikelola tiap transaksiNyaris nol
Cocok untukKonflik sering, transaksi pendekKonflik jarang, transaksi panjang
Risiko utamaAntrean panjang, deadlock antar kunciKerja ulang saat tabrakan terjadi

Untuk rekening bank dengan transfer yang padat dan singkat, pessimistic dengan FOR UPDATE biasanya pas. Untuk data yang jarang diubah dua orang sekaligus — profil nasabah, misalnya — optimistic lebih ringan. Perbandingan penuh dengan contoh kode kedua strategi dibahas di chapter Penanganan Konkurensi .

Latihan Dua Terminal #

Blokir nyata antara dua penulis paling jujur diamati lewat dua terminal sungguhan. Lakukan urutan berikut di database latihan Anda:

  1. Buka dua terminal dan jalankan psql di keduanya, terhubung ke database yang sama. Sebut terminal pertama A dan terminal kedua B.
  2. Di terminal A, kunci baris Budi dan biarkan transaksi terbuka: BEGIN; lalu SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE; — hasilnya Rp400.000.
  3. Di terminal B, jalankan UPDATE rekening SET saldo = saldo - 50000 WHERE id = 1; — perintah ini tidak kembali; terminal B menunggu baris dibebaskan.
  4. Sementara B menunggu, jalankan dari terminal ketiga: SELECT application_name, state, wait_event_type, wait_event FROM pg_stat_activity WHERE datname = current_database() AND state = 'active'; — sesi B terlihat dengan wait_event_type = Lock dan wait_event = transactionid: penunggu kunci baris selalu mengantre pada nomor transaksi pemegang kunci.
  5. Kembali ke terminal A dan jalankan COMMIT; — seketika itu juga terminal B melanjutkan, UPDATE 1 muncul, dan SELECT saldo FROM rekening WHERE id = 1; di terminal mana pun menunjukkan Rp350.000.
  6. Ulangi langkah 2-3 tetapi ganti perintah B dengan SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE NOWAIT; — kini B ditolak seketika seperti pada demo skrip sebelumnya, dan saldo tetap Rp400.000 karena B tidak pernah menulis.

Kunci baris menyelesaikan pertarungan penulis melawan penulis pada baris yang sama. Namun sebagian operasi bekerja pada cakupan lebih luas — ALTER TABLE yang menulis ulang struktur, VACUUM, backup, sampai sekadar keinginan memastikan tak ada penulis lain selama maintenance berjalan. Operasi seperti itu memakai kunci level tabel, topik chapter Table Locking berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

antrian-dua-pekerja.sh

Unduh berkas
bash
#!/bin/bash
# Simulasi dua pekerja memanen antrian transfer yang sama.
# Pekerja A mengambil dua pekerjaan dan menahan transaksinya terbuka;
# pekerja B menjalankan perintah yang sama dan mendapat pekerjaan yang bebas.

: "${PGDATABASE:=latihan}"
export PGDATABASE

TMPD=$(mktemp -d)
mkfifo "$TMPD/komando.fifo"

# Pekerja A: sesi psql yang tetap hidup, perintah dikirim lewat fifo
PGAPPNAME=pekerja-a psql -X < "$TMPD/komando.fifo" > "$TMPD/a.log" 2>&1 &
SESIA=$!
exec 3>"$TMPD/komando.fifo"

printf "BEGIN;
WITH diambil AS (
    SELECT id
    FROM antrian_transfer
    WHERE status = 'menunggu'
    ORDER BY id
    LIMIT 2
    FOR UPDATE SKIP LOCKED
)
UPDATE antrian_transfer a
SET status = 'diproses'
FROM diambil d
WHERE a.id = d.id
RETURNING a.id, a.rekening_id, a.jumlah, a.status;
" >&3

# Tunggu sampai pekerja A memegang kunci dua baris pertama
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 = 'pekerja-a'
                        AND state = 'idle in transaction'")
  [ "$n" = "1" ] && break
  sleep 0.05
done

# Pekerja B: perintah yang sama, dijalankan saat baris 1-2 masih terkunci pekerja A
PGAPPNAME=pekerja-b psql -X > "$TMPD/b.log" 2>&1 <<'PEKERJA'
BEGIN;
WITH diambil AS (
    SELECT id
    FROM antrian_transfer
    WHERE status = 'menunggu'
    ORDER BY id
    LIMIT 2
    FOR UPDATE SKIP LOCKED
)
UPDATE antrian_transfer a
SET status = 'diproses'
FROM diambil d
WHERE a.id = d.id
RETURNING a.id, a.rekening_id, a.jumlah, a.status;
COMMIT;
PEKERJA

# Pekerja A menutup transaksinya
printf 'COMMIT;\n' >&3
exec 3>&-
wait $SESIA

echo "== Pekerja A (transaksi masih terbuka saat B berjalan) =="
cat "$TMPD/a.log"
echo
echo "== Pekerja B (baris 1-2 dilewati karena terkunci) =="
cat "$TMPD/b.log"
echo
echo "== Isi antrian setelah kedua pekerja commit =="
psql -X -c "SELECT id, rekening_id, jumlah, status FROM antrian_transfer ORDER BY id;"

rm -rf "$TMPD"

buat-tabel.sql

Unduh berkas
sql
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)
);

CREATE TABLE antrian_transfer (
    id          integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    rekening_id integer NOT NULL REFERENCES rekening(id),
    jumlah      numeric(12, 2) NOT NULL CHECK (jumlah > 0),
    status      text NOT NULL DEFAULT 'menunggu'
                CHECK (status IN ('menunggu', 'diproses', 'selesai'))
);

INSERT INTO rekening (id, nama, jenis, saldo) VALUES
    (1, 'Budi Santoso', 'tabungan', 500000),
    (2, 'Siti Aminah', 'tabungan', 300000),
    (3, 'Dewi Lestari', 'e-wallet', 950000);

INSERT INTO antrian_transfer (rekening_id, jumlah) VALUES
    (1, 100000),
    (2, 50000),
    (1, 250000),
    (3, 75000),
    (2, 150000);

SELECT id, nama, jenis, saldo FROM rekening ORDER BY id;

for-update-dasar.sql

Unduh berkas
sql
BEGIN;

SELECT id, nama, saldo FROM rekening
WHERE id IN (1, 2)
ORDER BY id
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;

for-update-error.sql

Unduh berkas
sql
SELECT jenis, sum(saldo) AS total_saldo
FROM rekening
GROUP BY jenis
ORDER BY jenis
FOR UPDATE;

mode-lock.sql

Unduh berkas
sql
BEGIN;

-- Paling ringan: hanya menjaga baris tidak dihapus atau diganti kunci primernya
SELECT saldo FROM rekening WHERE id = 1 FOR KEY SHARE;

-- Sekelas FOR KEY SHARE ditambah tolakan terhadap UPDATE non-kunci dari sesi lain
SELECT saldo FROM rekening WHERE id = 1 FOR SHARE;

-- Kunci penulisan ringan: masih boleh berdampingan dengan FOR KEY SHARE
SELECT saldo FROM rekening WHERE id = 1 FOR NO KEY UPDATE;

-- Kunci penulisan paling kuat: bertentangan dengan semua mode lain
SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE;

COMMIT;

nowait-gagal.sh

Unduh berkas
bash
#!/bin/bash
# Simulasi dua terminal: terminal A mengunci baris, terminal B menolak mengantre.
# Skrip menjalankan dua sesi psql terpisah, lalu mencetak keluaran masing-masing.

: "${PGDATABASE:=latihan}"
export PGDATABASE

TMPD=$(mktemp -d)
mkfifo "$TMPD/komando.fifo"

# Terminal A: sesi psql yang tetap hidup, perintah dikirim lewat fifo
PGAPPNAME=terminal-a psql -X < "$TMPD/komando.fifo" > "$TMPD/a.log" 2>&1 &
SESIA=$!
exec 3>"$TMPD/komando.fifo"

printf 'BEGIN;\nSELECT saldo FROM rekening WHERE id = 1 FOR UPDATE;\n' >&3

# Tunggu sampai transaksi A benar-benar memegang kunci (status idle in transaction)
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: minta baris yang sama tanpa mau mengantre
PGAPPNAME=terminal-b psql -X -c "SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE NOWAIT;" \
  > "$TMPD/b.log" 2>&1

# Terminal A menutup transaksinya
printf 'COMMIT;\n' >&3
exec 3>&-
wait $SESIA

echo "== Terminal A: kunci dipegang, transaksi masih terbuka =="
cat "$TMPD/a.log"
echo
echo "== Terminal B: NOWAIT langsung ditolak =="
cat "$TMPD/b.log"

rm -rf "$TMPD"