Transaksi memastikan bahwa serangkaian operasi database berjalan sebagai unit atomik — semua berhasil, atau semua dibatalkan. Tanpa transaksi, sebagian operasi dapat berhasil dan sebagian gagal, meninggalkan data dalam keadaan tidak konsisten. Pada chapter ini kita akan memahami prinsip ACID, anotasi @Transactional Spring, dan isolation level — diuji lewat SQLite in-memory.

Prinsip ACID #

ACID adalah akronim yang merangkum empat properti transaksi:

PropertiArtiImplikasi praktis
AtomicitySemua operasi atau tidak adaRollback bila satu operasi gagal
ConsistencyData tetap valid sebelum dan sesudahConstraint (FK, unique) tetap dipenuhi
IsolationTransaksi paralel tidak saling menggangguRead committed, repeatable read, dll.
DurabilityPerubahan yang di-commit permanenBertahan walau power off

Atomicity adalah properti yang paling sering dibahas karena langsung terlihat di kode. Saat method transfer() memindahkan uang dari akun A ke B, atomicity memastikan debit A dan kredit B terjadi bersama — bila kredit gagal, debit A di-rollback.

@Transactional Spring #

Spring menyediakan anotasi @Transactional yang membungkus method dalam transaksi database. Saat method dipanggil, Spring membuka koneksi, memulai transaksi, menjalankan method, dan commit bila tidak ada exception. Bila exception runtime terjadi, Spring rollback otomatis.

kotlin @Transactional
@Service
class TransferService(private val repo: AccountRepository) {
    @Transactional
    fun transfer(fromId: Int, toId: Int, amount: Int) {
        val from = repo.findById(fromId) ?: throw NotFoundException("Akun $fromId")
        val to = repo.findById(toId) ?: throw NotFoundException("Akun $toId")
        if (from.saldo < amount) throw IllegalArgumentException("Saldo tidak cukup")
        repo.save(from.copy(saldo = from.saldo - amount))
        repo.save(to.copy(saldo = to.saldo + amount))
    }
}

Spring memakai AOP proxy untuk mengimplementasikan @Transactional. Saat method dipanggil dari luar, proxy menangkap panggilan, membuka transaksi, dan menjalankan method asli. Bila method internal memanggil method @Transactional lain, proxy tidak aktif — self-invocation tidak memicu transaksi baru.

Isolation Level #

Isolation level menentukan seberapa paralel transaksi dapat melihat perubahan satu sama lain:

LevelDirty ReadNon-Repeatable ReadPhantom Read
READ_UNCOMMITTEDMungkinMungkinMungkin
READ_COMMITTEDTidakMungkinMungkin
REPEATABLE_READTidakTidakMungkin
SERIALIZABLETidakTidakTidak

Default Spring adalah ISOLATION_DEFAULT — mengikuti setting database. PostgreSQL default READ_COMMITTED, MySQL InnoDB default REPEATABLE_READ. Pilihan isolation level adalah tradeoff antara konsistensi dan performa.

Connection-per-Thread #

Spring memakai TransactionSynchronizationManager yang menyimpan koneksi database di ThreadLocal. Hal ini memastikan satu transaksi memakai satu koneksi — bila method @Transactional memanggil beberapa repository, semua memakai koneksi yang sama.

Demo Transaksi #

Kita akan mendemonstrasikan @Transactional lewat TransferService yang memindahkan saldo antar akun. Program menguji tiga skenario: transfer berhasil, transfer gagal di tengah (rollback), dan transfer dengan saldo tidak cukup. SQLite in-memory dipakai sebagai database — script kotlin_insert_output.py akan otomatis menyertakan sqlite-jdbc.jar via classpath.

kotlin
// database_transactions.kt
// Mendemonstrasikan @Transactional, isolation level, ACID, dan connection-per-
// thread. Pada kode produksi, Spring menyuntikkan proxy @Transactional. Di sini
// kita memakai JDBC + SQLite untuk menunjukkan commit/rollback secara nyata.

import java.sql.Connection
import java.sql.DriverManager

// Mock anotasi Spring
@Target(AnnotationTarget.FUNCTION) annotation class Transactional(val isolation: String = "READ_COMMITTED")

// Connection-per-thread: tiap thread memakai koneksi sendiri (di Spring via TransactionSynchronizationManager)
object ConnectionHolder {
    private val holder = ThreadLocal<Connection?>()
    private val keeper: Connection = DriverManager.getConnection("jdbc:sqlite:file::memory:?cache=shared").apply {
        // Schema shared via keeper connection
        createStatement().use { it.execute("CREATE TABLE IF NOT EXISTS akun (id INTEGER PRIMARY KEY, nama TEXT, saldo INTEGER)") }
    }
    fun get(): Connection {
        var conn = holder.get()
        if (conn == null || conn.isClosed) {
            conn = DriverManager.getConnection("jdbc:sqlite:file::memory:?cache=shared")
            holder.set(conn)
        }
        return conn
    }
    fun clear() { holder.get()?.takeIf { !it.isClosed }?.close(); holder.remove() }
    val keepAlive get() = keeper
}

// Service layer dengan @Transactional
class TransferService {
    @Transactional
    fun transfer(dariId: Int, keId: Int, jumlah: Int, fail: Boolean = false): String {
        val conn = ConnectionHolder.get()
        conn.autoCommit = false  // BEGIN TRANSACTION
        return try {
            // Cek saldo pengirim
            val saldoPengirim = conn.prepareStatement("SELECT saldo FROM akun WHERE id=?").use { ps ->
                ps.setInt(1, dariId); ps.executeQuery().use { rs -> if (rs.next()) rs.getInt(1) else -1 }
            }
            if (saldoPengirim < jumlah) {
                conn.rollback()
                return "Gagal: saldo pengirim ($saldoPengirim) < $jumlah"
            }
            // Debit
            conn.prepareStatement("UPDATE akun SET saldo=saldo-? WHERE id=?").use { ps ->
                ps.setInt(1, jumlah); ps.setInt(2, dariId); ps.executeUpdate()
            }
            // Force failure jika diminta
            if (fail) throw IllegalStateException("Simulasi gagal di tengah transfer")
            // Credit
            conn.prepareStatement("UPDATE akun SET saldo=saldo+? WHERE id=?").use { ps ->
                ps.setInt(1, jumlah); ps.setInt(2, keId); ps.executeUpdate()
            }
            conn.commit()
            "Sukses: $jumlah dipindah dari $dariId ke $keId"
        } catch (e: Exception) {
            conn.rollback()
            "Rollback: ${e.message}"
        }
    }
}

// Helper query saldo (read di luar transaksi)
fun bacaSaldo(id: Int): Int {
    val conn = ConnectionHolder.get()
    conn.autoCommit = true
    return conn.prepareStatement("SELECT saldo FROM akun WHERE id=?").use { ps ->
        ps.setInt(1, id); ps.executeQuery().use { rs -> if (rs.next()) rs.getInt(1) else -1 }
    }
}

fun main() {
    Class.forName("org.sqlite.JDBC")
    val holder = ConnectionHolder  // Init keeper

    val conn = ConnectionHolder.get()
    conn.autoCommit = false
    conn.prepareStatement("INSERT INTO akun(id,nama,saldo) VALUES(?,?,?)").use { ps ->
        ps.setInt(1, 1); ps.setString(2, "Budi"); ps.setInt(3, 1000); ps.executeUpdate()
        ps.setInt(1, 2); ps.setString(2, "Siti"); ps.setInt(3, 500); ps.executeUpdate()
        ps.setInt(1, 3); ps.setString(2, "Andi"); ps.setInt(3, 0); ps.executeUpdate()
    }
    conn.commit()
    conn.autoCommit = true
    ConnectionHolder.clear()

    println("=== Saldo awal ===")
    println("  Budi(1): ${bacaSaldo(1)}")
    println("  Siti(2): ${bacaSaldo(2)}")
    println("  Andi(3): ${bacaSaldo(3)}")
    ConnectionHolder.clear()

    println("\n=== ACID test: transfer Budi -> Siti (200) ===")
    val svc = TransferService()
    println(svc.transfer(1, 2, 200))
    ConnectionHolder.clear()
    println("  Budi(1): ${bacaSaldo(1)}, Siti(2): ${bacaSaldo(2)}")
    ConnectionHolder.clear()

    println("\n=== ACID test: transfer Siti -> Andi (fail di tengah, harus rollback) ===")
    println(svc.transfer(2, 3, 100, fail = true))
    ConnectionHolder.clear()
    println("  Siti(2): ${bacaSaldo(2)}, Andi(3): ${bacaSaldo(3)}")

    println("\n=== ACID test: transfer Andi(0) -> Budi (50), saldo tidak cukup ===")
    println(svc.transfer(3, 1, 50))
    ConnectionHolder.clear()
    println("  Andi(3): ${bacaSaldo(3)}, Budi(1): ${bacaSaldo(1)}")

    println("\n=== Isolation level — READ_COMMITTED default pada SQLite ===")
    println("  SQLite memakai isolation SERIALIZABLE secara default;")
    println("  Spring default mengikuti DB: di sini READ_COMMITTED dipetakan ke SERIALIZABLE.")

    println("\n=== Connection-per-thread ===")
    println("  Setiap transfer membuka koneksi baru via ThreadLocal;")
    println("  bersama cache=shared, semua koneksi melihat data yang sama.")
}

Output yang Dihasilkan:

text
=== Saldo awal ===
  Budi(1): 1000
  Siti(2): 500
  Andi(3): 0

=== ACID test: transfer Budi -> Siti (200) ===
Sukses: 200 dipindah dari 1 ke 2
  Budi(1): 800, Siti(2): 700

=== ACID test: transfer Siti -> Andi (fail di tengah, harus rollback) ===
Rollback: Simulasi gagal di tengah transfer
  Siti(2): 700, Andi(3): 0

=== ACID test: transfer Andi(0) -> Budi (50), saldo tidak cukup ===
Gagal: saldo pengirim (0) < 50
  Andi(3): 0, Budi(1): 800

=== Isolation level — READ_COMMITTED default pada SQLite ===
  SQLite memakai isolation SERIALIZABLE secara default;
  Spring default mengikuti DB: di sini READ_COMMITTED dipetakan ke SERIALIZABLE.

=== Connection-per-thread ===
  Setiap transfer membuka koneksi baru via ThreadLocal;
  bersama cache=shared, semua koneksi melihat data yang sama.

Mari kita bedah bagian penting kode di atas:

  1. object ConnectionHolder { private val holder = ThreadLocal<Connection?>() }: ThreadLocal menyimpan koneksi per-thread. Setiap thread memanggil transfer() memakai koneksi sendiri — tidak ada konflik antar transaksi paralel.
  2. private val keeper: Connection = DriverManager.getConnection(...): koneksi yang tetap terbuka. SQLite in-memory menghapus data ketika semua koneksi ditutup — keeper mencegah hal tersebut selama proses hidup.
  3. @Transactional fun transfer(dariId, keId, jumlah, fail): anotasi marker. Pada produksi, Spring AOP proxy menangkap panggilan method dan membungkusnya dalam try-catch-commit-rollback. Di sini kita lakukan manual dengan conn.autoCommit = false.
  4. if (fail) throw IllegalStateException("Simulasi gagal..."): simulasi kegagalan setelah debit tapi sebelum credit. Tanpa rollback, akun pengirim berkurang tapi penerima tidak bertambah — inkonsistensi. Dengan rollback, kedua perubahan dibatalkan.
  5. catch (e: Exception) { conn.rollback(); "Rollback: ${e.message}" }: rollback membatalkan seluruh perubahan sejak autoCommit = false. Pesan error memberi konteks apa yang gagal — pada produksi, log stack trace penuh ke logger.

Propagasi Transaksi #

Spring mendukung beberapa jenis propagasi:

  • REQUIRED (default): pakai transaksi existing, buat baru bila tidak ada.
  • REQUIRES_NEW: selalu buat transaksi baru, suspend existing.
  • NESTED: nested transaction via savepoint.
  • MANDATORY: wajib ada transaksi existing, lempar exception bila tidak ada.

Pilihan propagasi mempengaruhi bagaimana method @Transactional berinteraksi dengan transaksi pemanggil. Default REQUIRED cocok untuk sebagian besar kasus.

Referensi Lanjutan #

  • Spring Data JPA reference — rujukan resmi @Transactional Spring sebagai pola pengelolaan transaksi database
  • Ktor welcome — halaman Ktor untuk merancang lapisan transaksi eksplisit pada aplikasi coroutine-based

Selanjutnya #

Setelah transaksi dipahami, langkah berikutnya adalah menangani data besar. Chapter API Pagination dan Filtering akan membahas page/pageSize, cursor-based pagination, dan filter dinamis untuk endpoint list.