Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Apa itu transaction isolation level di database?
Isolation level adalah pengaturan yang mengontrol seberapa banyak sebuah transaction bisa melihat perubahan dari transaction lain yang berjalan bersamaan. Standar SQL mendefinisikan empat level, yaitu READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, dan SERIALIZABLE, dan makin tinggi makin banyak anomali yang dicegah. Level yang lebih kuat menukar throughput dan retry tambahan dengan jaminan yang lebih kuat.
02Apa isolation level default di PostgreSQL?
PostgreSQL memakai READ COMMITTED sebagai default. Setiap statement hanya melihat data yang sudah di-commit sebelum statement itu mulai, jadi dirty read tidak mungkin terjadi. Dua statement dalam satu transaction masih bisa melihat data berbeda, makanya kode baca-lalu-tulis butuh row lock atau UPDATE atomic.
03Apa beda REPEATABLE READ dan SERIALIZABLE di PostgreSQL?
REPEATABLE READ memberi satu snapshot untuk seluruh transaction, dan di PostgreSQL juga mencegah phantom read. Level ini masih mengizinkan write skew, yaitu dua transaction menulis baris berbeda setelah membaca kumpulan yang sama. SERIALIZABLE menambah pelacakan dependensi yang membatalkan salah satu transaction dengan error 40001 saat pola itu muncul.
04Bagaimana cara mencegah lost update di PostgreSQL?
Lakukan hitungannya dalam satu UPDATE seperti SET balance = balance - 50 WHERE balance >= 50 RETURNING balance, sehingga database menerapkannya di bawah row lock. Kalau harus membaca dulu, pakai SELECT FOR UPDATE supaya sesi pesaing menunggu dan membaca nilai terbaru. REPEATABLE READ juga mencegahnya dengan memunculkan 40001 saat baris berubah sejak snapshot Anda.
05Apa itu error 40001 dan bagaimana menanganinya?
SQLSTATE 40001 adalah serialization_failure. PostgreSQL memunculkannya saat transaction REPEATABLE READ atau SERIALIZABLE tidak bisa lanjut dengan aman. Ini bukan bug: lakukan rollback, lalu jalankan ulang seluruh transaction termasuk pembacaan dan keputusannya, dengan jumlah percobaan terbatas dan jeda singkat yang diberi jitter.
Transaction Isolation Level di PostgreSQL: Penjelasan Lengkap
Apa itu transaction isolation level database dan mana yang dipakai. READ COMMITTED, REPEATABLE READ, SERIALIZABLE di PostgreSQL dengan output dua sesi asli dan retry loop.
Isolation level menentukan anomali concurrency apa yang bisa dilihat satu transaction dari transaction lain. PostgreSQL memakai READ COMMITTED sebagai default, yang mencegah dirty read. REPEATABLE READ menambah satu snapshot stabil dan mencegah phantom. SERIALIZABLE juga mencegah write skew. Pakai READ COMMITTED plus row lock, dan SERIALIZABLE dengan retry error 40001 untuk aturan lintas baris.
Bayangkan dua kasir menutup saldo pelanggan yang sama pada detik yang sama. Keduanya membaca 100, keduanya mengurangi angkanya sendiri di kode aplikasi, lalu menulis hasilnya. Satu pengurangan hilang dan tidak ada error sama sekali. Itu lost update, dan ini bug isolation yang paling sering muncul di kode POS dan ERP biasa.
Post ini menjawab langsung: apa saja isolation level, apa yang dicegah masing-masing di PostgreSQL, dan mana yang sebaiknya dipilih. Semua transcript di bawah saya jalankan di PostgreSQL 16.15 lokal dengan dua sesi psql, jadi outputnya ditempel asli, bukan diketik dari ingatan. Aturannya berasal dari manual PostgreSQL, dan manualnya dikutip di bagian yang penting.
Anomali apa yang dicegah oleh isolation level?
Dirty read adalah membaca data yang sudah ditulis transaction lain tetapi belum di-commit. Non-repeatable read adalah membaca satu baris dua kali dalam satu transaction dan mendapat dua nilai berbeda karena ada commit di antaranya. Phantom read adalah hal yang sama untuk sekumpulan baris: WHERE yang sama mengembalikan himpunan baris yang berbeda pada pembacaan kedua.
Write skew adalah yang sering dilewatkan tutorial. Dua transaction sama-sama membaca sekumpulan baris yang beririsan, sama-sama memutuskan aturan masih terpenuhi, lalu masing-masing menulis ke baris yang berbeda. Tidak ada yang menimpa yang lain, jadi tidak ada konflik di level baris, tetapi hasil gabungannya melanggar aturan. Manual menyebut kelas umumnya serialization anomaly. Tabel ini menunjukkan mana dari keempatnya yang bisa terjadi di tiap level pada PostgreSQL.
Anomali
READ COMMITTED
REPEATABLE READ
SERIALIZABLE
Dirty read
Tidak mungkin
Tidak mungkin
Tidak mungkin
Non-repeatable read
Mungkin
Tidak mungkin
Tidak mungkin
Phantom read
Mungkin
Tidak mungkin di PostgreSQL
Tidak mungkin
Write skew (serialization anomaly)
Mungkin
Mungkin
Tidak mungkin
Dua kekhasan PostgreSQL mengubah cara membaca standar SQL. Manual menyatakan mode READ UNCOMMITTED berperilaku seperti READ COMMITTED, jadi dirty read tidak bisa terjadi di level mana pun. Manual juga menyatakan REPEATABLE READ miliknya tidak mengizinkan phantom read, padahal standar membolehkannya. Jadi sebenarnya hanya ada tiga level.
Apa sebenarnya jaminan READ COMMITTED?
READ COMMITTED adalah default. Setiap statement melihat snapshot dari semua yang sudah di-commit sebelum statement itu mulai, jadi Anda tidak pernah melihat pekerjaan setengah jadi, tetapi dua statement dalam satu transaction bisa melihat kondisi yang berbeda. Sesi 1 di bawah menjalankan query yang sama dua kali dan mendapat 100 lalu 150, karena sesi 2 melakukan commit di antaranya.
-- Setup (PostgreSQL 16.15, two psql sessions)
CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);
INSERT INTO accounts VALUES (1, 100);
-- READ COMMITTED: every statement takes a fresh snapshot
s1=# BEGIN ISOLATION LEVEL READ COMMITTED;
s1=# SELECT balance FROM accounts WHERE id = 1;
balance
---------
100
s2=# UPDATE accounts SET balance = 150 WHERE id = 1; -- autocommit, so committed at once
s1=# SELECT balance FROM accounts WHERE id = 1; -- same transaction, same query
balance
---------
150 -- non-repeatable read
Perilaku itu aman untuk satu statement dan berbahaya untuk logika baca-lalu-tulis. Contoh hitungannya: saldo 100, satu request mengambil 30 dan request lain mengambil 50, jadi jawaban yang benar 100 - 30 - 50 = 20. Kedua request membaca 100 dulu, menghitung 70 dan 50 di aplikasi, lalu menulis. Penulis terakhir menang dan tabel berakhir di 50.
-- Lost update at READ COMMITTED: both read 100, both compute in the app, both write.
-- Intended result: 100 - 30 - 50 = 20.
s1=# BEGIN; s2=# BEGIN;
s1=# SELECT balance FROM accounts WHERE id = 1; -- 100
s2=# SELECT balance FROM accounts WHERE id = 1; -- 100
s1=# UPDATE accounts SET balance = 70 WHERE id = 1; -- app computed 100 - 30
s1=# COMMIT;
s2=# UPDATE accounts SET balance = 50 WHERE id = 1; -- app computed 100 - 50, stale read
s2=# COMMIT;
s1=# SELECT balance FROM accounts WHERE id = 1;
50 -- the 30 is gone
Tanpa error, tanpa peringatan, dan 30 hilang. Isolation level sudah melakukan persis yang dijanjikan READ COMMITTED. Kesalahannya ada pada siklus read-modify-write yang dipecah menjadi dua round trip, makanya dua bagian berikutnya membahas cara menutup celah itu.
Apakah REPEATABLE READ di PostgreSQL itu snapshot isolation?
Ya, dari sisi perilaku. Transaction REPEATABLE READ mengambil satu snapshot pada query pertamanya dan menyimpannya sampai selesai, jadi SELECT kedua di bawah tetap mengembalikan 150 padahal sesi 2 sudah commit 200. Harganya terlihat saat menulis: kalau Anda meng-update baris yang berubah sejak snapshot, PostgreSQL menolak dengan SQLSTATE 40001 alih-alih menimpanya diam-diam.
-- REPEATABLE READ: one snapshot, taken at the first query
s1=# BEGIN ISOLATION LEVEL REPEATABLE READ;
s1=# SELECT balance FROM accounts WHERE id = 1;
150
s2=# UPDATE accounts SET balance = 200 WHERE id = 1; -- committed by session 2
s1=# SELECT balance FROM accounts WHERE id = 1;
150 -- still the snapshot value
s1=# UPDATE accounts SET balance = balance + 10 WHERE id = 1;
ERROR: could not serialize access due to concurrent update
s1=# ROLLBACK; -- SQLSTATE 40001, retry the transaction
Snapshot yang sama menutup celah phantom, yang tidak diwajibkan standar SQL di level ini. Baris yang di-insert dan di-commit sesi lain tetap tidak terlihat, sedangkan query yang identik di READ COMMITTED langsung melihatnya.
CREATE TABLE orders (id serial PRIMARY KEY, status text NOT NULL);
INSERT INTO orders (status) VALUES ('open'), ('open');
-- REPEATABLE READ: no phantom in PostgreSQL
s1=# BEGIN ISOLATION LEVEL REPEATABLE READ;
s1=# SELECT count(*) FROM orders WHERE status = 'open';
2
s2=# INSERT INTO orders (status) VALUES ('open');
s1=# SELECT count(*) FROM orders WHERE status = 'open';
2 -- the new row is invisible
s1=# COMMIT;
-- READ COMMITTED: phantom rows appear
s1=# BEGIN ISOLATION LEVEL READ COMMITTED;
s1=# SELECT count(*) FROM orders WHERE status = 'open';
3
s2=# INSERT INTO orders (status) VALUES ('open');
s1=# SELECT count(*) FROM orders WHERE status = 'open';
4 -- a phantom: the row set changed
Jadi REPEATABLE READ menyelesaikan lost update secara gratis, selama kode Anda melakukan retry pada 40001. Ia tidak menyelesaikan write skew, yang dibahas dua bagian lagi. Ingat bahwa perlindungan ini adalah perilaku PostgreSQL. Database lain mengimplementasikan nama level yang sama secara berbeda, jadi baca manual engine yang Anda pakai.
Bagaimana mencegah lost update dengan SELECT FOR UPDATE?
Kunci barisnya sebelum dibaca. SELECT ... FOR UPDATE mengambil row lock, sehingga sesi kedua menunggu di SELECT-nya sendiri dan, setelah yang pertama commit, membaca nilai baru 70 bukan 100 yang basi. Hitungan aplikasinya jadi benar: 70 - 50 = 20, sesuai 100 - 30 - 50 yang kita inginkan.
-- Fix 1: lock the row before reading it
s1=# BEGIN; s2=# BEGIN;
s1=# SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 100, row locked
s2=# SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- blocks, no output yet
s1=# UPDATE accounts SET balance = 70 WHERE id = 1;
s1=# COMMIT; -- releases the lock
-- session 2 unblocks and returns 70
s2=# UPDATE accounts SET balance = 20 WHERE id = 1; -- app computed 70 - 50
s2=# COMMIT;
s1=# SELECT balance FROM accounts WHERE id = 1;
20 -- 100 - 30 - 50
-- Fix 2: no read at all. One statement, the database does the arithmetic.
UPDATE accounts SET balance = balance - 50 WHERE id = 1 AND balance >= 50 RETURNING balance;
Perbaikan yang lebih murah adalah menghapus pembacaannya. Satu UPDATE accounts SET balance = balance - 50 WHERE balance >= 50 RETURNING balance menghitung di dalam database di bawah row lock. Kalau penulis lain sudah commit lebih dulu, PostgreSQL memeriksa ulang WHERE terhadap versi baris terbaru, jadi pengecekan saldo tetap benar. Nol baris kembali berarti guard gagal.
Pakai FOR UPDATE saat keputusan butuh lebih dari satu kolom atau tabel, dan satu UPDATE atomic kapan pun satu statement cukup mengungkapkannya. Keduanya bekerja di READ COMMITTED. Bab explicit locking di manual mendaftar mode lain, termasuk FOR SHARE dan SKIP LOCKED.
Apa itu write skew dan level mana yang mencegahnya?
Ambil aturan bahwa minimal satu kasir harus tetap bertugas. Alice dan Budi sama-sama bertugas. Tiap transaction menghitung kasir yang bertugas, melihat 2, memutuskan aman untuk pergi, lalu meng-update barisnya sendiri. Di REPEATABLE READ keduanya commit dan hitungan berakhir di 0. Baris yang mereka tulis berbeda, jadi pengecekan level baris tidak pernah aktif.
CREATE TABLE shifts (cashier text PRIMARY KEY, on_duty boolean NOT NULL);
INSERT INTO shifts VALUES ('alice', true), ('budi', true);
-- Rule the app enforces: at least one cashier must stay on duty.
-- REPEATABLE READ: both transactions see 2 on duty, each removes a DIFFERENT row
s1=# BEGIN ISOLATION LEVEL REPEATABLE READ; s2=# BEGIN ISOLATION LEVEL REPEATABLE READ;
s1=# SELECT count(*) FROM shifts WHERE on_duty; -- 2
s2=# SELECT count(*) FROM shifts WHERE on_duty; -- 2
s1=# UPDATE shifts SET on_duty = false WHERE cashier = 'alice';
s2=# UPDATE shifts SET on_duty = false WHERE cashier = 'budi'; -- different row, no conflict
s1=# COMMIT;
s2=# COMMIT; -- both succeed
s1=# SELECT count(*) FROM shifts WHERE on_duty;
0 -- the rule is broken
-- Same script after resetting both rows to true, with BEGIN ISOLATION LEVEL SERIALIZABLE
-- in both sessions. The two SELECTs and two UPDATEs run exactly as above, then:
s1=# COMMIT; -- succeeds
s2=# COMMIT;
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
s1=# SELECT cashier, on_duty FROM shifts ORDER BY cashier;
alice | f
budi | t -- one cashier is still on duty
SERIALIZABLE menangkapnya. PostgreSQL mengimplementasikannya dengan Serializable Snapshot Isolation, yang melacak dependensi baca dan tulis antar transaction konkuren tanpa memblokir, dan membatalkan salah satunya saat pola berbahaya terbentuk. Pada percobaan di atas COMMIT kedua gagal dengan serialization error, dan HINT di manual bilang transaction mungkin berhasil bila di-retry. Retry membaca hitungan 1 dan menolak dengan benar.
Kalau aturannya bisa dinyatakan sebagai constraint, pilih constraint. Unique index atau exclusion constraint menegakkannya di semua isolation level tanpa retry loop. Pakai SERIALIZABLE saat invariant mencakup beberapa baris dan tidak ada constraint yang bisa menyatakannya.
Bagaimana retry serialization failure di TypeScript?
Manual menyatakan aplikasi di REPEATABLE READ dan SERIALIZABLE harus siap me-retry pada SQLSTATE 40001, dan deadlock (40P01) juga layak di-retry. Retry harus menjalankan ulang seluruh transaction, termasuk logika yang memutuskan SQL apa yang dikirim, karena PostgreSQL tidak bisa melakukannya dengan aman untuk Anda. Wrapper ini melakukannya dengan client node-postgres.
import { Pool, PoolClient } from "pg";
type Level = "READ COMMITTED" | "REPEATABLE READ" | "SERIALIZABLE";
// 40001 serialization_failure, 40P01 deadlock_detected: the two codes PostgreSQL says to retry.
const RETRYABLE = new Set(["40001", "40P01"]);
const MAX_ATTEMPTS = 5;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function withTransaction<T>(
pool: Pool,
level: Level,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const client = await pool.connect();
try {
await client.query("BEGIN ISOLATION LEVEL " + level);
// The WHOLE unit of work runs again on a retry: reads, decisions and writes.
const result = await work(client);
await client.query("COMMIT"); // a serializable failure can surface HERE
return result;
} catch (err) {
await client.query("ROLLBACK").catch(() => undefined);
const code = (err as { code?: string }).code;
if (code && RETRYABLE.has(code) && attempt < MAX_ATTEMPTS) {
// Jitter stops two losers from colliding again in lockstep.
await sleep(Math.random() * 25 * 2 ** attempt);
continue;
}
throw err; // not retryable, or out of attempts
} finally {
client.release();
}
}
throw new Error("unreachable");
}
// Usage: the on-duty check and the update live inside the callback, so a retry re-reads.
await withTransaction(pool, "SERIALIZABLE", async (db) => {
const { rows } = await db.query("SELECT count(*)::int AS n FROM shifts WHERE on_duty");
if (rows[0].n < 2) throw new Error("last cashier must stay on duty");
await db.query("UPDATE shifts SET on_duty = false WHERE cashier = $1", [cashier]);
});
Ada tiga detail penting. COMMIT berada di dalam blok try karena kegagalan serializable bisa muncul saat commit, seperti tadi. Setiap percobaan mengambil koneksi baru dari pool dan melepasnya di finally. Dan jumlah percobaan dibatasi, karena manual menyatakan retry tidak dijamin berhasil saat contention tinggi.
Jauhkan side effect dari callback. Mengirim email, menagih kartu, atau mempublikasikan pesan di dalam transaction yang bisa berjalan tiga kali akan melakukannya tiga kali. Lakukan setelah wrapper selesai, atau pakai tabel outbox yang ditulis di transaction yang sama.
Isolation level mana yang sebaiknya dipakai?
Mulai dari apa yang harus benar di transaction Anda, bukan dari nama levelnya. Tabel ini merangkum tiga level PostgreSQL berdasarkan beban yang ditaruh ke kode Anda.
Level
Yang harus Anda lakukan
Mode kegagalan
Dipakai untuk
READ COMMITTED
Kunci baris atau tulis UPDATE atomic sendiri
Lost update dan write skew kalau lupa
Default untuk CRUD biasa, tulis satu statement, laporan yang toleran terhadap perubahan
REPEATABLE READ
Retry pada 40001
Write skew masih mungkin
Pembacaan multi-query yang butuh tampilan konsisten, read-modify-write pada satu baris
SERIALIZABLE
Retry pada 40001, jaga transaction tetap pendek
Lebih banyak transaction dibatalkan saat contention
Invariant lintas baris yang tidak bisa dinyatakan constraint
Default saya untuk Postgres di satu VPS di belakang API NestJS adalah READ COMMITTED dengan UPDATE atomic, lalu menaikkan level hanya pada jalur kode tertentu yang membutuhkannya. Jalankan checklist ini untuk setiap transaction yang menulis.
Bisakah satu UPDATE dengan guard WHERE dan RETURNING menyelesaikan semuanya? Kalau ya, tetap di READ COMMITTED dan selesai.
Adakah constraint, unique index, atau exclusion constraint yang bisa menyatakan aturannya? Kalau ya, tambahkan, karena berlaku di semua level.
Apakah Anda membaca satu baris, memutuskan di kode aplikasi, lalu menulis baris yang sama? Pakai SELECT FOR UPDATE, atau REPEATABLE READ dengan retry loop.
Apakah aturannya bergantung pada sekumpulan baris yang Anda baca tetapi tidak Anda tulis, seperti count atau sum? Pakai SERIALIZABLE dengan retry loop.
Bungkus semua yang di atas READ COMMITTED dengan retry helper, jaga transaction tetap pendek, dan pindahkan side effect ke luar.
Aturan yang saya bawa: isolation level adalah kontrak tentang apa yang boleh diamati sebuah transaction, bukan lock. Pilih level terlemah yang menjaga invariant tetap benar, nyatakan invariant sebagai constraint atau satu statement atomic bila bisa, dan anggap 40001 sebagai jawaban normal yang bisa di-retry, bukan bug. Lost update dan write skew terjadi diam-diam, dan itulah alasan keduanya perlu dirancang keluar dengan sengaja.