Langsung ke konten
Database Transaction dan Isolation Level: Panduan Praktis dari Kasus Nyata yang Pernah Saya Ambuk
Kembali ke Blog
Database·8 menit baca·

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.

TH
Tomi Hartanto·Senior Software Engineer

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.

Ilustrasi dua transaksi paralel membaca saldo sama dan saling menimpa hasil tulisan

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.

Perbandingan empat isolation level dari longgar hingga ketat

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:

  1. Single-row counter/balance → atomic UPDATE dengan kondisi di WHERE. Paling cepat, paling sederhana.
  2. Multi-step read-then-write pada beberapa barisSELECT ... FOR UPDATE dalam satu transaksi, dengan urutan lock konsisten.
  3. Logika kompleks, contention rendah-sedang → SERIALIZABLE + retry loop.
  4. 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

TH

Tomi Hartanto

Senior Software Engineer

Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.