Mengimpor sepuluh ribu baris produk lewat JDBC dengan cara naif berarti sepuluh ribu pemanggilan executeUpdate — dan setiap pemanggilan adalah satu bolak-balik penuh ke server database. Jika satu bolak-balik memakan dua milidetik, dua puluh detik habis hanya untuk menunggu jaringan, sebelum database sempat bekerja. JDBC menyediakan jawabannya sejak versi awal: batch processing — kumpulkan pernyataan SQL dengan addBatch(), lalu kirim semuanya sekaligus dengan executeBatch(). Chapter ini mengukur manfaatnya dengan penghitung roundtrip, membahas penanganan batch yang gagal di tengah jalan, lalu merangkainya dengan transaksi agar aman dipakai di produksi.

Mahalnya Bolak-Balik Jaringan #

Biaya terbesar operasi database jarak jauh biasanya bukan eksekusi SQL-nya, melainkan roundtrip: paket permintaan berjalan ke server, server memproses, paket jawaban berjalan pulang. Latensi jaringan antar data center bisa 1-50 milidetik, dan angka itu dikalikan jumlah pemanggilan. Tabel berikut membandingkan strategi pengiriman 1.000 baris dengan latensi 2 ms:

StrategiRoundtripWaktu tunggu jaringan
executeUpdate satu per satu1.000±2.000 ms
executeBatch (1.000 per batch)1±2 ms
executeBatch (chunk 100)10±20 ms

Satu batch raksasa bukan selalu juara: driver perlu menyusun paket besar di memori dan database menahan banyak baris dalam satu transaksi. Praktik umumnya mengirim dalam potongan (chunk) 100 sampai 1.000 baris — angka yang bisa Anda ukur ulang untuk beban kerja Anda sendiri.

API Batch dan Kontraknya #

Peralatan batch tinggal di Statement dan PreparedStatement:

MethodPeran
addBatch(sql) / addBatch()menambah pernyataan (atau set parameter) ke antrian
executeBatch()mengirim seluruh antrian sekali jalan
clearBatch()mengosongkan antrian tanpa mengeksekusi

executeBatch() mengembalikan int[] — satu angka per pernyataan, sama maknanya dengan hasil executeUpdate: 1 untuk satu INSERT, jumlah baris untuk UPDATE/DELETE, 0 untuk DDL. Kegagalan di tengah batch dilaporkan sebagai BatchUpdateException, turunan SQLException yang membawa getUpdateCounts() — potongan hasil per pernyataan yang sempat selesai sebelum kegagalan. Nilai negatif (Statement.EXECUTE_FAILED) menandai pernyataan yang gagal pada driver yang memilih melanjutkan; array yang lebih pendek dari antrian menandai driver yang berhenti di kegagalan pertama.

Demo Penghitung Roundtrip #

Program berikut membuktikan mekanismenya dengan MiniDb — database in-memory yang menghitung setiap “bolak-balik jaringan”. Skenario pertama mengirim empat INSERT satu per satu; skenario kedua mengirim empat INSERT yang sama lewat batch; skenario ketiga menabrakkan batch ke primary key yang sudah ada, membaca getUpdateCounts(), lalu memulihkan tabel lewat potret (padanan rollback):

java BatchUpdate.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class BatchUpdate {

    public static void main(String[] args) {
        MiniDb db = new MiniDb();

        System.out.println("== Mode satu-per-satu: 4 kali executeUpdate ==");
        MiniStatement st = db.createStatement();
        st.executeUpdate("INSERT INTO pesan VALUES (1, 'Halo Budi')");
        st.executeUpdate("INSERT INTO pesan VALUES (2, 'Stok tersedia')");
        st.executeUpdate("INSERT INTO pesan VALUES (3, 'Pesanan dikirim')");
        st.executeUpdate("INSERT INTO pesan VALUES (4, 'Terima kasih')");
        System.out.println("roundtrip : " + db.getRoundtrip());
        System.out.println("baris     : " + db.getJumlahBaris());

        db.reset();
        System.out.println();
        System.out.println("== Mode batch: addBatch + executeBatch ==");
        st = db.createStatement();
        st.addBatch("INSERT INTO pesan VALUES (1, 'Halo Budi')");
        st.addBatch("INSERT INTO pesan VALUES (2, 'Stok tersedia')");
        st.addBatch("INSERT INTO pesan VALUES (3, 'Pesanan dikirim')");
        st.addBatch("INSERT INTO pesan VALUES (4, 'Terima kasih')");
        try {
            int[] hasil = st.executeBatch();
            System.out.println("hasil     : " + Arrays.toString(hasil));
        } catch (BatchGagalException e) {
            System.out.println("tidak terjadi di skenario ini: " + e.getMessage());
        }
        System.out.println("roundtrip : " + db.getRoundtrip());
        System.out.println("baris     : " + db.getJumlahBaris());

        System.out.println();
        System.out.println("== Batch dengan kegagalan + rollback ==");
        List<String[]> potret = db.snapshot();
        st = db.createStatement();
        st.addBatch("INSERT INTO pesan VALUES (5, 'Promo diskon')");
        st.addBatch("INSERT INTO pesan VALUES (1, 'Duplikat')");
        st.addBatch("INSERT INTO pesan VALUES (6, 'Ulasan produk')");
        try {
            st.executeBatch();
        } catch (BatchGagalException e) {
            System.out.println("gagal     : " + e.getMessage());
            System.out.println("getUpdateCounts : " + Arrays.toString(e.getUpdateCounts()));
            db.pulihkan(potret);
            System.out.println("[db] ROLLBACK: tabel dipulihkan ke potret awal");
        }
        System.out.println("baris     : " + db.getJumlahBaris());
    }
}

/** Database mini dengan penghitung roundtrip jaringan. */
class MiniDb {

    private final List<String[]> tabel = new ArrayList<>();
    private int roundtrip = 0;

    MiniStatement createStatement() {
        return new MiniStatement(this);
    }

    int getRoundtrip() {
        return roundtrip;
    }

    int getJumlahBaris() {
        return tabel.size();
    }

    void reset() {
        tabel.clear();
        roundtrip = 0;
    }

    List<String[]> snapshot() {
        return new ArrayList<>(tabel);
    }

    void pulihkan(List<String[]> potret) {
        tabel.clear();
        tabel.addAll(potret);
    }

    void catatRoundtrip() {
        roundtrip++;
    }

    int terapkan(String sql) throws KunciDuplikatException {
        Matcher m = Pattern
                .compile("INSERT INTO pesan VALUES \\((\\d+), '([^']+)'\\)")
                .matcher(sql);
        if (!m.matches()) {
            throw new IllegalArgumentException("SQL tidak dikenal: " + sql);
        }
        int id = Integer.parseInt(m.group(1));
        for (String[] baris : tabel) {
            if (Integer.parseInt(baris[0]) == id) {
                throw new KunciDuplikatException("primary key duplikat: id=" + id);
            }
        }
        tabel.add(new String[]{m.group(1), m.group(2)});
        System.out.println("[db] INSERT (" + id + ", '" + m.group(2) + "')");
        return 1;
    }
}

/** Statement mini dengan antrian batch. */
class MiniStatement {

    private final MiniDb db;
    private final List<String> antrian = new ArrayList<>();

    MiniStatement(MiniDb db) {
        this.db = db;
    }

    void addBatch(String sql) {
        antrian.add(sql);
    }

    void clearBatch() {
        antrian.clear();
    }

    int executeUpdate(String sql) {
        db.catatRoundtrip();                 // satu bolak-balik per pernyataan
        try {
            return db.terapkan(sql);
        } catch (KunciDuplikatException e) {
            throw new IllegalStateException(e.getMessage());
        }
    }

    int[] executeBatch() throws BatchGagalException {
        db.catatRoundtrip();                 // SELURUH antrian dalam satu bolak-balik
        System.out.println("[db] BATCH: " + antrian.size()
                + " pernyataan dikirim dalam 1 roundtrip");
        int[] hasil = new int[antrian.size()];
        for (int i = 0; i < antrian.size(); i++) {
            try {
                hasil[i] = db.terapkan(antrian.get(i));
            } catch (KunciDuplikatException e) {
                throw new BatchGagalException(
                        Arrays.copyOf(hasil, i), antrian.size(), e.getMessage());
            }
        }
        antrian.clear();
        return hasil;
    }
}

class KunciDuplikatException extends Exception {

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

/** Padanan BatchUpdateException: membawa jumlah baris per pernyataan sukses. */
class BatchGagalException extends Exception {

    private final int[] updateCounts;

    BatchGagalException(int[] updateCounts, int total, String penyebab) {
        super(penyebab + " (" + updateCounts.length + " dari " + total
                + " pernyataan selesai sebelum gagal)");
        this.updateCounts = updateCounts;
    }

    int[] getUpdateCounts() {
        return updateCounts;
    }
}

Output yang Dihasilkan:

text
== Mode satu-per-satu: 4 kali executeUpdate ==
[db] INSERT (1, 'Halo Budi')
[db] INSERT (2, 'Stok tersedia')
[db] INSERT (3, 'Pesanan dikirim')
[db] INSERT (4, 'Terima kasih')
roundtrip : 4
baris     : 4

== Mode batch: addBatch + executeBatch ==
[db] BATCH: 4 pernyataan dikirim dalam 1 roundtrip
[db] INSERT (1, 'Halo Budi')
[db] INSERT (2, 'Stok tersedia')
[db] INSERT (3, 'Pesanan dikirim')
[db] INSERT (4, 'Terima kasih')
hasil     : [1, 1, 1, 1]
roundtrip : 1
baris     : 4

== Batch dengan kegagalan + rollback ==
[db] BATCH: 3 pernyataan dikirim dalam 1 roundtrip
[db] INSERT (5, 'Promo diskon')
gagal     : primary key duplikat: id=1 (1 dari 3 pernyataan selesai sebelum gagal)
getUpdateCounts : [1]
[db] ROLLBACK: tabel dipulihkan ke potret awal
baris     : 4

Mari kita telaah bukti pada output di atas:

  1. roundtrip : 4 versus roundtrip : 1: empat INSERT yang sama menghasilkan empat baris tabel pada kedua mode — hasil akhirnya identik — tetapi mode batch hanya membayar satu bolak-balik. Pada data nyata, selisih ini berlipat sesuai jumlah baris.
  2. hasil : [1, 1, 1, 1]: array hasil executeBatch memuat satu entri per pernyataan dalam urutan penambahan addBatch. Inilah alasan addBatch memakai antrian: urutan harus terpelihara.
  3. Skenario ketiga berhenti di pernyataan kedua: INSERT (5, 'Promo diskon') sempat masuk tabel, lalu id=1 menabrak primary key. getUpdateCounts() melaporkan [1] — satu pernyataan selesai — persis perilaku BatchUpdateException pada driver yang berhenti di kegagalan pertama.
  4. db.pulihkan(potret) mengembalikan tabel ke empat baris: tanpa pemulihan, baris hasil batch yang sempat masuk akan tertinggal setengah jalan. Padanan JDBC-nya adalah rollback() — dan di situlah transaksi dari chapter sebelumnya bersatu dengan batch.

Batch pada PreparedStatement #

Bentuk paling produktif dari batch justru di PreparedStatement: SQL template sama, parameter berbeda — kombinasi yang memungkinkan database memakai ulang rencana eksekusi. Pola lengkapnya dengan SQLite (ditampilkan sebagai contoh karena driver tidak terpasang di lingkungan uji):

java Batch INSERT dengan PreparedStatement (butuh database)
String sql = "INSERT INTO produk (nama, harga) VALUES (?, ?)";
try (Connection conn = DriverManager.getConnection("jdbc:sqlite:toko.db");
     PreparedStatement ps = conn.prepareStatement(sql)) {

    conn.setAutoCommit(false);
    for (Produk p : daftarProduk) {
        ps.setString(1, p.nama());
        ps.setBigDecimal(2, p.harga());
        ps.addBatch();                       // ikat parameter, antre eksekusi
    }
    int[] hasil = ps.executeBatch();
    conn.commit();

    int total = 0;
    for (int h : hasil) {
        total += Math.max(h, 0);            // nilai negatif = pernyataan gagal
    }
    System.out.println("Baris masuk: " + total);
}

Kini impor besar kita cepat dan aman. Ada satu pertanyaan yang sering menyusul: baris yang baru saja kita masukkan mendapat id otomatis dari database — bagaimana cara mengetahuinya tanpa kueri tambahan? Chapter berikutnya membahas RETURN_GENERATED_KEYS dan getGeneratedKeys().

Referensi #

Berkas Sumber

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

BatchUpdate.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class BatchUpdate {

    public static void main(String[] args) {
        MiniDb db = new MiniDb();

        System.out.println("== Mode satu-per-satu: 4 kali executeUpdate ==");
        MiniStatement st = db.createStatement();
        st.executeUpdate("INSERT INTO pesan VALUES (1, 'Halo Budi')");
        st.executeUpdate("INSERT INTO pesan VALUES (2, 'Stok tersedia')");
        st.executeUpdate("INSERT INTO pesan VALUES (3, 'Pesanan dikirim')");
        st.executeUpdate("INSERT INTO pesan VALUES (4, 'Terima kasih')");
        System.out.println("roundtrip : " + db.getRoundtrip());
        System.out.println("baris     : " + db.getJumlahBaris());

        db.reset();
        System.out.println();
        System.out.println("== Mode batch: addBatch + executeBatch ==");
        st = db.createStatement();
        st.addBatch("INSERT INTO pesan VALUES (1, 'Halo Budi')");
        st.addBatch("INSERT INTO pesan VALUES (2, 'Stok tersedia')");
        st.addBatch("INSERT INTO pesan VALUES (3, 'Pesanan dikirim')");
        st.addBatch("INSERT INTO pesan VALUES (4, 'Terima kasih')");
        try {
            int[] hasil = st.executeBatch();
            System.out.println("hasil     : " + Arrays.toString(hasil));
        } catch (BatchGagalException e) {
            System.out.println("tidak terjadi di skenario ini: " + e.getMessage());
        }
        System.out.println("roundtrip : " + db.getRoundtrip());
        System.out.println("baris     : " + db.getJumlahBaris());

        System.out.println();
        System.out.println("== Batch dengan kegagalan + rollback ==");
        List<String[]> potret = db.snapshot();
        st = db.createStatement();
        st.addBatch("INSERT INTO pesan VALUES (5, 'Promo diskon')");
        st.addBatch("INSERT INTO pesan VALUES (1, 'Duplikat')");
        st.addBatch("INSERT INTO pesan VALUES (6, 'Ulasan produk')");
        try {
            st.executeBatch();
        } catch (BatchGagalException e) {
            System.out.println("gagal     : " + e.getMessage());
            System.out.println("getUpdateCounts : " + Arrays.toString(e.getUpdateCounts()));
            db.pulihkan(potret);
            System.out.println("[db] ROLLBACK: tabel dipulihkan ke potret awal");
        }
        System.out.println("baris     : " + db.getJumlahBaris());
    }
}

/** Database mini dengan penghitung roundtrip jaringan. */
class MiniDb {

    private final List<String[]> tabel = new ArrayList<>();
    private int roundtrip = 0;

    MiniStatement createStatement() {
        return new MiniStatement(this);
    }

    int getRoundtrip() {
        return roundtrip;
    }

    int getJumlahBaris() {
        return tabel.size();
    }

    void reset() {
        tabel.clear();
        roundtrip = 0;
    }

    List<String[]> snapshot() {
        return new ArrayList<>(tabel);
    }

    void pulihkan(List<String[]> potret) {
        tabel.clear();
        tabel.addAll(potret);
    }

    void catatRoundtrip() {
        roundtrip++;
    }

    int terapkan(String sql) throws KunciDuplikatException {
        Matcher m = Pattern
                .compile("INSERT INTO pesan VALUES \\((\\d+), '([^']+)'\\)")
                .matcher(sql);
        if (!m.matches()) {
            throw new IllegalArgumentException("SQL tidak dikenal: " + sql);
        }
        int id = Integer.parseInt(m.group(1));
        for (String[] baris : tabel) {
            if (Integer.parseInt(baris[0]) == id) {
                throw new KunciDuplikatException("primary key duplikat: id=" + id);
            }
        }
        tabel.add(new String[]{m.group(1), m.group(2)});
        System.out.println("[db] INSERT (" + id + ", '" + m.group(2) + "')");
        return 1;
    }
}

/** Statement mini dengan antrian batch. */
class MiniStatement {

    private final MiniDb db;
    private final List<String> antrian = new ArrayList<>();

    MiniStatement(MiniDb db) {
        this.db = db;
    }

    void addBatch(String sql) {
        antrian.add(sql);
    }

    void clearBatch() {
        antrian.clear();
    }

    int executeUpdate(String sql) {
        db.catatRoundtrip();                 // satu bolak-balik per pernyataan
        try {
            return db.terapkan(sql);
        } catch (KunciDuplikatException e) {
            throw new IllegalStateException(e.getMessage());
        }
    }

    int[] executeBatch() throws BatchGagalException {
        db.catatRoundtrip();                 // SELURUH antrian dalam satu bolak-balik
        System.out.println("[db] BATCH: " + antrian.size()
                + " pernyataan dikirim dalam 1 roundtrip");
        int[] hasil = new int[antrian.size()];
        for (int i = 0; i < antrian.size(); i++) {
            try {
                hasil[i] = db.terapkan(antrian.get(i));
            } catch (KunciDuplikatException e) {
                throw new BatchGagalException(
                        Arrays.copyOf(hasil, i), antrian.size(), e.getMessage());
            }
        }
        antrian.clear();
        return hasil;
    }
}

class KunciDuplikatException extends Exception {

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

/** Padanan BatchUpdateException: membawa jumlah baris per pernyataan sukses. */
class BatchGagalException extends Exception {

    private final int[] updateCounts;

    BatchGagalException(int[] updateCounts, int total, String penyebab) {
        super(penyebab + " (" + updateCounts.length + " dari " + total
                + " pernyataan selesai sebelum gagal)");
        this.updateCounts = updateCounts;
    }

    int[] getUpdateCounts() {
        return updateCounts;
    }
}