Transaksi Database & Anotasi @Transactional

15 min readIntermediate
DatabaseTransaksiACIDSpring

Pernah menyalin uang dari rekening ke rekening lain? Dua hal harus terjadi: saldo pengirim berkurang, saldo penerima bertambah. Kalau yang pertama sukses tapi yang kedua gagal, uangnya "hilang" entah ke mana. Di sinilah transaksi berperan — ia mengubah sekumpulan operasi menjadi satu kesatuan yang tidak bisa dibelah: berhasil semua, atau batal semua.

Artikel ini membahas konsep transaksi database dari dasar (ACID, commit, rollback), lalu beranjak ke cara praktis mengelolanya di Spring dengan anotasi @Transactional — termasuk jebakan yang paling sering membuat developer bingung, seperti self-invocation.

Apa Itu Transaksi

Transaksi (transaction) adalah sekumpulan operasi database yang dijalankan sebagai satu unit all-or-nothing: entah seluruh operasinya berhasil, entah seluruhnya dibatalkan. Tidak ada keadaan "setengah jalan".

Contoh klasiknya transfer uang dari rekening A ke rekening B:

// Tanpa transaksi: rentan setengah jalan
accountRepository.debit(fromId, amount);   // A berkurang ... sukses
accountRepository.credit(toId, amount);    // B bertambah ... BOOM, gagal

Jika dua operasi di atas dijalankan tanpa transaksi, kegagalan di baris kedua meninggalkan data yang salah: uang sudah ditarik dari A tapi belum masuk ke B. Dengan transaksi, kegagalan di mana pun di dalam unit tersebut membuat semua perubahan dibatalkan, sehingga data kembali seperti semula.

Bayangkan transaksi sebagai tombol "batal" di aplikasi food delivery: kalau pesanan sedang diproses dan satu langkahnya gagal, seluruh pesanan dibatalkan — bukan dipertahankan setengah-setengah.

ACID: Empat Sifat Transaksi yang Aman

Agar transaksi benar-benar dapat diandalkan, database harus menjamin empat sifat yang disingkat ACID: Atomicity, Consistency, Isolation, dan Durability.

HurufSifatMaknaContoh sederhana
AAtomicitySemua operasi dalam transaksi sukses, atau semuanya dibatalkanTransfer uang: debit + credit dijalankan sebagai satu kesatuan
CConsistencyTransaksi mengubah database dari satu keadaan valid ke keadaan valid lainAturan "saldo tidak boleh negatif" tetap dijaga setelah transfer
IIsolationTransaksi yang berjalan bersamaan tidak saling menggangguDua orang menabrak transaksi yang sama, hasilnya seperti dijalankan bergantian
DDurabilityPerubahan yang sudah commit tetap tersimpan walau server mati mendadakUang yang sudah ditransfer tetap tercatat setelah database restart

Mari kita lihat satu per satu lebih dekat.

Atomicity

Atomicity menjamin transaksi tidak bisa berhenti di tengah jalan. Jika salah satu operasi gagal, seluruh operasi yang sudah sempat dieksekusi ikut dibatalkan. Inilah "jiwa" dari transaksi — tidak ada operasi yang dibiarkan menggantung.

Consistency

Consistency berkaitan dengan invariant (aturan) yang didefinisikan aplikasi atau database: foreign key, constraint, nilai default, dan sejenisnya. Setelah transaksi selesai, database selalu berada dalam keadaan yang "masuk akal". Perlu dicatat: database hanya menjamin konsistensi yang bisa dinyatakan lewat constraint; logika bisnis seperti "saldo tidak boleh minus" tetap tanggung jawab aplikasi.

Isolation

Isolation mengatur bagaimana transaksi yang berjalan bersamaan saling berinteraksi. Tanpa isolasi yang tepat, transaksi A bisa membaca data setengah jadi milik transaksi B. Detailnya akan kita bahas di bagian isolation level.

Durability

Durability menjanjikan bahwa begitu transaksi di-commit, datanya permanen. Database melakukan ini dengan menuliskan catatan ke transaction log (WAL di PostgreSQL, redo log di MySQL) sebelum menyatakan "sukses". Dengan begitu, walau listrik mati sesaat setelahnya, data bisa dipulihkan saat server menyala kembali.

Commit vs Rollback

Transaksi berakhir dengan salah satu dari dua keputusan:

Transaksi — commit vs rollback

COMMIT (komit) menyatakan transaksi berhasil. Semua perubahan yang terjadi di dalamnya disimpan permanen ke database dan bisa dilihat transaksi lain. Ini seperti menekan tombol "kirim" pada formulir transfer — uangnya benar-benar berpindah.

ROLLBACK (gulung balik) menyatakan transaksi gagal. Semua perubahan dibatalkan, dan database kembali ke keadaan sebelum transaksi dimulai. Ini seperti membatalkan formulir sebelum dikirim — seolah-olah tidak pernah terjadi apa-apa.

Intuisi cepat: pikirkan transaksi sebagai save point di game. COMMIT = simpan progress; ROLLBACK = muat ulang dari simpanan terakhir.

Contoh dunia nyata yang sering terasa: memesan tiket konser. Prosesnya bisa terdiri dari beberapa langkah — cek ketersediaan kursi, reserve kursi, potong saldo pembayaran, buat tiket digital. Kalau pembayaran gagal di tengah, tentu kita tidak mau kursinya tetap ter-reserve. Semua langkah masuk satu transaksi; ketika pembayaran gagal, semuanya di-rollback dan kursi kembali tersedia untuk pembeli lain.

BEGIN / COMMIT / ROLLBACK secara Manual

Di SQL, transaksi dikendalikan lewat tiga perintah dasar:

BEGIN;                              -- mulai transaksi
UPDATE accounts SET balance = balance - 100000 WHERE id = 1;  -- debit
UPDATE accounts SET balance = balance + 100000 WHERE id = 2;  -- kredit
COMMIT;                             -- berhasil: simpan permanen

Dan versi gagalnya:

BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
UPDATE accounts SET balance = balance + 100000 WHERE id = 2;
ROLLBACK;                           -- ada kesalahan: batal semua

Mengelola BEGIN/COMMIT/ROLLBACK secara manual di aplikasi itu rentan salah. Beberapa masalah yang biasa muncul:

  • Lupa memanggil COMMIT setelah serangkaian operasi → koneksi terkunci, transaksi menggantung.
  • Exception terjadi di tengah kode tapi ROLLBACK tidak sempat dieksekusi karena tertelan catch block.
  • Code path jadi berantakan: pemanggilan beginTransaction() dan commit() tersebar di mana-mana, bercampur dengan logika bisnis.
  • Koneksi database dipakai terlalu lama karena transaksi "terbuka" menunggu keputusan commit/rollback yang tidak kunjung datang.

Karena itu lahirlah pendekatan deklaratif: kita cukup menyatakan "method ini transaksional", lalu framework yang mengurusi buka, commit, dan rollback-nya. Di Spring, caranya dengan anotasi @Transactional.

@Transactional di Spring

Anotasi @Transactional menandai sebuah method (atau class) agar dijalankan dalam lingkup transaksi. Spring mengambil alih semua detail begin/commit/rollback sehingga kode bisnis kita bersih dari boilerplate:

@Service
public class TransferService {

    private final AccountRepository accountRepository;

    public TransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void transfer(long fromId, long toId, BigDecimal amount) {
        accountRepository.debit(fromId, amount);
        accountRepository.credit(toId, amount);
    }
}

Jika credit melempar exception, Spring otomatis melakukan rollback — dua baris di atas seolah-olah tidak pernah dijalankan.

Cara Kerja: Proxy di Balik Layar

Spring tidak "memodifikasi" method kita. Ia membungkus bean kita dengan proxy — objek pengganti yang dibangkitkan secara dinamis. Proxy inilah yang membuka transaksi sebelum method dijalankan, lalu memutuskan commit atau rollback setelah method selesai:

  Pemanggil (controller/service lain)
        |
        |  panggilan
        v
  +-----------------------+
  |   Spring Proxy        |  1. buka transaksi (BEGIN)
  |   (objek transaksional)|  2. panggil method asli
  +-----------------------+
        |
        v
  +-----------------------+
  |   Method asli Anda    |  3. operasi database dijalankan
  +-----------------------+
        |
        v
  +-----------------------+
  |   Spring Proxy        |  4. sukses  -> COMMIT
  |                       |     exception -> ROLLBACK
  +-----------------------+

Kunci memahami @Transactional: yang menerima panggilan adalah proxy, bukan bean asli. Ini adalah akar dari hampir semua jebakan yang akan kita bahas nanti, termasuk self-invocation.

Secara default, Spring memakai proxy berbasis subclass (CGLIB) yang dihasilkan saat aplikasi startup. Prosesnya bisa Anda baca lebih lanjut di dokumentasi transaksi Spring.

Rollback Rules: Checked vs Runtime Exception

Keputusan rollback di Spring mengikuti aturan berikut:

Jenis exceptionApakah memicu rollback?Penjelasan
RuntimeException (unchecked) — mis. IllegalArgumentException, NullPointerExceptionYaDefault: apapun RuntimeException atau Error memicu rollback
ErrorYaMis. OutOfMemoryError
Checked exception — mis. IOException, SQLExceptionTidakDefault-nya TIDAK memicu rollback

Alasan di balik aturan ini: Spring menganut anggapan bahwa checked exception menandakan "kegagalan yang diharapkan dan bisa diantisipasi", sehingga transaksi dianggap masih bisa dilanjutkan. Sebaliknya, RuntimeException menandakan kondisi tak terduga yang membuat data dalam keadaan tidak aman.

Karena perilaku ini sering tidak diinginkan, kita bisa memaksa rollback untuk semua exception:

@Transactional(rollbackFor = Exception.class)
public void processInvoice(Invoice invoice) throws InvoiceServiceException {
    invoiceRepository.save(invoice);
    billingClient.send(invoice); // bisa melempar InvoiceServiceException (checked)
}

Kebiasaan yang aman: jika Anda melempar exception sendiri yang bersifat checked, selalu sertakan rollbackFor = Exception.class (atau setidaknya rollbackFor = YourException.class). Kalau tidak, transaksi Anda bisa "berhasil" commit padahal logika bisnisnya gagal.

Penjelasan lengkap tentang aturan ini ada di referensi @Transactional Spring.

Propagation: Bagaimana Transaksi Bercabang

Ketika method transaksional A memanggil method transaksional B, apa yang terjadi? Itu ditentukan oleh propagation (perambatan). Nilai yang paling sering dipakai:

PropagationPerilakuKapan dipakai
REQUIRED (default)Pakai transaksi yang sudah ada; jika tidak ada, buat yang baruHampir semua kasus normal
REQUIRES_NEWSelalu membuat transaksi baru; transaksi lama dijeda duluAudit log, kirim event, operasi yang harus terekam walau transaksi utama di-rollback
SUPPORTSPakai transaksi jika ada; jika tidak, jalan tanpa transaksiMethod read-only yang boleh dipanggil dari mana saja
MANDATORYHarus sudah ada transaksi; jika tidak, lempar exceptionMethod yang hanya boleh dipanggil dari dalam transaksi
NESTEDPakai savepoint di dalam transaksi yang adaOperasi parsial yang boleh di-rollback tanpa membatalkan seluruh transaksi

Fokus utama kita pada REQUIRED vs REQUIRES_NEW karena perbedaannya paling sering menimbulkan kejutan.

REQUIRED — jika method B dipanggil di dalam transaksi method A, B bergabung ke transaksi yang sama. Akibatnya: kalau B di-rollback, A ikut batal; kalau A di-rollback, semua yang sudah dikerjakan B ikut hilang.

@Service
public class TransferService {

    @Transactional   // REQUIRED (default)
    public void transfer(long fromId, long toId, BigDecimal amount) {
        accountRepository.debit(fromId, amount);
        bonusService.applyBonus(toId, amount); // bergabung ke transaksi yang sama
        accountRepository.credit(toId, amount);
    }
}

REQUIRES_NEW — method B membuka transaksi terpisah yang independen. Transaksi A dijeda selama B berjalan. Jika B berhasil di-commit, hasilnya tetap tersimpan walaupun A nanti di-rollback. Ini cocok untuk audit log yang harus selalu tercatat apa pun hasil transaksi utama:

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, long entityId) {
        auditLogRepository.save(new AuditLog(action, entityId, Instant.now()));
    }
}
@Service
public class TransferService {

    @Transactional
    public void transfer(long fromId, long toId, BigDecimal amount) {
        accountRepository.debit(fromId, amount);
        auditService.log("TRANSFER_START", fromId);  // transaksi terpisah
        accountRepository.credit(toId, amount);
    }
}

Ingat: dengan REQUIRES_NEW, log audit akan tersimpan meskipun transaksi utama gagal di-rollback. Justru itu tujuannya — kita ingin jejak kegagalan tetap ada.

Isolation Level dan Fenomena Pembacaan

Isolation level menentukan seberapa "terbuka" sebuah transaksi terhadap perubahan transaksi lain yang belum commit. Setiap level memungkinkan atau mencegah tiga fenomena pembacaan:

FenomenaArti
Dirty readMembaca data yang ditulis transaksi lain yang belum commit; jika transaksi itu di-rollback, kita sudah terlanjur membaca data "hantu"
Non-repeatable readDalam satu transaksi, membaca baris yang sama dua kali dan mendapat nilai berbeda karena transaksi lain meng-commit di antaranya
Phantom readDalam satu transaksi, menjalankan query yang sama dua kali dan mendapat jumlah baris berbeda karena transaksi lain menambah/menghapus baris di antaranya

Matriks perlindungan tiap level:

Isolation LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDBisa terjadiBisa terjadiBisa terjadi
READ_COMMITTEDAmanBisa terjadiBisa terjadi
REPEATABLE_READAmanAmanBisa terjadi (PostgreSQL mengamankan)
SERIALIZABLEAmanAmanAman

Ringkasan cepat:

LevelDeskripsi
READ_UNCOMMITTEDPaling longgar; bisa membaca data belum commit (dirty read). Jarang dipakai
READ_COMMITTEDDefault PostgreSQL dan Oracle; hanya membaca data yang sudah commit
REPEATABLE_READDefault MySQL/InnoDB; data yang dibaca stabil selama transaksi
SERIALIZABLEPaling ketat; transaksi dijalankan seakan bergiliran, tanpa saling melihat

Catatan penting: "default" berbeda antar database. PostgreSQL menggunakan READ_COMMITTED secara bawaan; MySQL (InnoDB) menggunakan REPEATABLE_READ. Jika aplikasi Anda berganti database, perilaku transaksi bisa ikut berubah. Lihat dokumentasi isolasi transaksi PostgreSQL untuk detailnya.

Contoh penggunaan eksplisit:

@Transactional(isolation = Isolation.SERIALIZABLE)
public void bookSeat(long seatId, long userId) {
    // paling ketat: dua booking bersamaan tidak akan saling menabrak
    seatRepository.reserve(seatId, userId);
}

Semakin tinggi level isolasi, semakin aman datanya — tetapi semakin berat juga (lebih banyak lock, lebih sering transaksi harus menunggu). Pilih level paling rendah yang masih cukup untuk kebutuhan Anda; jangan asal SERIALIZABLE.

Di Mana Sebaiknya @Transactional Diletakkan

Letakkan @Transactional di service layer, bukan di repository maupun controller:

  • Service layer — tempat logika bisnis yang terdiri dari beberapa operasi database; inilah unit kerja yang benar-benar perlu "semua atau tidak sama sekali".
  • Repository — repository hanya mengeksekusi satu operasi data (satu save, satu findAll). Menandai repository transaksional hanya berguna dalam kasus khusus (misal method repository yang menjalankan beberapa statement), dan biasanya tidak perlu.
  • Controller — controller harus tetap tipis: hanya menerima request dan meneruskan ke service. Menaruh @Transactional di sini memperlebar lingkup transaksi ke hal-hal yang bukan urusan database (parsing, validasi, serialisasi), persis yang tidak kita inginkan.

Sebagai aturan praktis: jika method memanipulasi lebih dari satu tabel atau membutuhkan operasi yang harus konsisten, taruh transaksinya di service.

Jebakan Umum @Transactional

Self-Invocation: Proxy Tidak Aktif

Ini jebakan nomor satu. Karena Spring membungkus bean dengan proxy, panggilan antar method di dalam class yang sama lewat this tidak melewati proxy — transaksinya tidak aktif.

@Service
public class OrderService {

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        chargePayment(order); // panggilan internal lewat `this`
    }

    @Transactional
    public void chargePayment(Order order) {
        paymentGateway.charge(order);
        orderRepository.markPaid(order.getId());
    }
}

Dari sudut pandang createOrder, baris chargePayment(order) memanggil method secara langsung di objek yang sama, bukan lewat proxy. Akibatnya chargePayment dijalankan tanpa transaksi — kalau terjadi RuntimeException di dalamnya, perubahan pada orderRepository.markPaid tidak otomatis di-rollback bersama createOrder.

Solusinya, inject diri sendiri (self-injection) atau pisahkan ke bean lain:

@Service
public class OrderService {

    @Autowired
    private OrderService self;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        self.chargePayment(order); // lewat proxy -> transaksi aktif
    }

    @Transactional
    public void chargePayment(Order order) {
        paymentGateway.charge(order);
        orderRepository.markPaid(order.getId());
    }
}

Pendekatan yang lebih bersih: pindahkan chargePayment ke bean tersendiri (misal PaymentService). Dua method di class berbeda akan selalu melewati proxy satu sama lain. Referensi resminya: panduan transaksi deklaratif Spring.

Transaksi Terlalu Panjang

Transaksi mengunci resource database: koneksi JDBC, baris data (row lock), bahkan tabel. Semakin lama transaksi terbuka, semakin besar risiko:

  • Koneksi pool habis karena semua koneksi "ditahan" transaksi yang belum selesai.
  • Baris yang di-lock menghalangi transaksi lain (yang bisa berujung deadlock atau waktu tunggu membengkak).

Prinsipnya: jaga transaksi tetap pendek. Jangan lakukan hal berikut di dalam @Transactional:

  • Panggilan HTTP ke layanan lain (restTemplate.externalApi()) — bisa lambat berdetik-detik padahal koneksi dan lock terus ditahan.
  • Mengirim email / notifikasi — memang biasanya cepat, tapi tetap bukan urusan database.
  • Proses yang butuh input pengguna — jangan pernah membuka transaksi lalu menunggu jawaban manusia.

Gambaran buruk:

@Transactional
public void register(User user) {
    userRepository.save(user);
    externalVerificationService.call() // HTTP lambat, koneksi & lock tertahan
    emailService.sendWelcome(user);     // lebih baik setelah commit
}

Pola yang lebih sehat: selesaikan semua operasi database dalam transaksi, lalu lakukan efek samping (HTTP, email) setelah transaksi commit. Jika efek samping itu penting, pertimbangkan pola outbox atau event asynchronous — topik lanjutan di artikel microservice yang resilient.

Hanya Method Public yang Dibungkus Proxy

Proxy Spring (berbasis CGLIB) hanya bisa mencegat method yang public. Method private atau protected yang diberi @Transactional tidak akan dijalankan dalam transaksi — anotasinya diam-diam diabaikan. Spring sendiri sudah memperingatkan ini di dokumentasinya. Jadi pastikan @Transactional selalu menempel pada method public (dan sebaiknya dipanggil dari luar bean).

Optimistic vs Pessimistic Locking

Ketika dua transaksi mengubah baris yang sama bersamaan, kita perlu mengendalikan konfliknya. Ada dua strategi besar:

Optimistic locking berasumsi konflik jarang terjadi. Kita menaruh kolom versi — Spring Data JPA melakukannya lewat anotasi @Version:

@Entity
public class Account {

    @Id
    private Long id;

    @Version
    private Long version; // dinaikkan otomatis oleh JPA setiap update

    private BigDecimal balance;
}

Saat dua transaksi mencoba meng-update baris yang sama, transaksi yang kalah akan mendapat OptimisticLockException (atau ObjectOptimisticLockingFailureException) karena versi yang dibacanya sudah basi. Tidak ada lock yang menahan baris — jadi performanya ringan.

Pessimistic locking berasumsi konflik sering terjadi. Baris dikunci sejak awal sehingga transaksi lain menunggu:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);

Query di atas menjadi SELECT ... FOR UPDATE di database — baris dikunci sampai transaksi selesai, transaksi lain yang membaca baris yang sama akan menunggu.

AspekOptimistic (@Version)Pessimistic (PESSIMISTIC_WRITE)
Lock menahan dataTidakYa (sampai commit)
Beban pada databaseRinganLebih berat (lock overhead)
Cara menangani konflikException saat commit (update gagal)Menunggu, lalu lanjut
Cocok untukKonflik jarang, lalu lintas baca tinggiKonflik sering, update cepat

Alur Transaksi dalam Satu Request

Ringkasan visual bagaimana transaksi hidup dan mati dalam satu request di Spring:

 Request masuk -> Controller -> Service (dibungkus proxy)
                                         |
                    @Transactional        v
                   .-------------------------------.
                   |  1. Proxy membuka transaksi    |
                   |  2. Method asli dijalankan     |
                   |     a. operasi DB #1           |
                   |     b. operasi DB #2           |
                   |     c. ...                     |
                   |  3a. selesai normal  -> COMMIT |
                   |  3b. RuntimeException-> ROLLBACK|
                   '-------------------------------'
                                         |
                     response sukses / exception ke caller

Pada kasus gagal: RuntimeException ditangkap proxy, seluruh operasi di-ROLLBACK, lalu exception diteruskan ke ControllerAdvice yang memetakannya ke HTTP 500.

Ringkasan

Transaksi mengubah sekumpulan operasi menjadi satu unit all-or-nothing yang dijamin oleh sifat ACID: Atomicity, Consistency, Isolation, Durability. Transaksi berakhir dengan COMMIT (simpan) atau ROLLBACK (batal semua). Mengelola BEGIN/COMMIT/ROLLBACK secara manual rentan salah, sehingga Spring menyediakan @Transactional secara deklaratif lewat proxy: transaksi dibuka sebelum method, di-commit saat sukses, dan di-rollback saat ada RuntimeException (kecuali kita override dengan rollbackFor). Pahami propagation (REQUIRED vs REQUIRES_NEW) dan isolation level sesuai database yang dipakai. Letakkan anotasi di service layer, waspadai self-invocation dan transaksi yang terlalu panjang, dan pilih locking (@Version vs PESSIMISTIC_WRITE) sesuai pola konflik Anda.

Lanjut membaca

← Back to technical articles