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:
| Strategi | Roundtrip | Waktu tunggu jaringan |
|---|---|---|
executeUpdate satu per satu | 1.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:
| Method | Peran |
|---|---|
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):
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:
== 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 : 4Mari kita telaah bukti pada output di atas:
roundtrip : 4versusroundtrip : 1: empatINSERTyang 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.hasil : [1, 1, 1, 1]: array hasilexecuteBatchmemuat satu entri per pernyataan dalam urutan penambahanaddBatch. Inilah alasanaddBatchmemakai antrian: urutan harus terpelihara.- Skenario ketiga berhenti di pernyataan kedua:
INSERT (5, 'Promo diskon')sempat masuk tabel, laluid=1menabrak primary key.getUpdateCounts()melaporkan[1]— satu pernyataan selesai — persis perilakuBatchUpdateExceptionpada driver yang berhenti di kegagalan pertama. db.pulihkan(potret)mengembalikan tabel ke empat baris: tanpa pemulihan, baris hasil batch yang sempat masuk akan tertinggal setengah jalan. Padanan JDBC-nya adalahrollback()— 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):
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 #
- Oracle: Statement — batch (JDK 25)
- Oracle: BatchUpdateException (JDK 25)
- Oracle: PreparedStatement (JDK 25)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
BatchUpdate.java
Unduh berkasimport 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;
}
}