Sore yang sibuk di bank: dua teller membuka rekening Budi pada menit yang sama, keduanya melihat saldo Rp500.000, keduanya memproses penarikan Rp50.000, dan malam itu rekonsiliasi menemukan selisih Rp50.000 yang tidak pernah dilaporkan sebagai error oleh sistem mana pun. Seluruh section ini sebenarnya dibangun untuk mencegah kejadian itu, alat demi alat: BEGIN/COMMIT untuk keutuhan, isolation level untuk batas pandangan, FOR UPDATE untuk kunci baris, advisory lock untuk kesepakatan antar proses, dan SERIALIZABLE untuk jaminan eksekusi berurutan. Chapter penutup ini merangkai semuanya menjadi strategi: kita bedah dulu anatomi masalah lost update sampai ke akarnya, lalu membangun empat jawaban yang bisa dijalankan dari satu sesi — optimistic dengan kolom versi, pessimistic dengan FOR UPDATE, antrian pekerja dengan SKIP LOCKED, dan idempotency lewat kunci unik — sebelum menutup dengan panduan keputusan untuk memilih di antara semuanya.
Menyiapkan Tabel Latihan #
Demo-demo chapter ini memakai tiga tabel. Tabel rekening kini tampil dengan satu kolom baru: versi, penanda optimistic locking yang menjadi tulang punggung demo pertama. Tabel antrian_tugas berisi pekerjaan background untuk pola SKIP LOCKED, dan transfer_log mencatat operasi dengan kolom kunci_idempoten yang dijaga constraint UNIQUE:
-- Tiga tabel latihan untuk strategi konkurensi:
-- rekening : saldo ditambah kolom versi untuk optimistic locking;
-- antrian_tugas: daftar pekerjaan background untuk pola SKIP LOCKED;
-- transfer_log : catatan operasi dengan kunci idempoten UNIQUE.
CREATE TABLE rekening (
id integer PRIMARY KEY,
nama text NOT NULL,
jenis text NOT NULL,
saldo numeric(12, 2) NOT NULL CHECK (saldo >= 0),
versi integer NOT NULL DEFAULT 1,
CONSTRAINT saldo_ewallet_maks
CHECK (jenis <> 'e-wallet' OR saldo <= 1000000)
);
CREATE TABLE antrian_tugas (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tugas text NOT NULL,
status text NOT NULL DEFAULT 'menunggu'
CHECK (status IN ('menunggu', 'diproses', 'selesai'))
);
CREATE TABLE transfer_log (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kunci_idempoten text NOT NULL UNIQUE,
keterangan text NOT NULL,
jumlah numeric(12, 2) NOT NULL CHECK (jumlah > 0)
);
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_tugas (tugas) VALUES
('kirim ringkasan mutasi harian'),
('hitung bunga tabungan'),
('arsipkan mutasi bulan lalu'),
('kirim notifikasi saldo rendah');
SELECT id, nama, jenis, saldo, versi FROM rekening ORDER BY id;
SELECT id, tugas, status FROM antrian_tugas ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 4
id | nama | jenis | saldo | versi
----+--------------+----------+-----------+-------
1 | Budi Santoso | tabungan | 500000.00 | 1
2 | Siti Aminah | tabungan | 300000.00 | 1
3 | Dewi Lestari | e-wallet | 950000.00 | 1
(3 rows)
id | tugas | status
----+-------------------------------+----------
1 | kirim ringkasan mutasi harian | menunggu
2 | hitung bunga tabungan | menunggu
3 | arsipkan mutasi bulan lalu | menunggu
4 | kirim notifikasi saldo rendah | menunggu
(4 rows)Semua rekening berangkat dengan versi = 1 dan keempat tugas berstatus menunggu. Seluruh demo inti chapter ini sengaja dirancang berjalan dari satu sesi; perilaku antar sesi dibahas lewat eksperimen dua terminal pada bagian berikutnya.
Masalah Lost Update #
Lost update lahir dari pola baca-hitung-tulis: aplikasi membaca saldo, menghitung nilai baru di memorinya, lalu menulis hasil hitungan itu kembali ke database. Selama jeda antara baca dan tulis, pihak lain bisa saja menulis duluan — dan tulisan yang datang belakangan menimpa hitungan yang dibangun di atas angka basi. Tidak ada error, tidak ada log, tidak ada penolakan: penulis kedua mengembalikan UPDATE 1 seolah semuanya baik-baik saja.
Anatomi lengkapnya paling jujur dilihat lewat dua terminal psql. Jalankan urutan berikut di database latihan Anda, tepat setelah file tabel di atas:
- Buka dua terminal dan jalankan
psqldi keduanya, terhubung ke database yang sama. Sebut terminal pertama A dan terminal kedua B. - Di terminal A, jalankan
BEGIN;laluSELECT saldo FROM rekening WHERE id = 1;— hasilnya Rp500.000. Biarkan transaksi ini terbuka. - Di terminal B, jalankan dua perintah yang sama. Hasilnya juga Rp500.000 — pembacaan tidak pernah saling memblokir berkat MVCC, jadi B sama sekali tidak tahu A sedang memegang angka yang sama. Biarkan juga transaksi B terbuka.
- Di terminal A, teller memproses penarikan Rp50.000: aplikasi menghitung 500000 - 50000 = 450000, lalu mengirim
UPDATE rekening SET saldo = 450000 WHERE id = 1;— hasilnyaUPDATE 1. Tutup denganCOMMIT;. - Di terminal B, teller kedua memproses penarikan Rp50.000 dari saldo yang sama yang ia baca:
UPDATE rekening SET saldo = 450000 WHERE id = 1;— hasilnyaUPDATE 1, tanpa keluhan apa pun. Tutup denganCOMMIT;. - Periksa hasil akhir dari terminal mana pun:
SELECT saldo FROM rekening WHERE id = 1;— nilainya Rp450.000, padahal dua penarikan Rp50.000 seharusnya menghasilkan Rp400.000. Rp50.000 menguap tanpa jejak. Pulihkan keadaan latihan denganUPDATE rekening SET saldo = 500000 WHERE id = 1;sebelum lanjut.
Setiap strategi di chapter ini menutup celah itu dari sudut berbeda, dan nasib teller B menjadi pembandingnya:
| Strategi | Nasib teller B pada langkah 5 | Saldo akhir |
|---|---|---|
| Tanpa strategi | UPDATE 1 tanpa keluhan — tulisan menimpa | salah: Rp450.000 |
Pessimistic (FOR UPDATE) | menunggu sampai A commit, lalu membaca Rp450.000 dan menghitung ulang Rp400.000 | benar |
Optimistic (kolom versi) | UPDATE 0 — tulisan ditolak, B membaca ulang dan mencoba lagi | benar |
| Serializable | COMMIT B ditolak dengan SQLSTATE 40001, B mengulang transaksi | benar |
Strategi Optimistic: Kolom Versi #
Strategi optimistic tidak mengunci apa pun saat membaca. Sebagai gantinya, setiap baris membawa nomor versi yang naik setiap kali berubah, dan penulisan hanya diterima bila versi baris masih sama dengan versi yang dilihat aplikasi saat membaca. Demo pertama memperagakan siklusnya yang berhasil — perhatikan bahwa tidak ada BEGIN yang ditahan: baca dan tulis adalah dua perintah terpisah, dan itulah inti strategi ini:
-- Siklus optimistic yang berhasil: baca saldo dan versi, hitung di aplikasi,
-- tulis dengan syarat versi belum berubah. Perhatikan: tidak ada BEGIN yang
-- ditahan - baca dan tulis adalah dua perintah terpisah, tanpa kunci apa pun.
-- Langkah 1: aplikasi membaca saldo dan versi terbaru.
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;
-- Langkah 2: aplikasi menghitung saldo baru: 500000 - 100000 = 400000,
-- lalu menulis dengan syarat versi yang dibacanya: 1.
UPDATE rekening
SET saldo = 400000, versi = versi + 1
WHERE id = 1 AND versi = 1;
-- Langkah 3: aplikasi memeriksa jumlah baris hasil UPDATE.
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;Output yang Dihasilkan:
id | nama | saldo | versi
----+--------------+-----------+-------
1 | Budi Santoso | 500000.00 | 1
(1 row)
UPDATE 1
id | nama | saldo | versi
----+--------------+-----------+-------
1 | Budi Santoso | 400000.00 | 2
(1 row)Mari kita bedah peran setiap bagian:
SELECTpembuka membaca saldo Rp500.000 bersamaversi = 1. Kedua nilai ini menjadi salinan kerja aplikasi — angka yang semua keputusan berikutnya dibangun di atasnya.UPDATE ... WHERE id = 1 AND versi = 1menulis nilai absolut hasil hitungan aplikasi sekaligus menaikkan versi, tetapi hanya untuk baris yang versinya masih 1. PasanganSET saldo = 400000, versi = versi + 1berarti “nilai baru datang dari luar, penanda pergantiannya dari dalam”.SELECTpenutup memastikan saldo Rp400.000 danversi = 2— tulisan diterima karena tidak ada pihak lain yang sempat mengubah baris ini lebih dulu.
Sekarang bagian yang sebenarnya: tulisan yang datang terlambat. Permintaan kedua — misalnya dikirim ulang oleh sistem teller yang masih memegang salinan lama Rp500.000 versi 1 — mencoba menulis dengan syarat versi yang sudah kedaluwarsa:
-- Siklus optimistic yang kalah lalu pulih: permintaan kedua masih memegang
-- salinan lama (saldo 500000, versi 1) karena dibaca sebelum tulisan pertama
-- terjadi. Syarat versi membuat tulisannya ditolak tanpa error - cukup
-- UPDATE 0 - lalu aplikasi membaca ulang dan mencoba lagi.
-- Percobaan dengan salinan basi: menghitung 500000 - 50000 = 450000,
-- menulis dengan syarat versi = 1.
UPDATE rekening
SET saldo = 450000, versi = versi + 1
WHERE id = 1 AND versi = 1;
-- UPDATE 0: tidak ada baris yang cocok, tulisan ditolak diam-diam.
-- Aplikasi membaca ulang keadaan termutakhir...
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;
-- ...menghitung ulang dari saldo yang baru: 400000 - 50000 = 350000,
-- lalu mencoba lagi dengan versi yang sekarang benar: 2.
UPDATE rekening
SET saldo = 350000, versi = versi + 1
WHERE id = 1 AND versi = 2;
-- Percobaan ulang diterima: UPDATE 1.
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;Output yang Dihasilkan:
UPDATE 0
id | nama | saldo | versi
----+--------------+-----------+-------
1 | Budi Santoso | 400000.00 | 2
(1 row)
UPDATE 1
id | nama | saldo | versi
----+--------------+-----------+-------
1 | Budi Santoso | 350000.00 | 3
(1 row)Tag UPDATE 0 pada percobaan pertama adalah seluruh kabar kekalahan itu — perintah sah, tidak ada error, hanya nol baris yang berubah karena syarat versi = 1 tidak lagi cocok. Logika keputusan aplikasi mengikuti jumlah baris itu:
UPDATE 1— tulisan diterima, saldo dan versi sudah bergerak; lanjutkan ke pekerjaan berikutnya.UPDATE 0— data berubah sejak terakhir dibaca; jangan paksa tulisan dengan melepas syarat versi.- Baca ulang saldo dan versi termutakhir, hitung ulang nilai baru dari data yang segar itu.
- Tulis kembali dengan versi yang baru dibaca — pada output di atas, percobaan kedua ini diterima (
UPDATE 1) dan saldo menjadi Rp350.000 denganversi = 3. - Bila kalah berulang kali, hentikan siklusnya setelah beberapa percobaan dan laporkan kondisi sibuk ke pengguna — loop tanpa batas hanya memindahkan antrean ke tempat lain.
Strategi Pessimistic: Kunci Dulu, Ubah Kemudian #
Strategi pessimistic membalik asumsinya: konflik dianggap pasti terjadi, jadi baris dikunci sebelum dibaca. SELECT ... FOR UPDATE mengambil kunci baris di tempat, penulis lain tertahan sampai transaksi pemegang kunci selesai, dan antrean itu justru yang diinginkan. Resep lengkapnya untuk transfer Rp50.000 dari Siti ke Dewi:
-- Siklus pessimistic: kunci dulu baris yang diperebutkan lewat FOR UPDATE,
-- baru ubah, lalu commit. ORDER BY id menjaga urutan penguncian konsisten
-- di semua penulis - syarat utama mencegah deadlock antar transfer.
BEGIN;
SELECT id, nama, saldo FROM rekening
WHERE id IN (2, 3)
ORDER BY id
FOR UPDATE;
UPDATE rekening SET saldo = saldo - 50000 WHERE id = 2;
UPDATE rekening SET saldo = saldo + 50000 WHERE id = 3;
COMMIT;
SELECT id, nama, saldo FROM rekening ORDER BY id;Output yang Dihasilkan:
BEGIN
id | nama | saldo
----+--------------+-----------
2 | Siti Aminah | 300000.00
3 | Dewi Lestari | 950000.00
(2 rows)
UPDATE 1
UPDATE 1
COMMIT
id | nama | saldo
----+--------------+------------
1 | Budi Santoso | 350000.00
2 | Siti Aminah | 250000.00
3 | Dewi Lestari | 1000000.00
(3 rows)Bedah perannya baris demi baris:
BEGINmembuka transaksi — kunci baris hanya hidup di dalam transaksi, jadi strategi ini selalu berupa siklus utuh, bukan perintah tunggal.SELECT ... FOR UPDATEmembaca sekaligus mengunci dua baris rekening. KlausaORDER BY idbukan hiasan: seluruh penulis yang mengunci banyak baris dengan urutan sama tidak akan pernah membentuk lingkaran tunggu — pola pencegah deadlock yang dibedah di chapter Deadlocks .- Dua
UPDATEmemindahkan Rp50.000 — perhitungan boleh berupa aritmetika server karena kunci menjamin tidak ada pihak lain yang menyisipkan perubahan di tengah. COMMITmengunci permanen perubahan dan melepas kedua kunci; penulis lain yang menunggu baru melanjutkan setelah titik ini.
Pembaca biasa tetap tidak terganggu sepanjang proses — MVCC menyajikan versi terakhir yang di-commit, topik chapter MVCC
— sehingga laporan dan layar nasabah tidak ikut mengantre. Perilaku lengkap mode-mode kunci baris, opsi NOWAIT, dan matriks benturannya dibahas di chapter Row Locking
; di sini yang penting adalah posisinya dalam strategi: untuk baris yang padat diperebutkan dan transaksi yang singkat, mengunci sejak awal lebih murah daripada mengulang pekerjaan yang kalah.
Antrian Pekerja dengan SKIP LOCKED #
Kasus ketiga bukan dua teller, melainkan sekawanan pekerja background yang memanen daftar pekerjaan yang sama. Tanpa pengaman, dua pekerja bisa mengambil tugas yang sama; dengan FOR UPDATE polos, pekerja kedua mengantre di belakang pilihan pekerja pertama padahal baris lain bebas. Kombinasi FOR UPDATE SKIP LOCKED menyelesaikan keduanya: baris yang sedang dipegang pekerja lain dilewati, bukan ditunggu:
-- Pola pekerja antrian: ambil satu pekerjaan tertua yang masih menunggu
-- dengan FOR UPDATE SKIP LOCKED. Dalam satu sesi semua baris bebas, jadi
-- pekerjaan pertama langsung didapat; saat banyak pekerja berjalan bersamaan,
-- SKIP LOCKED membuat masing-masing melompati baris yang sedang dipegang
-- pekerja lain tanpa mengantre.
BEGIN;
WITH diambil AS (
SELECT id
FROM antrian_tugas
WHERE status = 'menunggu'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE antrian_tugas a
SET status = 'selesai'
FROM diambil d
WHERE a.id = d.id
RETURNING a.id, a.tugas, a.status;
COMMIT;
SELECT id, tugas, status FROM antrian_tugas ORDER BY id;Output yang Dihasilkan:
BEGIN
id | tugas | status
----+-------------------------------+---------
1 | kirim ringkasan mutasi harian | selesai
(1 row)
UPDATE 1
COMMIT
id | tugas | status
----+-------------------------------+----------
1 | kirim ringkasan mutasi harian | selesai
2 | hitung bunga tabungan | menunggu
3 | arsipkan mutasi bulan lalu | menunggu
4 | kirim notifikasi saldo rendah | menunggu
(4 rows)Dalam satu sesi, seluruh baris bebas — SKIP LOCKED tidak melewati apa pun, dan pekerjaan tertua (id 1, kirim ringkasan mutasi harian) langsung menjadi milik pekerja. Nilai pola ini baru meledak saat pekerja lain hadir: pekerja kedua yang menjalankan perintah yang persis sama akan melompati baris yang terkunci dan mengambil pekerjaan berikutnya yang bebas — simulasi dua pekerja sekaligus berjalan sempurna di chapter Row Locking
. Satu perintah tunggal di dalam satu transaksi sudah memadai: CTE diambil memilih dan mengunci, UPDATE di luarnya menandai status, dan RETURNING memberi tahu pekerja persis pekerjaan mana yang menjadi tanggung jawabnya. Untuk pekerjaan yang benar-benar lama, tandai dulu diproses dan tutup transaksi penandaan dengan cepat, kerjakan pekerjaannya di luar database, lalu tutup siklus dengan selesai di transaksi kecil kedua — kunci tidak perlu dipeluk selama pengerjaan, tetapi akibatnya pekerjaan yang tertinggal karena proses mati di tengah jalan berstatus diproses selamanya dan perlu proses pemulihan untuk mengembalikannya ke menunggu.
Idempotency: Aman Mengirim Ulang #
Strategi terakhir menjawab masalah yang bukan berasal dari teller, melainkan dari jaringan: respons server hilang di tengah jalan setelah COMMIT sampai, aplikasi tidak tahu apakah penarikan sudah tercatat atau belum, dan satu-satunya tindakan yang wajar adalah mengirim ulang. Pengiriman ulang seperti itu hanya aman bila operasinya idempotent — menjalankannya dua kali memberi efek yang sama dengan sekali. Database menyediakan penegaknya lewat constraint UNIQUE pada kolom kunci_idempoten:
-- Pengiriman ulang operasi yang sama ditolak oleh kunci idempoten:
-- INSERT pertama berhasil, INSERT kedua dengan kunci yang sama
-- melanggar constraint UNIQUE dan gagal (SQLSTATE 23505).
INSERT INTO transfer_log (kunci_idempoten, keterangan, jumlah)
VALUES ('TRF-2026-0001', 'Tarik tunai Budi Rp100.000', 100000);
INSERT INTO transfer_log (kunci_idempoten, keterangan, jumlah)
VALUES ('TRF-2026-0001', 'Tarik tunai Budi Rp100.000', 100000);Output yang Dihasilkan:
psql:idempotensi-replay.sql:9: ERROR: duplicate key value violates unique constraint "transfer_log_kunci_idempoten_key"
DETAIL: Key (kunci_idempoten)=(TRF-2026-0001) already exists.INSERT pertama berhasil dan ter-commit otomatis; pengiriman ulang dengan kunci TRF-2026-0001 yang sama ditolak dengan duplicate key value violates unique constraint — SQLSTATE 23505, bukan kegagalan sistem melainkan kabar baik: operasi ini sudah tercatat. Bentuk kedua menyerap pengiriman ulang tanpa error sama sekali lewat ON CONFLICT DO NOTHING:
-- Bentuk halus yang sama tanpa error: ON CONFLICT DO NOTHING menyerap
-- pengiriman ulang dan melaporkan INSERT 0 0.
-- SELECT terakhir membuktikan hanya satu baris yang tercatat meskipun
-- operasi sudah dikirim tiga kali (satu berhasil, satu ditolak, satu diserap).
INSERT INTO transfer_log (kunci_idempoten, keterangan, jumlah)
VALUES ('TRF-2026-0001', 'Tarik tunai Budi Rp100.000', 100000)
ON CONFLICT (kunci_idempoten) DO NOTHING;
SELECT id, kunci_idempoten, keterangan, jumlah
FROM transfer_log
ORDER BY id;Output yang Dihasilkan:
INSERT 0 0
id | kunci_idempoten | keterangan | jumlah
----+-----------------+----------------------------+-----------
1 | TRF-2026-0001 | Tarik tunai Budi Rp100.000 | 100000.00
(1 row)Setelah tiga kali pengiriman — satu diterima, satu ditolak, satu diserap — tabel tetap memuat tepat satu baris. Pilihan antara kedua bentuk itu soal selera alur aplikasi: menangkap 23505 dan menganggapnya “sudah diproses” cocok untuk alur yang memang berbasis error handling, sedangkan INSERT 0 0 dari ON CONFLICT DO NOTHING cocok untuk alur yang memeriksa jumlah baris — angka nol berarti operasi sudah pernah masuk. Kunci idempoten sendiri sebaiknya lahir dari sisi bisnis: nomor referensi transfer, identitas pesanan, atau token permintaan yang dibawa sejak awal, bukan dihitung dari isi baris.
Retry yang Tepat Sasaran #
Semua strategi di atas berujung pada satu keterampilan akhir: mengulang dengan benar. Transaksi serializable dikalahkan lewat 40001, deadlock dikalahkan lewat 40P01, dan penolakan NOWAIT datang sebagai 55P03 — ketiganya menyatakan hal yang sama: coba lagi dengan keadaan yang lebih baru, dan kemungkinan besar Anda akan berhasil. Kode-kode itulah satu-satunya yang layak diulang:
| SQLSTATE | Nama | Pemicu | Layak diulang |
|---|---|---|---|
40001 | serialization_failure | SSI mendeteksi tabrakan logika pada transaksi SERIALIZABLE | ya |
40P01 | deadlock_detected | server membatalkan satu transaksi untuk memutus lingkaran tunggu | ya |
55P03 | lock_not_available | FOR UPDATE NOWAIT atau lock_timeout menolak mengantre | ya |
23505 | unique_violation | kunci idempoten menolak duplikat | tidak — artinya operasi sudah tercatat |
23514 | check_violation | data melanggar aturan bisnis | tidak — mengulang tidak mengubah aturan |
Siklus ulangnya sendiri sudah dibedah lengkap di chapter Serializable Transactions : tangkap error, pastikan transaksi lama tertutup, buka transaksi baru, jalankan ulang seluruh logika dari pembacaan termutakhir, batasi jumlah percobaan, dan beri jeda antar percobaan. Satu syarat membuat siklus itu aman — syarat yang menghubungkan bagian ini dengan bagian sebelumnya.
Panduan Keputusan Strategi #
Peta akhirnya bisa diringkas menjadi satu tabel keputusan — enam skenario yang menutup hampir semua kebutuhan aplikasi nyata:
| Skenario | Strategi paling pas | Alat utama |
|---|---|---|
| Transfer antar rekening, konflik sering, transaksi pendek | Pessimistic | SELECT ... FOR UPDATE dalam transaksi singkat |
| Mengubah data yang jarang diubah dua pihak sekaligus | Optimistic | kolom versi + UPDATE ... WHERE versi = X |
| Invariant lintas banyak baris (total saldo, stok gabungan) | Serializable + retry | BEGIN ISOLATION LEVEL SERIALIZABLE, tangkap 40001 |
| Banyak pekerja memanen daftar pekerjaan | Antrian pekerja | FOR UPDATE SKIP LOCKED + LIMIT |
| Hanya satu proses yang boleh jalan (maintenance, cron) | Eksklusi kasar | advisory lock, mis. pg_advisory_xact_lock |
| Operasi yang bisa dikirim ulang jaringan/aplikasi | Idempotency | UNIQUE pada kunci idempoten + ON CONFLICT |
Baris kelima perlu satu penjelasan singkat karena alatnya berbeda dari yang lain: advisory lock tidak menempel pada baris mana pun, melainkan menjadi kesepakatan “pemegang kunci inilah satu-satunya yang bekerja” antar proses — pola pemilih tunggal untuk pekerjaan maintenance yang dibedah penuh di chapter Advisory Locks . Dua keputusan desain melengkapi tabel itu: pertahankan transaksi sependek mungkin di semua strategi, karena durasi adalah bahan bakar antrean dan benturan; dan saat satu baris menjadi titik panas yang tak terhindarkan, pertimbangkan memecah pembaruan menjadi antrean pekerjaan — memindahkan perebutan dari baris saldo ke baris antrian sering kali meredamnya lebih murah daripada menaikkan level isolasi.
Bagian tersulit dari konkurensi kini sudah di tangan Anda: mengenali anomali, memilih strategi, menegakkan idempotency, dan mengulang dengan disiplin. Data di baliknya pun siap untuk bentuk berikutnya — payload API yang tiba sebagai JSON, dokumen semi-terstruktur yang disimpan apa adanya, dan query yang menggali nilai dari dalamnya. Section JSON dan Data Semi-terstruktur membuka babak itu.
Referensi #
- Explicit Locking (Row-level Locks, Advisory Locks)
- INSERT Reference (ON CONFLICT)
- Transaction Isolation
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Tiga tabel latihan untuk strategi konkurensi:
-- rekening : saldo ditambah kolom versi untuk optimistic locking;
-- antrian_tugas: daftar pekerjaan background untuk pola SKIP LOCKED;
-- transfer_log : catatan operasi dengan kunci idempoten UNIQUE.
CREATE TABLE rekening (
id integer PRIMARY KEY,
nama text NOT NULL,
jenis text NOT NULL,
saldo numeric(12, 2) NOT NULL CHECK (saldo >= 0),
versi integer NOT NULL DEFAULT 1,
CONSTRAINT saldo_ewallet_maks
CHECK (jenis <> 'e-wallet' OR saldo <= 1000000)
);
CREATE TABLE antrian_tugas (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tugas text NOT NULL,
status text NOT NULL DEFAULT 'menunggu'
CHECK (status IN ('menunggu', 'diproses', 'selesai'))
);
CREATE TABLE transfer_log (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kunci_idempoten text NOT NULL UNIQUE,
keterangan text NOT NULL,
jumlah numeric(12, 2) NOT NULL CHECK (jumlah > 0)
);
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_tugas (tugas) VALUES
('kirim ringkasan mutasi harian'),
('hitung bunga tabungan'),
('arsipkan mutasi bulan lalu'),
('kirim notifikasi saldo rendah');
SELECT id, nama, jenis, saldo, versi FROM rekening ORDER BY id;
SELECT id, tugas, status FROM antrian_tugas ORDER BY id;
idempotensi-hasil.sql
Unduh berkas-- Bentuk halus yang sama tanpa error: ON CONFLICT DO NOTHING menyerap
-- pengiriman ulang dan melaporkan INSERT 0 0.
-- SELECT terakhir membuktikan hanya satu baris yang tercatat meskipun
-- operasi sudah dikirim tiga kali (satu berhasil, satu ditolak, satu diserap).
INSERT INTO transfer_log (kunci_idempoten, keterangan, jumlah)
VALUES ('TRF-2026-0001', 'Tarik tunai Budi Rp100.000', 100000)
ON CONFLICT (kunci_idempoten) DO NOTHING;
SELECT id, kunci_idempoten, keterangan, jumlah
FROM transfer_log
ORDER BY id;
idempotensi-replay.sql
Unduh berkas-- Pengiriman ulang operasi yang sama ditolak oleh kunci idempoten:
-- INSERT pertama berhasil, INSERT kedua dengan kunci yang sama
-- melanggar constraint UNIQUE dan gagal (SQLSTATE 23505).
INSERT INTO transfer_log (kunci_idempoten, keterangan, jumlah)
VALUES ('TRF-2026-0001', 'Tarik tunai Budi Rp100.000', 100000);
INSERT INTO transfer_log (kunci_idempoten, keterangan, jumlah)
VALUES ('TRF-2026-0001', 'Tarik tunai Budi Rp100.000', 100000);
optimistic-kalah.sql
Unduh berkas-- Siklus optimistic yang kalah lalu pulih: permintaan kedua masih memegang
-- salinan lama (saldo 500000, versi 1) karena dibaca sebelum tulisan pertama
-- terjadi. Syarat versi membuat tulisannya ditolak tanpa error - cukup
-- UPDATE 0 - lalu aplikasi membaca ulang dan mencoba lagi.
-- Percobaan dengan salinan basi: menghitung 500000 - 50000 = 450000,
-- menulis dengan syarat versi = 1.
UPDATE rekening
SET saldo = 450000, versi = versi + 1
WHERE id = 1 AND versi = 1;
-- UPDATE 0: tidak ada baris yang cocok, tulisan ditolak diam-diam.
-- Aplikasi membaca ulang keadaan termutakhir...
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;
-- ...menghitung ulang dari saldo yang baru: 400000 - 50000 = 350000,
-- lalu mencoba lagi dengan versi yang sekarang benar: 2.
UPDATE rekening
SET saldo = 350000, versi = versi + 1
WHERE id = 1 AND versi = 2;
-- Percobaan ulang diterima: UPDATE 1.
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;
optimistic-menang.sql
Unduh berkas-- Siklus optimistic yang berhasil: baca saldo dan versi, hitung di aplikasi,
-- tulis dengan syarat versi belum berubah. Perhatikan: tidak ada BEGIN yang
-- ditahan - baca dan tulis adalah dua perintah terpisah, tanpa kunci apa pun.
-- Langkah 1: aplikasi membaca saldo dan versi terbaru.
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;
-- Langkah 2: aplikasi menghitung saldo baru: 500000 - 100000 = 400000,
-- lalu menulis dengan syarat versi yang dibacanya: 1.
UPDATE rekening
SET saldo = 400000, versi = versi + 1
WHERE id = 1 AND versi = 1;
-- Langkah 3: aplikasi memeriksa jumlah baris hasil UPDATE.
SELECT id, nama, saldo, versi FROM rekening WHERE id = 1;
pessimistic-for-update.sql
Unduh berkas-- Siklus pessimistic: kunci dulu baris yang diperebutkan lewat FOR UPDATE,
-- baru ubah, lalu commit. ORDER BY id menjaga urutan penguncian konsisten
-- di semua penulis - syarat utama mencegah deadlock antar transfer.
BEGIN;
SELECT id, nama, saldo FROM rekening
WHERE id IN (2, 3)
ORDER BY id
FOR UPDATE;
UPDATE rekening SET saldo = saldo - 50000 WHERE id = 2;
UPDATE rekening SET saldo = saldo + 50000 WHERE id = 3;
COMMIT;
SELECT id, nama, saldo FROM rekening ORDER BY id;
skip-locked-antrian.sql
Unduh berkas-- Pola pekerja antrian: ambil satu pekerjaan tertua yang masih menunggu
-- dengan FOR UPDATE SKIP LOCKED. Dalam satu sesi semua baris bebas, jadi
-- pekerjaan pertama langsung didapat; saat banyak pekerja berjalan bersamaan,
-- SKIP LOCKED membuat masing-masing melompati baris yang sedang dipegang
-- pekerja lain tanpa mengantre.
BEGIN;
WITH diambil AS (
SELECT id
FROM antrian_tugas
WHERE status = 'menunggu'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE antrian_tugas a
SET status = 'selesai'
FROM diambil d
WHERE a.id = d.id
RETURNING a.id, a.tugas, a.status;
COMMIT;
SELECT id, tugas, status FROM antrian_tugas ORDER BY id;