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 UPDATEmemblokir, lalu mengembalikan versi baris terbaru untuk dihitung ulang.SKIP LOCKEDmengubah baris terkunci menjadi pola antrean tanpa menunggu.- Nilai sequence tidak pernah di-rollback, sehingga
ROLLBACKtetap menyisakan celah id.
Lingkungan Pengujian
Seluruh output berasal dari container sekali pakai, bukan dari asumsi.
| Komponen | Nilai Teruji | Keterangan |
|---|---|---|
| PostgreSQL | 17.11 (Debian 17.11-1.pgdg13+2) | x86_64, compiler gcc 14.2.0 |
| Image | postgres:17 | Dijalankan dengan docker run |
| Sesi uji | Dua koneksi psql terpisah | Diurutkan dengan pg_sleep agar reproducible |
| Rujukan | Dokumentasi PostgreSQL 17 | Dipin 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:
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 Level | Dirty Read | Nonrepeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| Read uncommitted | Diizinkan, tapi tidak di PG | Mungkin | Mungkin | Mungkin |
| Read committed | Tidak mungkin | Mungkin | Mungkin | Mungkin |
| Repeatable read | Tidak mungkin | Tidak mungkin | Diizinkan, tapi tidak di PG | Mungkin |
| Serializable | Tidak mungkin | Tidak mungkin | Tidak mungkin | Tidak 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:
BEGIN;
UPDATE akun SET saldo = 999 WHERE id = 1;
SELECT pg_sleep(3);
COMMIT;Sesi kedua membaca dengan level paling longgar:
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT 'T2 melihat saldo = ' || saldo FROM akun WHERE id = 1;
COMMIT;Hasilnya:
T2 melihat saldo = 100Nilai 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:
T1 baca pertama = 100
T1 baca kedua = 200Sesi kedua mengubah saldo ke 200 di antara kedua SELECT. Pola yang sama berlaku untuk phantom:
T1 hitung baju ke-1 = 2
T1 hitung baju ke-2 = 3Satu 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:
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 NOTHINGpada Read Committed bisa tidak jadi meng-insert apa pun karena konflik dengan transaksi lain yang efeknya belum terlihat di snapshot INSERT. Jangan mengandalkanDO NOTHINGsebagai 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:
T1 baca saldo = 100
T1 tulis saldo = 90
T2 baca saldo = 100
T2 tulis saldo = 110
SALDO AKHIR = 110Update 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:
T1 FOR UPDATE, saldo = 100
T1 tulis 90, commit
T2 FOR UPDATE, saldo = 90
T2 tulis 100, commit
SALDO AKHIR = 100Sesi 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:
| Mode | Sifat |
|---|---|
FOR KEY SHARE | Tidak memblokir FOR NO KEY UPDATE dan UPDATE biasa |
FOR SHARE | Lock bersama, memblokir UPDATE dan DELETE |
FOR NO KEY UPDATE | Lock eksklusif, tidak memblokir FOR KEY SHARE |
FOR UPDATE | Lock 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:
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 = 4Perhatikan 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:
ERROR: 40001: could not serialize access due to concurrent update
ERROR: 25P02: current transaction is aborted, commands ignored until end of transaction blockPerintah 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:
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:
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 transactionsBaris (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:
locktype |mode |count
relation |RowExclusiveLock |1
relation |SIReadLock |1
relation |AccessShareLock |1
transactionid|ExclusiveLock |1
virtualxid |ExclusiveLock |1Sesi 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:
T2 sum class=2 = 300
T2 COMMIT OKTidak 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:
SELECT id FROM akun WHERE id <= 2 ORDER BY id FOR UPDATE SKIP LOCKED;Saat baris 1 dipegang sesi lain, sesi kedua memperoleh:
T2 ambil antrean SKIP LOCKED, dapat: 2Baris 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:
sebelum rollback, last_value = 1
setelah rollback, last_value = 1
id baris tersimpan = 2BEGIN; 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
BEGIN;
SELECT 1;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;menghasilkan:
ERROR: SET TRANSACTION ISOLATION LEVEL must be called before any querySetelah 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:
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 UPDATEdi dalam transaksi, bukan menulis nilai absolut hasil hitungan aplikasi. - Untuk antrean worker, pakai
FOR UPDATE SKIP LOCKEDsupaya 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.