Home / Programming Languages, Databases, and Software Development / Transaksi …

Transaksi PostgreSQL: Isolation Level dari Read Committed sampai Serializable

Read Committed tidak mencegah lost update, dan Repeatable Read tidak mencegah semua anomali serialisasi; dua klaim itu sering diulang tapi jarang diuji. Artikel ini menjalankan skenario dua sesi pada PostgreSQL 17.11 dan menunjukkan output aslinya, termasuk pesan error yang harus ditangani aplikasi.

Ringkasan

  • PostgreSQL hanya mengimplementasikan tiga level internal; Read Uncommitted diperlakukan sebagai Read Committed.
  • Read Committed mengambil snapshot baru di tiap perintah, jadi dua SELECT bisa berbeda.
  • Repeatable Read membekukan snapshot di awal transaksi dan menutup phantom read.
  • Repeatable Read dan Serializable sama-sama menolak transaksi dengan SQLSTATE 40001.
  • Lost update terjadi saat aplikasi menulis nilai absolut hasil perhitungannya sendiri.
  • SELECT ... FOR UPDATE memblokir, lalu mengembalikan versi baris terbaru untuk dihitung ulang.
  • SKIP LOCKED mengubah baris terkunci menjadi pola antrean tanpa menunggu.
  • Nilai sequence tidak pernah di-rollback, sehingga ROLLBACK tetap menyisakan celah id.

Lingkungan Pengujian

Seluruh output berasal dari container sekali pakai, bukan dari asumsi.

KomponenNilai TerujiKeterangan
PostgreSQL17.11 (Debian 17.11-1.pgdg13+2)x86_64, compiler gcc 14.2.0
Imagepostgres:17Dijalankan dengan docker run
Sesi ujiDua koneksi psql terpisahDiurutkan dengan pg_sleep agar reproducible
RujukanDokumentasi PostgreSQL 17Dipin ke versi 17 agar kutipan tidak bergeser

Dokumentasi “current” di situs PostgreSQL menunjuk ke versi 18 saat artikel ini ditulis, sementara 19 masih Beta 4. Karena itu semua tautan di bawah memakai nomor versi 17.

Skema yang dipakai sepanjang artikel:

sql
CREATE TABLE akun (id int PRIMARY KEY, nama text NOT NULL, saldo int NOT NULL);
INSERT INTO akun VALUES (1, 'andi', 100);

CREATE TABLE produk (id serial PRIMARY KEY, kategori text NOT NULL);
INSERT INTO produk (kategori) VALUES ('baju'), ('baju'), ('sepatu');

CREATE TABLE mytab (class int NOT NULL, value int NOT NULL);
INSERT INTO mytab VALUES (1, 10), (1, 20), (2, 100), (2, 200);

Mengapa PostgreSQL Tidak Mengunci untuk Baca

PostgreSQL menjaga konsistensi dengan model multiversi, atau MVCC. Setiap perintah SQL melihat snapshot database pada satu titik waktu, bukan keadaan terkini. Pengantar MVCC menyatakan jaminan ini: membaca tidak pernah memblokir menulis, dan menulis tidak pernah memblokir membaca, bahkan pada level Serializable.

Konsekuensinya besar untuk desain aplikasi. Anda hampir tidak perlu lock untuk membaca, dan lock yang Anda tulis sendiri dengan SELECT ... FOR UPDATE adalah pilihan sadar, bukan kebutuhan teknis untuk menghindari data rusak.

Empat Level dan Tiga Implementasi

Transaction Isolation menegaskan satu hal yang sering luput: PostgreSQL mengimplementasikan hanya tiga level internal. Read Uncommitted yang diminta aplikasi berjalan sebagai Read Committed.

Isolation LevelDirty ReadNonrepeatable ReadPhantom ReadSerialization Anomaly
Read uncommittedDiizinkan, tapi tidak di PGMungkinMungkinMungkin
Read committedTidak mungkinMungkinMungkinMungkin
Repeatable readTidak mungkinTidak mungkinDiizinkan, tapi tidak di PGMungkin
SerializableTidak mungkinTidak mungkinTidak mungkinTidak mungkin

Empat fenomena itu didefinisikan di tabel yang sama: dirty read membaca data dari transaksi yang belum commit, nonrepeatable read membaca ulang data yang berubah setelah commit-transaksi lain, phantom read menemukan himpunan baris yang memenuhi kondisi berubah, dan serialization anomaly membuat hasil commit tidak cocok dengan urutan serial mana pun.

Contoh dirty read di level Read Uncommitted. Sesi pertama mengubah saldo tanpa commit:

sql
BEGIN;
UPDATE akun SET saldo = 999 WHERE id = 1;
SELECT pg_sleep(3);
COMMIT;

Sesi kedua membaca dengan level paling longgar:

sql
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT 'T2 melihat saldo = ' || saldo FROM akun WHERE id = 1;
COMMIT;

Hasilnya:

text
T2 melihat saldo = 100

Nilai 999 yang belum commit tidak terlihat. Ini bukti bahwa Read Uncommitted bukan Read Uncommitted sungguhan, melainkan Read Committed dengan nama lain. Tapi SHOW transaction_isolation tetap melaporkan read uncommitted sesuai yang diminta, jadi jangan pakai GUC itu untuk menyimpulkan implementasi yang dipakai.

Read Committed: Snapshot Per Perintah

Read Committed adalah default PostgreSQL, dan itu menjelaskan banyak laporan bug yang dianggap “mustahil”. SHOW default_transaction_isolation di mesin uji mengembalikan read committed.

Setiap perintah mengambil snapshot baru saat mulai jalan. Dua SELECT dalam satu transaksi boleh melihat data berbeda:

text
T1 baca pertama = 100
T1 baca kedua  = 200

Sesi kedua mengubah saldo ke 200 di antara kedua SELECT. Pola yang sama berlaku untuk phantom:

text
T1 hitung baju ke-1 = 2
T1 hitung baju ke-2 = 3

Satu baris baju di-insert dan commit di antara kedua count(*).

Update yang Melihat Versi Baris Lain

Read Committed punya aturan khusus untuk UPDATE, DELETE, SELECT FOR UPDATE, dan SELECT FOR SHARE. Perintah mencari target baris dengan snapshot saat mulai, tapi bila baris itu sudah diubah atau dikunci transaksi lain, perintah menunggu transaksi pertama selesai, lalu mengevaluasi ulang WHERE terhadap versi baris yang sudah diperbarui.

Dokumentasi memberi contoh yang mudah dipahami. Dengan website.hits berisi 9 dan 10, eksekusi UPDATE website SET hits = hits + 1; membuat DELETE dari sesi lain tidak berefek:

sql
BEGIN;
UPDATE website SET hits = hits + 1;
-- dari sesi lain: DELETE FROM website WHERE hits = 10;
COMMIT;

Nilai lama 9 dilewati, dan setelah UPDATE nilai baris itu bukan 10 lagi melainkan 11, sehingga predicate DELETE tidak cocok. Akibatnya satu perintah bisa melihat hasil update konkuren pada baris yang disentuhnya, tetapi tidak pada baris lain. Dokumentasi menyimpulkan Read Committed tidak cocok untuk perintah dengan kondisi pencarian kompleks, meski sudah cukup untuk kasus sederhana.

INSERT ... ON CONFLICT DO NOTHING pada Read Committed bisa tidak jadi meng-insert apa pun karena konflik dengan transaksi lain yang efeknya belum terlihat di snapshot INSERT. Jangan mengandalkan DO NOTHING sebagai jaminan idempotensi lintas transaksi.

Lost Update: Yang Sebenarnya Hilang

Lost update adalah kasus yang paling sering dikira mustahil di database. Dua sesi membaca saldo yang sama, menghitung nilai baru sendiri, lalu menulis nilai absolut. Kedua sesi membaca 100, T1 menulis 90 pada detik pertama, T2 menulis 110 pada detik kedua:

text
T1 baca saldo = 100
T1 tulis saldo = 90
T2 baca saldo = 100
T2 tulis saldo = 110
SALDO AKHIR = 110

Update T1 hilang, dan saldo akhir salah karena seharusnya 100.

Ada nuansa penting yang sering terlewat. Menulis UPDATE akun SET saldo = saldo + 10 justru tidak kehilangan update, karena PostgreSQL menghitung ekspresi itu pada versi baris terkini saat mengunci baris. Percobaan dengan dua sesi yang sama-sama +10 menghasilkan saldo akhir 120, bukan 110. Yang hilang adalah penulisan nilai absolut yang dihitung di dalam aplikasi.

Mengunci Baris dengan SELECT FOR UPDATE

Explicit Locking menjelaskan bahwa FOR UPDATE mencegah baris diubah transaksi lain sampai transaksi selesai, dan kalau transaksi lain sedang berjalan, FOR UPDATE menunggu lalu mengunci serta mengembalikan baris versi terbaru.

Sesi pertama dikunci lebih dulu, lalu sesi kedua:

text
T1 FOR UPDATE, saldo = 100
T1 tulis 90, commit
T2 FOR UPDATE, saldo = 90
T2 tulis 100, commit
SALDO AKHIR = 100

Sesi kedua menunggu sampai sesi pertama commit, baru membaca 90 dan menghitung 100. Pola yang benar adalah kunci, baca, hitung, tulis, dalam satu transaksi.

Empat mode row-level lock tersedia, dari yang terlemah hingga terkuat:

ModeSifat
FOR KEY SHARETidak memblokir FOR NO KEY UPDATE dan UPDATE biasa
FOR SHARELock bersama, memblokir UPDATE dan DELETE
FOR NO KEY UPDATELock eksklusif, tidak memblokir FOR KEY SHARE
FOR UPDATELock eksklusif penuh

Dokumentasi mencatat SELECT FOR UPDATE memodifikasi baris terpilih untuk menandainya terkunci, sehingga bisa menimbulkan disk write. Yang juga penting: hanya ACCESS EXCLUSIVE yang memblokir SELECT biasa tanpa FOR UPDATE.

Repeatable Read: Snapshot yang Beku

Repeatable Read mengambil snapshot saat statement non-transactional pertama dalam transaksi, bukan tiap perintah. Hasilnya, semua fenomena tertutup kecuali serialization anomaly:

text
RR T1 baca ke-1 = 100
RR T1 count baju ke-1 = 4
RR T1 baca ke-2 = 100
RR T1 count baju ke-2 = 4

Perhatikan count(*) juga tetap 4 meski satu baris baju sudah di-insert dan commit oleh sesi lain. Phantom read memang tidak terjadi di PostgreSQL, dan standar SQL mengizinkan jaminan yang lebih tinggi dari yang diminta.

Begitu transaksi menyentuh baris yang berubah sejak snapshot-nya, PostgreSQL menolak:

text
ERROR:  40001: could not serialize access due to concurrent update
ERROR:  25P02: current transaction is aborted, commands ignored until end of transaction block

Perintah kedua itu penting. Setelah 40001, transaksi berada dalam keadaan gagal dan menolak semua perintah sampai transaksi diakhiri. Mencoba query lain untuk mencari penyebab hanya menambah delay.

Hanya transaksi yang melakukan update yang perlu retry. Transaksi read-only pada Repeatable Read tidak akan pernah konflik, kata dokumentasi secara eksplisit.

Serializable: Yang Menolak Urutan Mustahil

Serializable bekerja seperti Repeatable Read, ditambah pemantauan dependensi baca-tulis. Pemantauan ini tidak menambah blocking, hanya sedikit overhead, dan setiap kondisi yang terdeteksi memicu kegagalan serialisasi.

Kasus dari dokumentasi, dengan tabel mytab berisi (1,10) (1,20) (2,100) (2,200). Sesi A menghitung SUM untuk class = 1 yaitu 30 lalu insert sebagai nilai class 2. Sesi B menghitung SUM untuk class = 2 yaitu 300 lalu insert sebagai nilai class 1:

sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT 'T1 sum class=1 = ' || SUM(value) FROM mytab WHERE class = 1;
INSERT INTO mytab VALUES (2, 30);
SELECT pg_sleep(2);
COMMIT;

Hasil di mesin uji:

text
T1 sum class=1 = 30
T1 COMMIT OK
T2 sum class=2 = 300
ERROR:  40001: could not serialize access due to read/write dependencies among transactions

Baris (1,300) tidak masuk ke tabel karena transaksi B di-rollback. Skenario identik pada Repeatable Read, tanpa satu pun error, menghasilkan enam baris termasuk (1,300) yang bertentangan dengan nilai 30. Itulah serialization anomaly yang dicegah Serializable.

Bukti Lock Internal

Predikat lock Serializable terlihat di pg_locks selama transaksinya masih terbuka:

text
locktype     |mode              |count
relation     |RowExclusiveLock  |1
relation     |SIReadLock        |1
relation     |AccessShareLock   |1
transactionid|ExclusiveLock     |1
virtualxid   |ExclusiveLock     |1

Sesi READ COMMITTED dengan query serupa menunjukkan nol predicate lock. SIReadLock tidak memblokir dan tidak bisa menyebabkan deadlock, tugasnya hanya menandai dependensi antar transaksi.

Menangani 40001 dengan Retry

SQLSTATE 40001 adalah kontrak yang harus ditangani, bukan exception yang boleh telanjur. Aturan retry yang benar: abort seluruh transaksi, jalankan ulang dari awal, dan batasi jumlah percobaan. Menjalankan ulang skenario yang gagal setelah sesi lain selesai berhasil:

text
T2 sum class=2 = 300
T2 COMMIT OK

Tidak ada transaksi baca-tulis yang overlap, jadi tidak ada 40001. Yang penting ditegaskan: yang diulang adalah seluruh transaksi, bukan hanya statement yang gagal, karena snapshot yang dipakai transaksi sudah tidak valid.

Dokumentasi juga memberi daftar prinsip performa untuk Serializable: declare READ ONLY bila memungkinkan, batasi jumlah koneksi aktif dengan connection pool, jangan muat lebih dari yang dibutuhkan untuk integritas, hindari membiarkan koneksi menggantung dalam status idle in transaction (pakai idle_in_transaction_session_timeout), dan hilangkan FOR UPDATE yang tak dibutuhkan karena Serializable sudah melindunginya. Untuk predicate lock yang sering digabung ke level relasi, naikkan max_pred_locks_per_transaction, max_pred_locks_per_relation, atau max_pred_locks_per_page. Sequential scan selalu butuh predicate lock level relasi, sehingga menurunkan random_page_cost dan menaikkan cpu_tuple_cost bisa menurunkan tingkat kegagalan serialisasi.

Antrian dengan SKIP LOCKED

SKIP LOCKED mengubah baris yang sedang terkunci dari hambatan menjadi hal yang boleh dilewati. Pola worker memproses antrean tidak lagi saling menunggu:

sql
SELECT id FROM akun WHERE id <= 2 ORDER BY id FOR UPDATE SKIP LOCKED;

Saat baris 1 dipegang sesi lain, sesi kedua memperoleh:

text
T2 ambil antrean SKIP LOCKED, dapat: 2

Baris terkunci dilewati, baris berikutnya diproses. Pola ini adalah alasan SKIP LOCKED ada, dan bedanya dengan FOR UPDATE biasa adalah ketiadaan blocking, bukan tingkat isolasi.

Dua Jebakan yang Terverifikasi

Sequence Tidak Pernah Di-rollback

ROLLBACK mengembalikan baris, tetapi tidak mengembalikan nilai sequence:

text
sebelum rollback, last_value = 1
setelah rollback, last_value = 1
id baris tersimpan = 2

BEGIN; INSERT ...; ROLLBACK; membuat insert berikutnya mendapat id = 2, bukan 1. Dokumentasi menyatakan perubahan pada sequence langsung terlihat oleh transaksi lain dan tidak di-rollback. Jangan andalkan id mentah dari client karena ada celah. last_value yang masih 1 bukan bukti rollback bekerja, melainkan efek cache nilai di dalam sequence.

SET TRANSACTION Harus Paling Awal

text
BEGIN;
SELECT 1;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

menghasilkan:

text
ERROR:  SET TRANSACTION ISOLATION LEVEL must be called before any query

Setelah query pertama jalan, level sudah terkunci untuk transaksi itu. Yang tersedia untuk kebutuhan lain adalah BEGIN ISOLATION LEVEL ... di baris pertama, atau GUC default_transaction_isolation per sesi:

sql
SET default_transaction_isolation = 'repeatable read';
BEGIN;
SHOW transaction_isolation;  -- repeatable read
COMMIT;

Sesi berikutnya kembali ke read committed, karena nilainya per koneksi. ALTER ROLE ... SET default_transaction_isolation menyimpan nilainya di pg_roles.rolconfig, dan setelan itu dievaluasi ketika koneksi dibuat, bukan saat SET ROLE di sesi yang sedang berjalan.

Rekomendasi Praktis

  • Biarkan Read Committed sebagai default dan jangan naikkan level tanpa alasan; Serializable menambahkan pemantauan, bukan hanya penguncian.
  • Untuk satu baris yang dihitung ulang, pakai SELECT ... FOR UPDATE di dalam transaksi, bukan menulis nilai absolut hasil hitungan aplikasi.
  • Untuk antrean worker, pakai FOR UPDATE SKIP LOCKED supaya tidak ada worker yang idle menunggu.
  • Perlakukan 40001 sebagai jalur normal: abort, retry seluruh transaksi, beri batas percobaan.
  • Hindari transaksi yang menunggu input pengguna, karena transaksi terbuka menahan lock tanpa batas.
  • Gunakan urutan penguncian yang konsisten di seluruh aplikasi untuk menghindari deadlock, dan siapkan retry juga untuk 40P01 (deadlock terdeteksi).

Kesimpulan

Isolation level bukan tombol yang bisa dipasang untuk seluruh aplikasi; setiap level menutup fenomena tertentu dan membiarkan yang lain. Read Committed menutup dirty read tetapi melepas non-repeatable dan phantom read, Repeatable Read membekukan snapshot namun masih bisa ditolak dengan 40001, dan Serializable menutup semua anomali dengan biaya pemantauan tambahan. Karena PostgreSQL berbasis MVCC, sebagian besar masalah konkurensi tidak perlu lock sama sekali; lock eksplisit hanya perlu untuk hitung-ulang pada baris tertentu dan pola antrean. Mulailah dari default, ukur dengan skenario dua sesi seperti di artikel ini, dan naikkan level hanya ketika Anda bisa menunjukkan anomali yang benar-benar terjadi.