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:
| Properti | Arti | Implikasi praktis |
|---|---|---|
| Atomicity | Semua operasi atau tidak ada | Rollback bila satu operasi gagal |
| Consistency | Data tetap valid sebelum dan sesudah | Constraint (FK, unique) tetap dipenuhi |
| Isolation | Transaksi paralel tidak saling mengganggu | Read committed, repeatable read, dll. |
| Durability | Perubahan yang di-commit permanen | Bertahan 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.
@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:
| Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ_UNCOMMITTED | Mungkin | Mungkin | Mungkin |
| READ_COMMITTED | Tidak | Mungkin | Mungkin |
| REPEATABLE_READ | Tidak | Tidak | Mungkin |
| SERIALIZABLE | Tidak | Tidak | Tidak |
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.
// 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:
=== 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:
object ConnectionHolder { private val holder = ThreadLocal<Connection?>() }: ThreadLocal menyimpan koneksi per-thread. Setiap thread memanggiltransfer()memakai koneksi sendiri — tidak ada konflik antar transaksi paralel.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.@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 denganconn.autoCommit = false.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.catch (e: Exception) { conn.rollback(); "Rollback: ${e.message}" }: rollback membatalkan seluruh perubahan sejakautoCommit = 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
@TransactionalSpring 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.