Aturan bank menyebut total saldo Budi dan Siti tidak boleh jatuh di bawah Rp600.000. Dua teller membuka aplikasi pada saat yang sama, keduanya membaca total Rp800.000, keduanya menyimpulkan “masih ada ruang”, dan keduanya mengirim penarikan Rp150.000 — total jatuh ke Rp500.000 dan aturan dilanggar tanpa satu pun perintah yang menolak. Fenomena ini bernama write skew: tidak ada dua transaksi yang menyentuh baris yang sama, jadi kunci baris tidak bisa menolong, dan Repeatable Read pun membiarkannya lewat. Isolation level SERIALIZABLE menutup celah ini: PostgreSQL menjamin hasil sekelompok transaksi bersamaan setara dengan hasil bila transaksi-transaksi itu dijalankan satu per satu, dan bila jaminan itu terancam, salah satu transaksi dibatalkan dengan error yang harus ditangani aplikasi. Chapter ini membedah cara kerja SSI (Serializable Snapshot Isolation), sintaks pengaktifannya, jejaknya di pg_locks, sampai pola retry yang wajib menyertai level ini.
Menyiapkan Tabel Latihan #
Demo memakai tabel rekening yang sudah akrab dari chapter-chapter sebelumnya, dibuat ulang di database chapter ini supaya contoh tetap mandiri. Tiga rekening dengan saldo tetap menjadi bahan bacaan sekaligus bahan tabrakan logika:
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)Mengaktifkan Level Serializable #
Seperti level lainnya, SERIALIZABLE dipasang per transaksi lewat dua bentuk sintaks yang sudah diperkenalkan di chapter Isolation Levels
: menempelkan levelnya langsung di BEGIN, atau membuka transaksi polos lalu mengatur levelnya lewat SET TRANSACTION sebagai perintah pertama. SHOW TRANSACTION ISOLATION LEVEL memastikan keduanya benar-benar aktif:
-- Dua cara mengaktifkan level SERIALIZABLE untuk satu transaksi.
-- Bentuk pertama: level menempel langsung di BEGIN.
-- Bentuk kedua: transaksi dibuka polos, level diatur lewat SET TRANSACTION
-- sebagai perintah pertama transaksi.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SHOW TRANSACTION ISOLATION LEVEL;
COMMIT;
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SHOW TRANSACTION ISOLATION LEVEL;
COMMIT;Output yang Dihasilkan:
BEGIN
transaction_isolation
-----------------------
serializable
(1 row)
COMMIT
BEGIN
SET
transaction_isolation
-----------------------
serializable
(1 row)
COMMITKedua transaksi melaporkan serializable. Aturan dari chapter Isolation Levels tetap berlaku: SET TRANSACTION hanya sah sebagai perintah pertama transaksi — setelah satu query pun berjalan, PostgreSQL menolaknya dengan SET TRANSACTION ISOLATION LEVEL must be called before any query. Bagi aplikasi yang ingin menjadikan serializable sebagai kebiasaan tetap, alternatifnya adalah mengatur default_transaction_isolation di sisi server, tetapi perubahan itu menyasar seluruh transaksi di database dan sebaiknya diputuskan bersama administrator.
Transaksi Serializable Lengkap sampai Commit #
Level yang lebih ketat tidak mengubah bentuk perintah. Transfer Rp100.000 dari Budi ke Siti tetap dibangun dari baca, dua UPDATE, dan COMMIT — perbedaannya hanya klausa level di BEGIN:
-- Transaksi serializable lengkap dari awal sampai commit:
-- buka dengan levelnya, baca saldo kedua rekening, jalankan transfer,
-- tutup dengan COMMIT, lalu verifikasi hasilnya.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT id, nama, saldo FROM rekening WHERE id IN (1, 2) ORDER BY id;
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
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:
BEGIN ISOLATION LEVEL SERIALIZABLEmembuka transaksi sekaligus menetapkan levelnya. Sejak titik ini, seluruh pembacaan transaksi ini dilacak oleh SSI.SELECT ... WHERE id IN (1, 2)membaca saldo kedua rekening. Di level ini, pembacaan bukan kejadian pasif — PostgreSQL mencatat bahwa transaksi ini “pernah melihat” baris-baris tersebut.- Dua
UPDATEmemindahkan Rp100.000 dari Budi ke Siti. Angka hasilnya sama persis dengan eksekusi di level default karena tidak ada transaksi lain yang ikut bermain. COMMITmenutup transaksi. Di level inilah letak bedanya: saat commit, SSI memeriksa apakah pembacaan dan penulisan transaksi ini membentuk tabrakan logika dengan transaksi serializable lain — bila ya, commit ditolak. Di sini tidak ada lawan, jadi commit diterima.
Kejujuran perlu disampaikan: dalam satu sesi, transaksi serializable berjalan persis seperti transaksi biasa — perlindungannya baru berarti ketika transaksi kedua hadir pada saat bersamaan. Bagian berikutnya membedah apa yang sebenarnya dikerjakan PostgreSQL di balik layar, sebelum kita membangun tabrakan yang sesungguhnya lewat dua terminal.
Jejak SSI: Kunci Predikat #
Serializable Snapshot Isolation bekerja dengan cara mencatat apa saja yang dibaca setiap transaksi — rekaman itu disebut kunci predikat, dan PostgreSQL menyimpannya dengan mode SIReadLock. Kunci predikat tidak pernah memblokir siapa pun; fungsinya murni menjadi bahan bukti saat SSI mencari tabrakan. Rekaman ini terlihat langsung di catalog pg_locks:
-- Jejak SSI: membaca tabel dalam transaksi serializable meninggalkan
-- kunci predikat (SIReadLock) yang terlihat di pg_locks.
-- count(*) memindai seluruh tabel, jadi kuncinya menempel pada relasi
-- secara utuh (locktype = relation), bukan pada baris tertentu.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS jumlah_rekening FROM rekening;
SELECT locktype, relation::regclass AS tabel, page, tuple, mode, granted
FROM pg_locks
WHERE mode = 'SIReadLock'
AND relation = 'rekening'::regclass;
ROLLBACK;
SELECT count(*) AS sisa_kunci_si_read
FROM pg_locks
WHERE mode = 'SIReadLock'
AND relation = 'rekening'::regclass;Output yang Dihasilkan:
BEGIN
jumlah_rekening
-----------------
3
(1 row)
locktype | tabel | page | tuple | mode | granted
----------+----------+------+-------+------------+---------
relation | rekening | | | SIReadLock | t
(1 row)
ROLLBACK
sisa_kunci_si_read
--------------------
0
(1 row)Hasilnya bercerita tentang seluruh siklusnya. Query count(*) mengembalikan 3 rekening, dan tepat setelah itu pg_locks memuat satu baris SIReadLock pada tabel rekening dengan locktype = relation — pembacaan seluruh tabel membuat SSI mencatat “transaksi ini mungkin telah melihat semua baris rekening”. Setelah ROLLBACK, penghitungan terakhir melaporkan 0: kunci predikat ikut lenyap saat transaksi berakhir, tanpa membedakan commit atau rollback.
Error Biasa Bukan Serialization Failure #
Sebelum membahas cara menangani serialization failure, kita perlu memastikan satu hal: error yang muncul di dalam transaksi serializable tidak selalu merupakan serialization failure. File berikut menjalankan dua UPDATE — yang pertama sah, yang kedua mencoba menarik saldo Budi menjadi negatif dan melanggar CHECK (saldo >= 0):
-- Error di dalam transaksi serializable TIDAK selalu serialization failure.
-- Dua UPDATE di sini: yang pertama sah, yang kedua melanggar CHECK saldo >= 0.
-- Hasilnya error constraint biasa (SQLSTATE 23514), bukan 40001.
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE rekening SET saldo = saldo + 50000 WHERE id = 2;
UPDATE rekening SET saldo = saldo - 500000 WHERE id = 1;
COMMIT;Output yang Dihasilkan:
psql:error-bukan-serialisasi.sql:9: ERROR: new row for relation "rekening" violates check constraint "rekening_saldo_check"
DETAIL: Failing row contains (1, Budi Santoso, tabungan, -100000.00).Error new row for relation "rekening" violates check constraint "rekening_saldo_check" adalah pelanggaran aturan bisnis dengan SQLSTATE 23514 — angka ini bukan 40001. Level serializable tidak mengubah makna constraint sedikit pun. Konsekuensinya penting bagi pola retry: mengulang transaksi ini akan gagal lagi selamanya karena datanya memang melanggar aturan, sedangkan serialization failure justru layak diulang karena kegagalan disebabkan tabrakan waktu, bukan isi data. Perhatikan pula bahwa UPDATE pertama (setoran Rp50.000 untuk Siti) ikut lenyap — transaksi yang error di tengah jalan dibatalkan seluruhnya, dan saldo ketiga rekening tetap seperti hasil demo sebelumnya.
Eksperimen Dua Terminal: Write Skew dan Error 40001 #
Serialization failure tidak bisa dibuktikan dari satu sesi — error ini hanya lahir dari tabrakan dua transaksi yang sungguh-sungguh berjalan bersamaan, dan tidak ada urutan perintah dalam satu sesi yang bisa memalsukannya. Karena itu benturan aslinya kita bangun lewat dua terminal psql. Skenarionya kembali ke aturan pembuka chapter: total saldo Budi dan Siti (saat ini Rp800.000) tidak boleh jatuh di bawah Rp600.000, dan dua teller sama-sama mau menarik Rp150.000:
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 ISOLATION LEVEL SERIALIZABLE;laluSELECT sum(saldo) AS total FROM rekening WHERE id IN (1, 2);— hasilnya Rp800.000. Biarkan transaksi ini terbuka.Di terminal B, jalankan dua perintah yang sama persis. Total yang terbaca juga Rp800.000 — snapshot B diambil sebelum A menulis apa pun. Kedua teller kini sama-sama “melihat” ruang Rp200.000.
Di terminal A, jalankan
UPDATE rekening SET saldo = saldo - 150000 WHERE id = 1;— hasilnyaUPDATE 1— laluCOMMIT;. Penarikan A sah dan tercatat.Di terminal B, jalankan
UPDATE rekening SET saldo = saldo - 150000 WHERE id = 2;. Perintah ini ditolak dengan:textERROR: could not serialize access due to read/write dependencies among transactions DETAIL: Reason code: Canceled on identification as a pivot, during write. HINT: The transaction might succeed if retried.Tergantung timing, kadang
UPDATEB sempat diterima (UPDATE 1) dan penolakan baru muncul saatCOMMIT— titik deteksi boleh berpindah, hasil akhirnya tidak: transaksi B batal seluruhnya.Verifikasi dari terminal mana pun:
SELECT sum(saldo) FROM rekening WHERE id IN (1, 2);— hasilnya Rp650.000. Hanya penarikan A yang tercatat; aturan Rp600.000 selamat. Bila Anda mengulang eksperimen yang sama denganBEGIN ISOLATION LEVEL REPEATABLE READ;di kedua terminal, kedua commit justru diterima dan total menjadi Rp500.000 — write skew lolos karena baris yang disentuh memang berbeda. Sebelum pindah ke bagian berikutnya, pulihkan keadaan latihan denganUPDATE rekening SET saldo = 400000 WHERE id = 1;danUPDATE rekening SET saldo = 400000 WHERE id = 2;.
Error di langkah 5 membawa SQLSTATE 40001 (serialization_failure) — kode inilah yang menjadi sinyal resmi bagi aplikasi. Ulangi pembacaan SQLSTATE itu dari psql dengan meta-command \errverbose setelah error terjadi; keluarannya menampilkan ERROR: 40001: could not serialize access due to read/write dependencies among transactions. Berbeda dengan error constraint, kegagalan ini menyatakan “coba lagi dan Anda mungkin berhasil” — itulah mengapa pola retry menjadi bagian tak terpisahkan dari level serializable.
Pola Retry untuk SQLSTATE 40001 #
Aplikasi yang memakai level serializable wajib mengulang transaksi yang kalah. Pola yang benar berbentuk siklus, bukan sekadar menjalankan ulang query yang gagal:
- Tangkap error dan periksa SQLSTATE-nya. Hanya
40001(serialization failure) — ditambah40P01untuk deadlock yang dibahas di chapter Deadlocks dan55P03untuk penolakanNOWAIT— yang layak diulang. - Pastikan transaksi yang gagal benar-benar tertutup. Koneksi driver biasanya sudah meng-rollback otomatis; bila tidak, jalankan
ROLLBACKsecara eksplisit sebelum melanjutkan. - Buka transaksi BARU dengan
BEGIN ISOLATION LEVEL SERIALIZABLE. Snapshot baru berarti semua pembacaan mengambil data termutakhir — termasuk hasil commit milik transaksi yang menang. - Jalankan ulang seluruh logika transaksi dari awal, bukan hanya perintah terakhir. Keputusan bisnis yang diambil dari pembacaan lama harus dihitung ulang dari pembacaan baru — pada eksperimen di atas, teller B yang mengulang akan membaca total Rp650.000 dan aturan Rp600.000 kini menolak penarikan Rp150.000 berikutnya.
- Batasi jumlah percobaan dan beri jeda antar percobaan. Tabrakan yang berulang biasanya menandakan transaksi terlalu panjang atau terlalu banyak proses yang menyasar data sama.
- Bila SQLSTATE-nya bukan kode yang layak retry — seperti
23514pada demo sebelumnya — perlakukan sebagai kegagalan bisnis biasa: jangan diulang, laporkan ke pengguna atau log.
Perbandingan Tiga Level #
Dengan ketiga level yang kini sudah dibedah, peta lengkapnya bisa dirangkum dalam satu tabel:
| Aspek | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|
| Serialization anomaly (write skew) | bisa terjadi | bisa terjadi | dicegah — transaksi korban ditolak |
| Konflik tulis pada baris yang sama | penulis kedua menunggu lalu menulis di atas versi baru | penulis kedua ditolak dengan could not serialize access due to concurrent update | sama seperti repeatable read, ditambah deteksi tabrakan predikat |
| Biaya overhead | paling ringan | ringan | paling berat: kunci predikat dan pelacakan ketergantungan |
| Beban aplikasi | minimal | tangani error concurrent update | wajib menangkap 40001 dan mengulang transaksi |
| Cocok untuk | operasi umum sehari-hari | laporan multi-perintah, konsistensi baca | invariant lintas baris: saldo gabungan, stok, kuota |
Kolom terakhir adalah alasan eksistensi level ini: ketika kebenaran dihitung dari kumpulan baris — bukan satu baris tunggal — dan kesalahan tidak boleh terjadi, serializable menawarkan jaminan yang tidak dimiliki level lain.
Kapan Serializable Layak Dipakai #
Level serializable paling tepat untuk transaksi pendek yang menjaga kebenaran kritis: cadangan saldo yang boleh dijatuhkan sebatas total gabungan, reservasi stok lintas gudang, pembagian kuota, atau penerapan diskon yang tidak boleh dobel. Semua kasus itu punya ciri yang sama — aturan bisnisnya melintasi beberapa baris sekaligus, sehingga mengunci “baris yang benar” di muka sulit dilakukan: SELECT FOR UPDATE menuntut kita tahu dulu baris mana yang akan diperebutkan, sedangkan laporan agregat sering baru tahu setelah membaca banyak baris.
Lawan kondisinya juga jelas. Transaksi panjang di level serializable menumpuk kunci predikat dan memperbesar peluang benturan, sehingga retry terjadi makin sering — pola yang memakan throughput. Untuk laporan berdurasi panjang yang hanya membaca, PostgreSQL menyediakan kombinasi BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE: transaksi seperti ini menunggu snapshot yang dijamin aman, tidak pernah gagal dengan 40001, dan tidak pernah menjadi biang tabrakan bagi transaksi lain.
Tabrakan waktu kini punya tiga lapis jawaban: kunci baris untuk pertarungan langsung, kunci predikat untuk pertarungan logika, dan pola retry untuk mengulang yang kalah. Chapter berikutnya menutup section ini dengan merangkai semua alat tersebut menjadi strategi utuh — kapan memilih pessimistic, kapan optimistic, kapan antrian SKIP LOCKED, dan bagaimana idempotency membuat pengulangan menjadi aman — dalam satu panduan keputusan untuk aplikasi nyata.
Referensi #
- Transaction Isolation (Serializable Isolation Level)
- SET TRANSACTION Reference
- Concurrency Control (MVCC)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
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;
error-bukan-serialisasi.sql
Unduh berkas-- Error di dalam transaksi serializable TIDAK selalu serialization failure.
-- Dua UPDATE di sini: yang pertama sah, yang kedua melanggar CHECK saldo >= 0.
-- Hasilnya error constraint biasa (SQLSTATE 23514), bukan 40001.
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE rekening SET saldo = saldo + 50000 WHERE id = 2;
UPDATE rekening SET saldo = saldo - 500000 WHERE id = 1;
COMMIT;
kunci-predikat.sql
Unduh berkas-- Jejak SSI: membaca tabel dalam transaksi serializable meninggalkan
-- kunci predikat (SIReadLock) yang terlihat di pg_locks.
-- count(*) memindai seluruh tabel, jadi kuncinya menempel pada relasi
-- secara utuh (locktype = relation), bukan pada baris tertentu.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS jumlah_rekening FROM rekening;
SELECT locktype, relation::regclass AS tabel, page, tuple, mode, granted
FROM pg_locks
WHERE mode = 'SIReadLock'
AND relation = 'rekening'::regclass;
ROLLBACK;
SELECT count(*) AS sisa_kunci_si_read
FROM pg_locks
WHERE mode = 'SIReadLock'
AND relation = 'rekening'::regclass;
set-level.sql
Unduh berkas-- Dua cara mengaktifkan level SERIALIZABLE untuk satu transaksi.
-- Bentuk pertama: level menempel langsung di BEGIN.
-- Bentuk kedua: transaksi dibuka polos, level diatur lewat SET TRANSACTION
-- sebagai perintah pertama transaksi.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SHOW TRANSACTION ISOLATION LEVEL;
COMMIT;
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SHOW TRANSACTION ISOLATION LEVEL;
COMMIT;
transfer-serializable.sql
Unduh berkas-- Transaksi serializable lengkap dari awal sampai commit:
-- buka dengan levelnya, baca saldo kedua rekening, jalankan transfer,
-- tutup dengan COMMIT, lalu verifikasi hasilnya.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT id, nama, saldo FROM rekening WHERE id IN (1, 2) ORDER BY id;
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;