← semua materi
15:00 Teknis Β· 20 Oct 2026 Β· 15:10

πŸ”¬ BACOT β€” Sesi 15:00 Technical Exploration

πŸ“š Topik: Isolation Level Transaksi & Anomali yang Tetap Lolos

Verifikasi versi: PostgreSQL 18.6 (rilis 2026-08-13), PostgreSQL 19 RC 1 (2026-10-15, belum GA). Semua benchmark di bawah saya jalankan sendiri hari ini di postgres:18-alpine (PG 18.6, x86_64, Docker lokal), bukan angka resmi vendor.


🎯 Tujuan Belajar

Bisa jawab tiga hal konkret untuk kode yang kamu tulis besok:

  1. Anomali apa yang tetap bisa lolos di isolation level yang kamu pakai sekarang.
  2. Berapa harga (abort rate, tps) kalau naik ke SERIALIZABLE.
  3. Pola retry yang benar untuk SQLSTATE 40001 β€” termasuk bug klasik "retry di tengah transaksi".

πŸ—οΈ Arsitektur & Alur Teknis

Tiga mesin berbeda yang sering disamakan orang

DBMS Level yang dipakai orang Mesin sebenarnya Anomali yang tetap lolos
PostgreSQL REPEATABLE READ Snapshot Isolation (MVCC) write skew, read-only anomaly
PostgreSQL SERIALIZABLE SSI (Snapshot Isolation + deteksi siklus) praktis tidak ada (kecuali bug)
MySQL/InnoDB REPEATABLE READ (default) MVCC + next-key lock untuk locking read write skew (karena consistent read non-locking tidak ambil gap lock)
MySQL/InnoDB SERIALIZABLE RR + plain SELECT diubah jadi LOCK IN SHARE MODE tidak ada, tapi blocking/deadlock tinggi

Fakta dari dokumentasi resmi PG 18 (postgresql.org/docs/18/transaction-iso.html):

Kenapa write skew lolos di Snapshot Isolation

Inti masalahnya: snapshot hanya mendeteksi konflik write-write. Dua transaksi yang baca himpunan sama tapi tulis baris berbeda tidak pernah bertabrakan di level storage.

Constraint bisnis: minimal 1 orang on-call.

T1: SELECT count(*) FROM oncall WHERE on_call;  -- lihat 2
T2: SELECT count(*) FROM oncall WHERE on_call;  -- lihat 2 (snapshot sendiri)
T1: UPDATE oncall SET on_call=false WHERE id=1; -- aman, 2 > 1
T2: UPDATE oncall SET on_call=false WHERE id=2; -- aman, 2 > 1
COMMIT; COMMIT;
β†’ 0 orang on-call. Constraint bisnis dilanggar. Tidak ada error.

SSI menambahkan deteksi rw-antidependency: T1 baca {1,2} lalu tulis 1; T2 baca {1,2} lalu tulis 2. Terbentuk siklus β†’ satu transaksi dibatalkan dengan SQLSTATE 40001.

Alur SSI di PostgreSQL

  1. SELECT di transaksi SERIALIZABLE memasang SIReadLock (predicate lock) pada heap/page/tuple yang dibaca β€” tanpa blocking.
  2. UPDATE/INSERT mencatat rw-conflict ke transaksi lain yang sudah baca baris itu.
  3. Saat commit, backend cek apakah terbentuk siklus dependency. Ada siklus β†’ ERROR: could not serialize access due to read/write dependencies among transactions (SQLSTATE 40001).
  4. Predicate lock disimpan melewati commit, sampai transaksi overlapping selesai β€” ini sumber utama memory pressure.
  5. Kalau tabel predicate lock penuh, PG menggabungkan lock granular ke level halaman lalu level relasi. Konsekuensi: abort rate naik, bukan inkonsistensi.

πŸ’» Kode Nyata / Konfigurasi

1. Reproduksi write skew β€” sudah saya jalankan, ini output aslinya

CREATE TABLE oncall(id int primary key, name text, on_call bool not null);
INSERT INTO oncall VALUES (1,'alice',true),(2,'bob',true);

Sesi A (REPEATABLE READ):

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE on_call;  -- 2
SELECT pg_sleep(2);
UPDATE oncall SET on_call=false WHERE id=1;
COMMIT;

Sesi B (paralel, REPEATABLE READ):

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE on_call;  -- 2 (snapshot sendiri)
SELECT pg_sleep(2);
UPDATE oncall SET on_call=false WHERE id=2;
COMMIT;

Hasil nyata dari PG 18.6:

A sees on_call=2 β†’ A updated β†’ A committed
B sees on_call=2 β†’ B updated β†’ B committed
FINAL: SELECT count(*) FROM oncall WHERE on_call  β†’  0

Dua-duanya sukses. Invariant rusak. Ini bukan bug PG, ini perilaku SI yang benar.

Ganti REPEATABLE READ β†’ SERIALIZABLE, output nyata:

A sees on_call=2 β†’ A updated β†’ A committed
B sees on_call=2 β†’ ERROR:  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.
FINAL: 1

2. Contoh resmi docs PG (write skew antar-agregat)

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

-- T1 SERIALIZABLE
SELECT SUM(value) FROM mytab WHERE class = 1;   -- 30
INSERT INTO mytab VALUES (2, 30);

-- T2 SERIALIZABLE (paralel)
SELECT SUM(value) FROM mytab WHERE class = 2;   -- 300
INSERT INTO mytab VALUES (1, 300);

Output nyata saya: T2 kena 40001 dengan Reason code: Canceled on identification as a pivot, during write. Ini sesuai contoh di dokumentasi resmi.

3. Retry loop yang benar (Python + psycopg 3)

import psycopg, random, time

SERIALIZATION_FAILURE = "40001"
DEADLOCK = "40P01"

def run_txn(conn, work, max_tries=10):
    for attempt in range(1, max_tries + 1):
        try:
            with conn.transaction():          # BEGIN ... COMMIT / ROLLBACK otomatis
                conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
                return work(conn)
        except psycopg.errors.Error as e:
            if e.sqlstate not in (SERIALIZATION_FAILURE, DEADLOCK):
                raise
            if attempt == max_tries:
                raise
            # backoff + jitter, JANGAN retry instan
            time.sleep(min(0.05 * 2 ** attempt, 2.0) * (0.5 + random.random()))

Aturan mutlak: work(conn) harus seluruh transaksi dibungkus ulang. Retry hanya statement yang gagal = data corruption. Setelah 40001, transaksi sudah aborted β€” semua command berikutnya dapat current transaction is aborted. Ini yang saya lihat langsung di output test di atas.

4. Deteksi predicate lock aktif

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE on_call;
SELECT locktype, mode, relation::regclass FROM pg_locks WHERE mode = 'SIReadLock';
COMMIT;

Output nyata:

relation | SIReadLock | oncall

Ini alat debugging #1 kalau abort rate tiba-tiba naik: SIReadLock di level relation (bukan tuple/page) berarti predicate lock kehabisan memory dan di-coarsen.

5. Tuning predicate lock

# postgresql.conf β€” naikkan kalau SIReadLock sering di-coarsen ke relation-level
max_pred_locks_per_transaction = 128   # default 64
max_pred_locks_per_relation     = 64   # default -2 (artinya max_pred_locks_per_transaction/2)
max_pred_locks_per_page         = 4    # default 2

Dan untuk workload SERIALIZABLE, docs PG 18 secara eksplisit menyarankan: - READ ONLY untuk transaksi yang memang read-only - hindari SELECT FOR UPDATE (redundan di SERIALIZABLE, malah menambah blocking) - idle_in_transaction_session_timeout supaya tidak ada transaksi menggantung - paksa index scan: sequential scan selalu memasang relation-level predicate lock β†’ abort rate meledak. Caranya turunkan random_page_cost / naikkan cpu_tuple_cost.


πŸ“Š Angka & Benchmark

Semua di bawah: PG 18.6, pgbench bawaan, Docker lokal, scale 5, 8 clients / 4 threads, 8–10 detik. Hardware container, bukan server produksi. Angka absolut tidak portable; rasio-nya yang berguna.

A. Low-conflict: single-row read + update (workload realistis OLTP)

Isolation tps failed
READ COMMITTED 3872 0
SERIALIZABLE 4060 0

Kesimpulan: pada workload yang tidak menyentuh baris yang sama, SSI praktis gratis (selisih di bawah noise). Ini konsisten dengan klaim paper Ports & Grittner (VLDB 2012): "performance only slightly below that of snapshot isolation."

B. High-conflict + write skew: baca agregat lintas baris, tulis baris sendiri (5 baris, 8 clients)

Isolation txn sukses failed tps
REPEATABLE READ 14.358 18.017 (55,7%) 1797
SERIALIZABLE 3.937 27.954 (87,7%) 493
SERIALIZABLE, --max-tries=10 4.360 2.181 (33,3%) 546

⚠️ Kejujuran data: di workload ini REPEATABLE READ pun sudah 55% gagal β€” penyebabnya row-level conflict (could not serialize access due to concurrent update, 40001) karena hanya ada 5 baris dan 8 client saling tabrak. Jadi selisih RRβ†’SERIALIZABLE (55%β†’88%) adalah pajak SSI di atas konflik baris yang sudah ada, bukan murni biaya SSI. Yang penting: retry tidak menyelamatkan throughput β€” tps cuma naik 493β†’546 (+10,7%) meski abort turun drastis. Retry memindahkan kerja, tidak menghilangkannya.

Pelajaran produksi: SERIALIZABLE tidak memperbaiki desain kontensi. Kalau 8 worker berebut 5 baris, ganti isolation level bukan solusinya β€” partisi baris atau antrikan lewat queue.

C. Temuan keamanan yang relevan (rilis 2026)

D. Perbandingan lintas DBMS (Hermitage, Kleppmann)

Test suite github.com/ept/hermitage mengklasifikasi anomali dengan nama formal (G0, G1a-c, OTV, PMP, P4, G-single, G2-item, G2). Poin penting yang sering salah dikutip:


βš–οΈ Trade-off & Alternatif

Pendekatan Cara kerja Kelebihan Kekurangan Kapan dipakai
READ COMMITTED snapshot per statement tanpa abort, cepat nonrepeatable read, lost update, write skew CRUD sederhana, single-row by PK
REPEATABLE READ (PG) snapshot per transaksi (SI) snapshot stabil, tanpa phantom, tanpa abort kecuali tabrakan baris write skew lolos, read-only anomaly read-heavy, reporting, agregasi
SERIALIZABLE (PG, SSI) SI + deteksi siklus benar by construction, tanpa blocking abort 40001 wajib di-retry, memory predicate lock invariant lintas-baris (saldo, kuota, seat, stok)
SERIALIZABLE (MySQL) locking read implisit benar blocking, deadlock, throughput jatuh di hot row workload rendah kontensi, atau sudah pakai aplikasi yang siap deadlock retry
Explicit lock (SELECT ... FOR UPDATE) pessimistic, blocking deterministik, mudah dipahami deadlock, lock wait, throughput drop hot row dengan kontensi tinggi; lebih murah dari SSI kalau barisnya sedikit dan diketahui
Unique constraint / exclusion constraint invariant di level storage tidak bisa dilanggar sama sekali, tanpa retry hanya untuk invariant bentuk tertentu idempotency key, double-booking, slot unik
Advisory lock (pg_advisory_xact_lock) lock per-key aplikasi granularitas bebas, ringan manual, mudah lupa release serialisasi per-entity (mis. per user_id)

Hierarki keputusan yang saya sarankan:

  1. Bisa dijaga constraint DB (unique, exclusion, check)? β†’ pakai itu. Nol retry, nol race.
  2. Bisa dijaga atomic single-statement (UPDATE ... WHERE balance >= 100)? β†’ pakai itu di READ COMMITTED. Nol retry.
  3. Kontensi lokal di sedikit baris diketahui? β†’ SELECT FOR UPDATE atau advisory lock.
  4. Invariant lintas baris yang tidak bisa di-encode sebagai constraint? β†’ baru SERIALIZABLE + retry loop.
  5. SERIALIZABLE di seluruh aplikasi secara default? Hampir selalu salah β€” kamu bayar abort di tempat yang tidak butuh.

Kombinasi yang sering optimal: READ COMMITTED default untuk 95% endpoint, SERIALIZABLE hanya di 5% transaksi yang memegang invariant, dengan retry loop terpusat di middleware.


🏭 Use Case Produksi & Failure Mode Umum

Failure mode #1 β€” retry di tengah transaksi

# SALAH β€” setelah 40001, transaksi sudah aborted
try:
    cur.execute(update_saldo)
except psycopg.errors.SerializationFailure:
    cur.execute(update_saldo)   # β†’ "current transaction is aborted"

Retry harus membungkus BEGIN ... COMMIT utuh. Saya verifikasi langsung: setelah 40001, semua command berikutnya gagal sampai ROLLBACK.

Failure mode #2 β€” retry tanpa backoff

8 client saling abort β†’ retry instan β†’ abort lagi. Di benchmark saya, max-tries=10 hanya menaikkan tps 10,7% karena semua client retry serempak. Wajib exponential backoff + jitter.

Failure mode #3 β€” sequential scan di SERIALIZABLE

Query tanpa index di SERIALIZABLE memasang relation-level predicate lock β†’ setiap write ke tabel itu jadi konflik β†’ abort rate meledak. Gejala: 40001 di endpoint yang secara logika tidak berhubungan. Cek pg_locks WHERE mode='SIReadLock' β€” kalau locktype='relation', itu penyebabnya.

Failure mode #4 β€” idle in transaction

Transaksi SERIALIZABLE menggantung memegang predicate lock melewati commit. Koneksi pool yang bocor = predicate lock menumpuk = coarsening = abort rate naik perlahan tanpa perubahan kode. Set idle_in_transaction_session_timeout.

Failure mode #5 β€” unique constraint violation di SERIALIZABLE

Ini yang paling mengejutkan dan ada di dokumentasi resmi: di SERIALIZABLE, kamu bisa dapat duplicate key value violates unique constraint walaupun sudah cek SELECT dulu. Penyebab: transaksi lain menyisipkan key yang sama tanpa mengikuti protokol "cek dulu". Solusi: pastikan semua transaksi SERIALIZABLE yang meng-insert key potensial melakukan pengecekan eksplisit lebih dulu β€” atau lebih baik, tangani unique violation sebagai retry-able error juga.

Failure mode #6 β€” salah paham "SERIALIZABLE = tidak perlu mikir"

SSI menjamin hasil ekuivalen dengan eksekusi serial. Tapi kalau logika satu transaksi sendiri salah (misal baca data basi dari cache aplikasi di luar transaksi), SERIALIZABLE tidak menolong. Isolasi DB tidak memperbaiki bug di layer aplikasi.

Use case nyata

Domain Invariant Pilihan
Booking kursi/bioskop 1 kursi = 1 pemesan unique constraint (show_id, seat_no) β€” bukan SERIALIZABLE
Saldo dompet saldo β‰₯ 0 UPDATE ... WHERE balance >= :amt di READ COMMITTED
Kuota campaign total klaim ≀ kuota UPDATE SET claimed = claimed + 1 WHERE claimed < quota + cek rowcount
On-call / approval berjaga minimal 1 approver aktif SERIALIZABLE atau advisory lock per tim
Ledger akuntansi sum debit = sum credit SERIALIZABLE + retry, atau double-entry dengan constraint
Idempotency API 1 request = 1 efek unique index pada idempotency key, ON CONFLICT DO NOTHING

πŸ› οΈ Latihan Praktis Kecil (bisa dicoba di laptop, ~10 menit)

docker run -d --rm --name isolab -e POSTGRES_PASSWORD=lab -p 55444:5432 postgres:18-alpine
sleep 6
docker exec isolab psql -U postgres -c "
  CREATE TABLE oncall(id int primary key, name text, on_call bool not null);
  INSERT INTO oncall VALUES (1,'alice',true),(2,'bob',true);"

Buka dua terminal, jalankan bergantian sesuai urutan:

Terminal A

docker exec -it isolab psql -U postgres
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE on_call;
-- tunggu, lalu jalankan UPDATE setelah B sudah SELECT
UPDATE oncall SET on_call=false WHERE id=1;
COMMIT;

Terminal B

docker exec -it isolab psql -U postgres
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE on_call;
UPDATE oncall SET on_call=false WHERE id=2;
COMMIT;

Lalu cek:

SELECT count(*) FROM oncall WHERE on_call;   -- 0 = write skew terjadi

Eksperimen lanjutan (wajib dicoba, ini bagian belajarnya):

  1. Ulangi dengan SERIALIZABLE di kedua sesi. Amati siapa yang kena 40001 dan Reason code-nya.
  2. Ulangi SERIALIZABLE, tapi sebelum UPDATE, jalankan SELECT locktype, mode, relation::regclass FROM pg_locks WHERE mode='SIReadLock'; di kedua sesi. Catat level granularitasnya.
  3. Tambah CREATE INDEX ON oncall(on_call); lalu ulangi β€” apakah Reason code berubah? (predicate lock jadi tuple-level, bukan relation-level)
  4. Jalankan UPDATE oncall SET on_call=true di sesi ketiga saat A dan B masih terbuka. Amati blocking vs abort.
  5. Retry manual: jalankan ulang transaksi B yang kena 40001. Verifikasi hasil akhirnya 1, bukan 0.

βœ… Cek Pemahaman

  1. Dua transaksi REPEATABLE READ di PostgreSQL masing-masing baca count(*) = 2 lalu meng-update baris berbeda. Keduanya commit. Apakah ini bug PostgreSQL? Kalau bukan, mekanisme apa yang seharusnya mencegahnya, dan kenapa REPEATABLE READ tidak melakukannya?

  2. Kamu punya endpoint yang di SERIALIZABLE kena 40001 sebanyak 12% padahal endpoint itu cuma SELECT satu baris by primary key. Sebutkan dua kemungkinan penyebab paling mungkin, dan query apa yang kamu jalankan untuk membedakannya.

  3. Kenapa SELECT ... FOR UPDATE yang ditambahkan "supaya aman" ke transaksi SERIALIZABLE justru bisa memperburuk throughput, padahal tujuannya sama-sama menjamin konsistensi?


πŸ”‘ Poin Kunci


➑️ Eksplorasi Lanjut

  1. Baca paper aslinya: Ports & Grittner, Serializable Snapshot Isolation in PostgreSQL, VLDB 2012 β€” PDF. Bagian menarik: bagaimana mereka membatasi memory predicate lock lewat "transaction summarization", dan kenapa read-only transaction bisa sering melepas lock di awal.
  2. Uji DBMS-mu sendiri: port test suite Hermitage ke stack-mu. Sepuluh test case, masing-masing ~20 baris SQL. Ini cara tercepat tahu apa yang sebenarnya dijamin database yang kamu pakai, bukan apa yang diklaim nama level-nya.
  3. Bandingkan dengan model lain: CockroachDB dan Spanner memakai Serializable sebagai default dengan mekanisme berbeda (timestamp ordering + TrueTime / HLC). Pertanyaan menarik: kenapa mereka bisa, dan apa yang mereka bayar (latency commit, regional round-trip)?
  4. Topik lanjutan yang menyambung: optimistic concurrency control di level aplikasi (version column + WHERE version = :v), dan kenapa pola itu sering lebih murah dari SERIALIZABLE untuk invariant per-baris.
  5. Observability: bikin dashboard untuk pg_stat_database.xact_rollback + pg_locks WHERE mode='SIReadLock'. Lonjakan xact_rollback tanpa perubahan traffic = tanda predicate lock coarsening.

Batasan riset sesi ini: - βœ… Terverifikasi langsung: perilaku isolation PG 18.6, semua output SQL dan angka pgbench di atas β€” dijalankan hari ini di container postgres:18-alpine. - βœ… Terverifikasi dokumentasi: tabel isolation level PG 18, definisi SSI, tuning max_pred_locks_*, fix 18.6, status PG 19 RC 1, default MySQL InnoDB. - ⚠️ Angka benchmark tidak representatif produksi: single container, storage lokal, dataset scale 5, tanpa network. Gunakan rasio, jangan angka absolut. - ⚠️ Belum diverifikasi: perilaku isolation MySQL 9.x secara empiris (tidak ada instance MySQL 9 di environment ini β€” hanya mysql:8.0); klaim MySQL di atas bersumber dari dokumentasi resmi, bukan test saya. - ❓ Tidak ada angka resmi soal overhead SSI dari PostgreSQL Global Development Group untuk versi 18. Klaim "slightly below SI" berasal dari paper 2012 dan benchmark saya sendiri.