Transfer uang dari rekening Budi ke rekening Siti adalah dua pernyataan UPDATE: saldo Budi dikurangi, saldo Siti ditambah. Kalau program mati di antara keduanya — listrik terputus, koneksi jaringan terputus, exception tak tertangani — uang telah keluar dari Budi tetapi tidak pernah tiba di Siti. Transaksi adalah jawaban database untuk masalah ini: sekelompok operasi dijanjikan berlaku seluruhnya atau tidak sama sekali. JDBC mengendalikan janji itu lewat setAutoCommit(false), commit(), dan rollback() — tiga method yang dibedah tuntas pada chapter ini, lengkap dengan Savepoint untuk pembatalan sebagian.

Masalah Operasi Setengah Jalan #

Secara default, JDBC berjalan dalam mode auto-commit: setiap executeUpdate langsung menjadi permanen begitu selesai, seolah-olah tiap pernyataan SQL adalah transaksinya sendiri. Mode ini nyaman untuk skrip sederhana tetapi berbahaya untuk logika yang berjalan bertahap. Tabel berikut memperlihatkan dua nasib transfer yang sama ketika langkah kedua gagal:

| Kejadian | Mode auto-commit | Mode transaksi | |—|—| | UPDATE debit Budi berhasil | langsung permanen | baru tersimpan sementara | | UPDATE kredit Siti gagal | Budi sudah kehilangan uang | seluruh perubahan dibatalkan | | Hasil akhir | data tidak konsisten | seperti tidak pernah dimulai |

Mekanisme di balik layarnya berpusat pada transaction log milik database engine: dalam mode transaksi, perubahan ditulis ke log dan buffer internal dulu, database lain belum melihatnya, dan COMMIT adalah perintah menjadikan semuanya permanen sementara ROLLBACK memutar ulang log hingga titik awal.

commit, rollback, dan Savepoint #

Ketiga method pengendali hidup di Connection. setAutoCommit(false) menandai awal transaksi dan menahan setiap perubahan. commit() menempelkan seluruh perubahan sejak awal transaksi. rollback() membuang semuanya kembali ke keadaan awal. Di antara keduanya ada setSavepoint(String): penanda bernama di tengah transaksi yang bisa dituju rollback(sp) — membatalkan hanya perubahan setelah penanda tersebut sambil membiarkan transaksi tetap terbuka. Savepoint berguna untuk operasi panjang yang sebagian kegagalannya bisa ditoleransi, misalnya impor seribu baris yang melewati baris bermasalah tanpa membuang sisanya.

Jaminan ACID #

Istilah ACID merangkum empat jaminan yang dibawa transaksi:

HurufNamaJaminan
AAtomicityseluruh operasi terjadi, atau tidak satu pun
CConsistencybatasan (PK, FK, tipe) tetap terpenuhi setelah transaksi
IIsolationtransaksi paralel tidak saling membaca kerja setengah jadi
DDurabilityhasil COMMIT bertahan meski sistem mati mendadak

Kedalaman Isolation bisa diatur lewat setTransactionIsolation — dari READ_COMMITTED (paling lazim dipakai) hingga SERIALIZABLE (paling ketat, paling mahal). Untuk sebagian besar aplikasi, tingkat default dari database sudah tepat; yang tidak boleh dinegosiasi adalah pola commit/rollback-nya.

Simulasi Transfer dengan Mesin Keadaan #

Tanpa server database, kita bisa membuktikan semantik transaksi lewat mesin keadaan sederhana: class BankMini menyimpan saldo “permanen”, sedangkan TransaksiMini bekerja di atas salinan — commit() menempelkan salinan ke saldo, rollback() membuangnya, dan savepoint menyimpan potret salinan di tengah jalan. Alur ini meniru penuh cara kerja log transaksi:

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

public class TransferBank {

    public static void main(String[] args) {
        BankMini bank = new BankMini();
        bank.tambahRekening("Budi", 500_000L);
        bank.tambahRekening("Siti", 300_000L);
        bank.tambahRekening("Dewi", 200_000L);
        bank.cetakSaldo("Saldo awal");

        System.out.println();
        System.out.println("== Skenario 1: transfer sukses, diakhiri commit ==");
        TransaksiMini tx1 = bank.beginTransaction();
        try {
            tx1.debit("Budi", 150_000L);
            tx1.kredit("Siti", 150_000L);
            tx1.commit();
        } catch (SaldoKurangException e) {
            tx1.rollback();
        }
        bank.cetakSaldo("Setelah transfer Budi -> Siti 150000");

        System.out.println();
        System.out.println("== Skenario 2: transfer gagal, diakhiri rollback ==");
        TransaksiMini tx2 = bank.beginTransaction();
        try {
            tx2.debit("Siti", 1_000_000L);   // saldo tidak cukup -> exception
            tx2.kredit("Budi", 1_000_000L);  // tidak pernah tercapai
            tx2.commit();                    // tidak pernah tercapai
        } catch (SaldoKurangException e) {
            System.out.println("gagal   : " + e.getMessage());
            tx2.rollback();
        }
        bank.cetakSaldo("Setelah transfer Siti -> Budi 1000000 gagal");

        System.out.println();
        System.out.println("== Skenario 3: rollback sebagian lewat savepoint ==");
        TransaksiMini tx3 = bank.beginTransaction();
        try {
            tx3.kredit("Budi", 50_000L);
            tx3.setSavepoint("setelah-topup-budi");
            tx3.kredit("Siti", 25_000L);
            tx3.debit("Dewi", 999_999L);     // gagal di sini
            tx3.commit();
        } catch (SaldoKurangException e) {
            System.out.println("gagal   : " + e.getMessage());
            tx3.rollback("setelah-topup-budi"); // batalkan hanya setelah savepoint
            tx3.commit();                        // terapkan sisanya
        }
        bank.cetakSaldo("Setelah skenario savepoint");
    }
}

/** Database mini: saldo rekening yang "persisten". */
class BankMini {

    private final Map<String, Long> saldo = new LinkedHashMap<>();

    void tambahRekening(String nama, long nilaiAwal) {
        saldo.put(nama, nilaiAwal);
    }

    TransaksiMini beginTransaction() {
        System.out.println("[tx] BEGIN (auto-commit dimatikan)");
        return new TransaksiMini(this);
    }

    Map<String, Long> snapshot() {
        return new HashMap<>(saldo);
    }

    void terapkan(Map<String, Long> kerja) {
        saldo.putAll(kerja);
    }

    void cetakSaldo(String judul) {
        System.out.println(judul);
        for (Map.Entry<String, Long> e : saldo.entrySet()) {
            System.out.printf("  %-5s : Rp%d%n", e.getKey(), e.getValue());
        }
    }
}

/** Transaksi mini: kerja di salinan, commit menempelkan, rollback membuang. */
class TransaksiMini {

    private final BankMini bank;
    private final Map<String, Long> kerja;
    private final Map<String, Map<String, Long>> savepoint = new HashMap<>();

    TransaksiMini(BankMini bank) {
        this.bank = bank;
        this.kerja = bank.snapshot();
    }

    void debit(String rekening, long jumlah) throws SaldoKurangException {
        long s = kerja.get(rekening);
        if (s < jumlah) {
            throw new SaldoKurangException(
                    "saldo " + rekening + " tidak cukup: " + s + " < " + jumlah);
        }
        kerja.put(rekening, s - jumlah);
        System.out.printf("[tx] DEBIT  %-5s -%d%n", rekening, jumlah);
    }

    void kredit(String rekening, long jumlah) {
        long s = kerja.get(rekening);
        kerja.put(rekening, s + jumlah);
        System.out.printf("[tx] KREDIT %-5s +%d%n", rekening, jumlah);
    }

    void setSavepoint(String nama) {
        savepoint.put(nama, new HashMap<>(kerja));
        System.out.println("[tx] SAVEPOINT " + nama);
    }

    void rollback() {
        kerja.putAll(bank.snapshot());
        System.out.println("[tx] ROLLBACK (seluruh perubahan dibatalkan)");
    }

    void rollback(String namaSavepoint) {
        Map<String, Long> keadaan = savepoint.get(namaSavepoint);
        if (keadaan == null) {
            throw new IllegalArgumentException("savepoint tidak ada: " + namaSavepoint);
        }
        kerja.putAll(keadaan);
        System.out.println("[tx] ROLLBACK TO SAVEPOINT " + namaSavepoint);
    }

    void commit() {
        bank.terapkan(kerja);
        System.out.println("[tx] COMMIT (perubahan menjadi permanen)");
    }
}

class SaldoKurangException extends Exception {

    SaldoKurangException(String pesan) {
        super(pesan);
    }
}

Output yang Dihasilkan:

text
Saldo awal
  Budi  : Rp500000
  Siti  : Rp300000
  Dewi  : Rp200000

== Skenario 1: transfer sukses, diakhiri commit ==
[tx] BEGIN (auto-commit dimatikan)
[tx] DEBIT  Budi  -150000
[tx] KREDIT Siti  +150000
[tx] COMMIT (perubahan menjadi permanen)
Setelah transfer Budi -> Siti 150000
  Budi  : Rp350000
  Siti  : Rp450000
  Dewi  : Rp200000

== Skenario 2: transfer gagal, diakhiri rollback ==
[tx] BEGIN (auto-commit dimatikan)
gagal   : saldo Siti tidak cukup: 450000 < 1000000
[tx] ROLLBACK (seluruh perubahan dibatalkan)
Setelah transfer Siti -> Budi 1000000 gagal
  Budi  : Rp350000
  Siti  : Rp450000
  Dewi  : Rp200000

== Skenario 3: rollback sebagian lewat savepoint ==
[tx] BEGIN (auto-commit dimatikan)
[tx] KREDIT Budi  +50000
[tx] SAVEPOINT setelah-topup-budi
[tx] KREDIT Siti  +25000
gagal   : saldo Dewi tidak cukup: 200000 < 999999
[tx] ROLLBACK TO SAVEPOINT setelah-topup-budi
[tx] COMMIT (perubahan menjadi permanen)
Setelah skenario savepoint
  Budi  : Rp400000
  Siti  : Rp450000
  Dewi  : Rp200000

Mari kita telaah bukti penting pada output di atas:

  1. Skenario 1: DEBIT Budi -150000 dan KREDIT Siti +150000 terjadi di salinan kerja, lalu COMMIT menempelkannya — saldo Budi turun ke 350000, saldo Siti naik ke 450000. Inilah jalur transaksi yang sehat.
  2. Skenario 2, saldo tidak berubah sama sekali: debit melempar SaldoKurangException sebelum satu rupiah pun menyentuh salinan, blok catch memanggil rollback(), dan saldo cetakan berikutnya identik dengan cetakan sebelumnya. Kita sengaja menaruh kredit dan commit setelah baris yang gagal — keduanya memang tak boleh tercapai, dan output membuktikannya.
  3. Skenario 3, rollback sebagian: KREDIT Budi +50000 sudah masuk salinan ketika setSavepoint("setelah-topup-budi") menyimpan potret salinan. Setelah KREDIT Siti +25000 dan kegagalan pada debit Dewi, rollback("setelah-topup-budi") mengembalikan salinan ke potret itu — top-up Budi selamat, top-up Siti dibatalkan — dan commit() berikutnya hanya menerapkan bagian yang tersisa.
  4. bank.snapshot() di konstruktor TransaksiMini: transaksi selalu mulai dari salinan keadaan saat BEGIN, bukan keadaan saat commit — alasan mengapa paralelisme dan urutan BEGIN berpengaruh pada hasil di database nyata.

Pola pada JDBC Sungguhan #

Bentuk kanoniknya di JDBC mengikuti arsitektur yang sama, dengan Connection sebagai pemegang kendali (ditampilkan sebagai contoh karena membutuhkan database dan driver yang tidak terpasang di lingkungan uji kita):

java Transfer klasik dengan JDBC (butuh database aktif)
try (Connection conn = DriverManager.getConnection(url)) {
    conn.setAutoCommit(false);              // mulai transaksi
    try (PreparedStatement debit = conn.prepareStatement(
                 "UPDATE rekening SET saldo = saldo - ? WHERE no_rek = ?");
         PreparedStatement kredit = conn.prepareStatement(
                 "UPDATE rekening SET saldo = saldo + ? WHERE no_rek = ?")) {

        debit.setLong(1, 150_000L);
        debit.setString(2, "REK-BUDI");
        debit.executeUpdate();

        kredit.setLong(1, 150_000L);
        kredit.setString(2, "REK-SITI");
        kredit.executeUpdate();

        conn.commit();                      // keduanya menjadi permanen
    } catch (SQLException e) {
        conn.rollback();                    // keduanya dibatalkan
        throw e;                            // laporkan ke pemanggil
    } finally {
        conn.setAutoCommit(true);           // pulihkan mode untuk koneksi berikutnya
    }
}

Satu transaksi dengan dua UPDATE sudah kita kuasai. Bagaimana dengan seribu INSERT? Mengirimnya satu per satu berarti seribu kali bolak-balik jaringan — chapter berikutnya merakitnya menjadi satu batch dengan addBatch() dan executeBatch().

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

TransferBank.java

Unduh berkas
java
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

public class TransferBank {

    public static void main(String[] args) {
        BankMini bank = new BankMini();
        bank.tambahRekening("Budi", 500_000L);
        bank.tambahRekening("Siti", 300_000L);
        bank.tambahRekening("Dewi", 200_000L);
        bank.cetakSaldo("Saldo awal");

        System.out.println();
        System.out.println("== Skenario 1: transfer sukses, diakhiri commit ==");
        TransaksiMini tx1 = bank.beginTransaction();
        try {
            tx1.debit("Budi", 150_000L);
            tx1.kredit("Siti", 150_000L);
            tx1.commit();
        } catch (SaldoKurangException e) {
            tx1.rollback();
        }
        bank.cetakSaldo("Setelah transfer Budi -> Siti 150000");

        System.out.println();
        System.out.println("== Skenario 2: transfer gagal, diakhiri rollback ==");
        TransaksiMini tx2 = bank.beginTransaction();
        try {
            tx2.debit("Siti", 1_000_000L);   // saldo tidak cukup -> exception
            tx2.kredit("Budi", 1_000_000L);  // tidak pernah tercapai
            tx2.commit();                    // tidak pernah tercapai
        } catch (SaldoKurangException e) {
            System.out.println("gagal   : " + e.getMessage());
            tx2.rollback();
        }
        bank.cetakSaldo("Setelah transfer Siti -> Budi 1000000 gagal");

        System.out.println();
        System.out.println("== Skenario 3: rollback sebagian lewat savepoint ==");
        TransaksiMini tx3 = bank.beginTransaction();
        try {
            tx3.kredit("Budi", 50_000L);
            tx3.setSavepoint("setelah-topup-budi");
            tx3.kredit("Siti", 25_000L);
            tx3.debit("Dewi", 999_999L);     // gagal di sini
            tx3.commit();
        } catch (SaldoKurangException e) {
            System.out.println("gagal   : " + e.getMessage());
            tx3.rollback("setelah-topup-budi"); // batalkan hanya setelah savepoint
            tx3.commit();                        // terapkan sisanya
        }
        bank.cetakSaldo("Setelah skenario savepoint");
    }
}

/** Database mini: saldo rekening yang "persisten". */
class BankMini {

    private final Map<String, Long> saldo = new LinkedHashMap<>();

    void tambahRekening(String nama, long nilaiAwal) {
        saldo.put(nama, nilaiAwal);
    }

    TransaksiMini beginTransaction() {
        System.out.println("[tx] BEGIN (auto-commit dimatikan)");
        return new TransaksiMini(this);
    }

    Map<String, Long> snapshot() {
        return new HashMap<>(saldo);
    }

    void terapkan(Map<String, Long> kerja) {
        saldo.putAll(kerja);
    }

    void cetakSaldo(String judul) {
        System.out.println(judul);
        for (Map.Entry<String, Long> e : saldo.entrySet()) {
            System.out.printf("  %-5s : Rp%d%n", e.getKey(), e.getValue());
        }
    }
}

/** Transaksi mini: kerja di salinan, commit menempelkan, rollback membuang. */
class TransaksiMini {

    private final BankMini bank;
    private final Map<String, Long> kerja;
    private final Map<String, Map<String, Long>> savepoint = new HashMap<>();

    TransaksiMini(BankMini bank) {
        this.bank = bank;
        this.kerja = bank.snapshot();
    }

    void debit(String rekening, long jumlah) throws SaldoKurangException {
        long s = kerja.get(rekening);
        if (s < jumlah) {
            throw new SaldoKurangException(
                    "saldo " + rekening + " tidak cukup: " + s + " < " + jumlah);
        }
        kerja.put(rekening, s - jumlah);
        System.out.printf("[tx] DEBIT  %-5s -%d%n", rekening, jumlah);
    }

    void kredit(String rekening, long jumlah) {
        long s = kerja.get(rekening);
        kerja.put(rekening, s + jumlah);
        System.out.printf("[tx] KREDIT %-5s +%d%n", rekening, jumlah);
    }

    void setSavepoint(String nama) {
        savepoint.put(nama, new HashMap<>(kerja));
        System.out.println("[tx] SAVEPOINT " + nama);
    }

    void rollback() {
        kerja.putAll(bank.snapshot());
        System.out.println("[tx] ROLLBACK (seluruh perubahan dibatalkan)");
    }

    void rollback(String namaSavepoint) {
        Map<String, Long> keadaan = savepoint.get(namaSavepoint);
        if (keadaan == null) {
            throw new IllegalArgumentException("savepoint tidak ada: " + namaSavepoint);
        }
        kerja.putAll(keadaan);
        System.out.println("[tx] ROLLBACK TO SAVEPOINT " + namaSavepoint);
    }

    void commit() {
        bank.terapkan(kerja);
        System.out.println("[tx] COMMIT (perubahan menjadi permanen)");
    }
}

class SaldoKurangException extends Exception {

    SaldoKurangException(String pesan) {
        super(pesan);
    }
}