
Database Transaction dan Isolation Level: Panduan Praktis dari Kasus Nyata yang Pernah Saya Ambuk
Panduan praktis database transaction dan isolation level untuk developer: anomaly yang sering terjadi, kapan pakai READ COMMITTED vs SERIALIZABLE, plus contoh kode Prisma dan SQL yang bisa langsung dicoba.
Beberapa tahun lalu saya menghabiskan hampir satu minggu mengejar bug yang aneh: saldo wallet beberapa user di sistem SaaS multi-tenant kami minus, padahal ada validasi saldo >= jumlah di setiap transaksi penarikan. Log aplikasi bersih, tidak ada error sama sekali. Ternyata dua request penarikan datang hampir bersamaan, keduanya membaca saldo yang sama (misal Rp100.000), keduanya lolos validasi, dan keduanya menulis saldo baru berdasarkan nilai lama itu. Klasik lost update — dan saat itu saya bahkan belum tahu istilahnya.
Masalahnya bukan kurang validasi. Masalahnya saya tidak paham bagaimana database transaction dan isolation level bekerja. Tulisan ini adalah rangkuman hal-hal yang saya pelajari dengan cara yang sulit, supaya Anda tidak perlu membayar biaya yang sama.
Transaction Itu Bukan Sekadar BEGIN-COMMIT
Saya dulu mengira transaction cuma mekanisme "all or nothing": kalau ada error, rollback. Itu memang benar (atomicity), tapi cuma satu dari empat jaminan ACID. Bagian yang paling sering diabaikan developer — termasuk saya waktu itu — adalah isolation: seberapa banyak transaksi lain "bocor" masuk ke dalam transaksi kita yang sedang berjalan.
Tanpa isolation, transaksi yang berjalan paralel bisa saling mengganggu dengan cara-cara yang halus. Dan inilah yang membuat bug concurrency jadi menyebalkan: di local development dengan satu user, semuanya selalu benar. Bug baru muncul di produksi saat traffic nyata.
Empat Anomaly yang Perlu Anda Kenali
Sebelum bicara isolation level, Anda perlu tahu jenis "kebocoran" apa saja yang mungkin terjadi. Ini bukan teori akademik — masing-masing pernah menggigit saya atau tim saya.
1. Dirty Read
Transaksi A membaca data yang sudah ditulis transaksi B, tapi B belum commit — dan bisa saja nanti rollback. Anda mengambil keputusan berdasarkan data yang secara teknis tidak pernah ada. Di PostgreSQL ini tidak akan terjadi pada isolation level default pun, tapi jangan asumsikan sama untuk database lain.
2. Non-repeatable Read
Dalam satu transaksi, Anda membaca baris yang sama dua kali dan hasilnya beda karena transaksi lain commit perubahan di antaranya. Ini sering bikin logika "baca-cek-tulis" jadi tidak konsisten.
3. Phantom Read
Mirip non-repeatable read, tapi pada level query: Anda menjalankan SELECT dengan kondisi yang sama dua kali, jumlah baris yang kembali berbeda karena transaksi lain insert baris baru yang cocok dengan kondisi itu.
4. Lost Update (yang menangkap saya)
Dua transaksi membaca nilai yang sama, menghitung berdasarkan nilai itu, lalu menulis. Yang commit terakhir menimpa hasil yang pertama — update pertama "hilang" tanpa jejak. Persis kasus saldo wallet saya di atas.

Isolation Level: dari Paling Longgar ke Paling Ketat
Standar SQL mendefinisikan empat level. Semakin ketat, semakin sedikit anomaly yang bisa terjadi — tapi juga semakin banyak konflik lock dan potensi retry. Tidak ada makan siang gratis.
| Level | Dirty Read | Non-repeatable Read | Phantom | Lost Update |
|---|---|---|---|---|
| READ UNCOMMITTED | mungkin | mungkin | mungkin | mungkin |
| READ COMMITTED | tidak | mungkin | mungkin | mungkin |
| REPEATABLE READ | tidak | tidak | tergantung DB | biasanya tidak |
| SERIALIZABLE | tidak | tidak | tidak | tidak |
Beberapa catatan penting dari pengalaman saya:
- PostgreSQL default-nya READ COMMITTED. Artinya lost update masih mungkin terjadi pada level default. Ini fakta yang mengejutkan banyak developer.
- MySQL/InnoDB default-nya REPEATABLE READ, dan implementasinya berbeda dengan PostgreSQL — dua database bisa punya nama level sama tapi perilaku beda. Jangan pernah berasumsi level ini berperilaku identik di semua database.
- SERIALIZABLE itu mahal. Di PostgreSQL, transaksi SERIALIZABLE yang konflik akan kena error serialization failure dan harus di-retry oleh aplikasi. Kalau Anda naikkan level tanpa menulis retry logic, Anda baru menukar satu bug dengan bug lain.
Pengenalan yang lebih detail soal perbedaan tiap level dan sejarahnya bisa dibaca di artikel Fauna tentang introduction to transaction isolation levels.

Solusi untuk Lost Update: Tiga Pendekatan yang Saya Pakai
Kasus saldo wallet tadi bisa diselesaikan dengan beberapa cara. Saya sudah mencoba ketiganya di produksi, dan masing-masing punya tempatnya.
1. Atomic UPDATE dengan kondisi di WHERE
Cara paling sederhana dan paling cepat: pindahkan validasi ke dalam statement UPDATE itu sendiri.
UPDATE wallets
SET balance = balance - 50000
WHERE user_id = 42 AND balance >= 50000;
Kalau baris ter-update, tarikan valid. Kalau tidak, saldo tidak cukup. Tidak ada race condition karena UPDATE di PostgreSQL mengunci baris yang dia ubah sampai transaksi selesai. Ini yang saya pakai untuk kasus-kasus sederhana.
Di Prisma bentuknya seperti ini:
const result = await prisma.wallet.updateMany({
where: { userId: 42, balance: { gte: 50000 } },
data: { balance: { decrement: 50000 } },
});
if (result.count === 0) {
throw new Error('Saldo tidak cukup');
}
2. SELECT ... FOR UPDATE
Kalau logikanya lebih kompleks dari sekadar decrement — misalnya perlu membaca beberapa tabel dulu sebelum memutuskan — gunakan pessimistic locking:
BEGIN;
SELECT balance FROM wallets WHERE user_id = 42 FOR UPDATE;
-- validasi + hitung di aplikasi
UPDATE wallets SET balance = 45000 WHERE user_id = 42;
COMMIT;
FOR UPDATE mengunci baris sampai transaksi selesai, jadi transaksi lain yang juga FOR UPDATE pada baris yang sama akan menunggu. Di Prisma Anda bisa pakai prisma.$queryRaw untuk ini. Diskusi bagus soal kapan pakai locks vs isolation level ada di thread Stack Overflow tentang transactions dan locks untuk database integrity.
Hati-hati dengan urutan lock: kalau dua transaksi mengunci baris yang sama dalam urutan berbeda, Anda bisa dapat deadlock. Kebiasaan saya sekarang: selalu lock baris dengan urutan yang konsisten (misalnya sort by ID) di semua kode path.
3. SERIALIZABLE dengan retry
Untuk logika bisnis yang kompleks — banyak tabel, banyak kondisi, sulit dijinakkan dengan dua cara di atas — naikkan transaksi itu saja ke SERIALIZABLE:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- baca, hitung, tulis sesukanya
COMMIT;
Database akan mendeteksi konflik dan melempar error 40001. Aplikasi harus menangkapnya dan menjalankan ulang seluruh transaksi. Pola retry sederhana di TypeScript:
async function withSerializableRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await prisma.$transaction(fn, {
isolationLevel: 'Serializable',
});
} catch (err: any) {
if (err?.code === '40001' && attempt < maxRetries) continue;
throw err;
}
}
throw new Error('Unreachable');
}
Yang saya suka dari pendekatan ini: Anda menulis logika bisnis seolah-olah single-user, concurrency ditangani database. Yang saya tidak suka: retry bisa memperburuk latensi saat hotspot (satu baris yang sangat sering di-update), jadi untuk kasus high-contention sederhana, atomic UPDATE tetap menang.
Kesalahan yang Paling Sering Saya Lihat di Review Code
Setelah dua kali kena masalah ini sendiri, sekarang saya cukup sensitif melihat pola berikut di pull request:
Baca-cek-tulis tanpa lock. const wallet = await prisma.wallet.findUnique(...), validasi di JavaScript, lalu update. Ini bom waktu. Level default PostgreSQL tidak akan menyelamatkan Anda.
Transaction panjang di dalam request handler yang menunggu API eksternal. Saya pernah melihat transaksi yang dibuka, lalu memanggil payment gateway di tengahnya. Connection pool cepat habis, seluruh service ikut tumbang. Aturan saya: transaksi sependek mungkin, semua panggilan I/O eksternal di luar transaksi. Ini sekaligus alasan Anda perlu paham cara kerja connection pool di ORM — query boros dan transaksi panjang sama-sama memakan pool dengan cara berbeda.
Retry tidak idempotent. Kalau Anda retry transaksi, pastikan operasinya aman dijalankan ulang. Kalau transaksi menulis ke tabel dan sekalian men-trigger side effect (kirim email, push ke queue), pindahkan side effect ke setelah commit — atau lebih baik lagi ke background job, sesuai pola yang saya bahas di artikel tentang background job dan queue dengan BullMQ.
Menganggap ORM transaction otomatis menyelesaikan concurrency. prisma.$transaction hanya memberi atomicity. Isolation-nya tetap READ COMMITTED default kecuali Anda eksplisit set. Banyak developer — termasuk saya dulu — mengira membungkus kode di transaction sudah cukup. Tidak.
Cheat Sheet: Kapan Pakai Apa
Setelah beberapa tahun, keputusan saya biasanya sejalan dengan urutan berikut:
- Single-row counter/balance → atomic
UPDATEdengan kondisi diWHERE. Paling cepat, paling sederhana. - Multi-step read-then-write pada beberapa baris →
SELECT ... FOR UPDATEdalam satu transaksi, dengan urutan lock konsisten. - Logika kompleks, contention rendah-sedang → SERIALIZABLE + retry loop.
- High contention pada satu hot row (misalnya counter global) → pertimbangkan pindah ke queue agar operasi serial di level aplikasi, daripada memaksa database berebut lock.
Dan satu hal terakhir: uji concurrency-nya. Buka dua terminal psql, jalankan BEGIN, dan eksekusi statement bergantian sambil mengamati kapan yang satu menunggu dan kapan yang satu error. Lima belas menit eksperimen manual ini mengajarkan saya lebih banyak daripada sekian tutorial yang pernah saya baca — dan bug saldo minus itu tidak pernah terulang lagi.
Sumber
- Demystifying Database Systems: An Introduction to Transaction Isolation Levels — Fauna — referensi detail perbedaan tiap isolation level dan anomaly yang mencegahnya.
- How to properly use transactions and locks to ensure database integrity? — Stack Overflow — diskusi praktis kapan memakai locks vs bergantung pada isolation level.
Tomi Hartanto
Senior Software Engineer
Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.
Artikel Terkait

Soft Delete vs Hard Delete: Saya Kena Masalah di Kedua-Duanya, Ini Cara Saya Memilih Sekarang

Row-Level Security vs Schema-per-Tenant: Saya Coba Keduanya untuk Isolasi Data Multi-Tenant, Ini Hasilnya

Connection Pooling: Kenapa Aplikasi Backend Kamu Sering Kehabisan Koneksi Database
Tomi Hartanto
Senior Software EngineerBackend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.