Tampilan
Kenapa penting
Hampir setiap service backend menyimpan data di database relasional, dan semua jalan dari Java ke database — Spring JDBC, JPA/Hibernate, jOOQ, Flyway — berdiri di atas satu fondasi: JDBC (Java Database Connectivity). Framework memang menyembunyikan detailnya, tetapi saat ada koneksi bocor yang membuat pool habis, transaksi yang hanya tersimpan setengah, atau celah SQL injection di laporan keamanan, kamu harus paham apa yang terjadi di lapisan ini. Di bab sebelumnya kamu merancang tabel, constraint, dan transaksi di SQL; sekarang kamu memanggilnya dari kode Java dengan aman dan efisien.
Konsep inti
JDBC = API standar + driver
JDBC adalah kumpulan interface di modul java.sql (dan javax.sql) yang sudah ada di JDK. Implementasinya disediakan vendor database dalam bentuk driver. Kode kamu hanya bergantung pada interface; mengganti database berarti mengganti driver dan URL, bukan menulis ulang semuanya.
| Interface | Peran |
|---|---|
Connection | sesi ke database; tempat transaksi dikendalikan |
PreparedStatement | SQL yang sudah dikompilasi dengan parameter ? |
ResultSet | kursor hasil SELECT, dibaca baris per baris dengan next() |
DataSource | "pabrik" koneksi — biasanya berupa connection pool |
SQLException | checked exception untuk semua error database |
Tambahkan driver PostgreSQL ke pom.xml. Scope runtime cukup karena kode kita tidak pernah meng-import class milik org.postgresql secara langsung:
xml
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.14</version>
<scope>runtime</scope>
</dependency>Database untuk mencoba
Contoh di bab ini memakai container PostgreSQL 17 dari bab SQL dasar: database toko, user app, password rahasia, port 5432. Agar tidak bentrok dengan tabel yang sudah kamu buat, contoh memakai schema terpisah lewat parameter URL currentSchema=jdbc_demo.
Membuka koneksi: DriverManager vs DataSource
Cara paling sederhana adalah DriverManager.getConnection(url, user, password). Driver JDBC 4+ terdaftar otomatis lewat ServiceLoader, jadi Class.forName("org.postgresql.Driver") yang sering terlihat di tutorial lama tidak diperlukan lagi.
java
// Potongan
try (Connection conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/toko", "app", password)) {
System.out.println(conn.getMetaData().getDatabaseProductName()); // PostgreSQL
}DriverManager membuka koneksi TCP baru setiap kali dipanggil — melibatkan handshake, autentikasi, dan alokasi proses di server PostgreSQL. Cocok untuk skrip dan belajar, tetapi terlalu mahal untuk service yang melayani ratusan request per detik. Di aplikasi sungguhan, kode bergantung pada interface DataSource, dan implementasinya adalah connection pool (dibahas di bawah). Karena keduanya sama-sama menghasilkan Connection, kode repository tidak perlu tahu dari mana koneksinya berasal.
Jangan hardcode kredensial
Password database tidak boleh ditulis di kode atau di-commit ke Git. Contoh di bab ini membacanya dari environment variable (DB_URL, DB_USER, DB_PASSWORD); nilai default hanya untuk container lokal.
Query dengan PreparedStatement dan mapping ke record
Pola dasar JDBC selalu sama: ambil Connection, siapkan PreparedStatement dengan parameter ?, isi parameter dengan setXxx(index, nilai) (index mulai dari 1), jalankan, lalu baca ResultSet. Ketiganya memegang resource di driver dan server, jadi semuanya ditutup dengan try-with-resources.
Program berikut membuat tabel, menyimpan produk, lalu membacanya kembali sebagai record Product:
java
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class ProductJdbc {
// Kredensial dibaca dari environment variable; nilai default hanya untuk container lokal.
static final String URL = System.getenv().getOrDefault("DB_URL",
"jdbc:postgresql://localhost:5432/toko?currentSchema=jdbc_demo");
static final String USER = System.getenv().getOrDefault("DB_USER", "app");
static final String PASSWORD = System.getenv().getOrDefault("DB_PASSWORD", "rahasia");
record Product(long id, String sku, String name, BigDecimal price, int stock) {}
public static void main(String[] args) throws SQLException {
try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) {
System.out.println("Terhubung ke " + conn.getMetaData().getDatabaseProductName());
createTable(conn);
long id = insert(conn, "KOPI-250", "Kopi Arabika 250 g", new BigDecimal("85000.00"), 10);
insert(conn, "TEH-100", "Teh Melati 100 g", new BigDecimal("18500.00"), 40);
insert(conn, "GULA-1KG", "Gula Pasir 1 kg", new BigDecimal("17000.00"), 0);
System.out.println("Produk baru id=" + id);
System.out.println(findBySku(conn, "KOPI-250"));
System.out.println(findBySku(conn, "SUSU-1L"));
findInStockCheaperThan(conn, new BigDecimal("50000")).forEach(System.out::println);
}
}
static void createTable(Connection conn) throws SQLException {
try (Statement st = conn.createStatement()) {
// Schema terpisah agar demo tidak bentrok dengan tabel dari bab SQL.
st.execute("CREATE SCHEMA IF NOT EXISTS jdbc_demo");
st.execute("DROP TABLE IF EXISTS product CASCADE");
st.execute("""
CREATE TABLE product (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
price NUMERIC(12,2) NOT NULL CHECK (price >= 0),
stock INT NOT NULL CHECK (stock >= 0)
)""");
}
}
static long insert(Connection conn, String sku, String name, BigDecimal price, int stock)
throws SQLException {
String sql = "INSERT INTO product (sku, name, price, stock) VALUES (?, ?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, new String[] {"id"})) {
ps.setString(1, sku);
ps.setString(2, name);
ps.setBigDecimal(3, price);
ps.setInt(4, stock);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
return keys.getLong("id");
}
}
}
static Optional<Product> findBySku(Connection conn, String sku) throws SQLException {
String sql = "SELECT id, sku, name, price, stock FROM product WHERE sku = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, sku);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
}
}
static List<Product> findInStockCheaperThan(Connection conn, BigDecimal maxPrice) throws SQLException {
String sql = """
SELECT id, sku, name, price, stock FROM product
WHERE stock > 0 AND price < ? ORDER BY price""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setBigDecimal(1, maxPrice);
try (ResultSet rs = ps.executeQuery()) {
List<Product> result = new ArrayList<>();
while (rs.next()) {
result.add(mapRow(rs));
}
return result;
}
}
}
static Product mapRow(ResultSet rs) throws SQLException {
return new Product(
rs.getLong("id"),
rs.getString("sku"),
rs.getString("name"),
rs.getBigDecimal("price"),
rs.getInt("stock"));
}
}Jalankan dengan driver di classpath (Java 25 bisa menjalankan file sumber langsung):
bash
java -cp "lib/*" ProductJdbc.javaOutput:
text
Terhubung ke PostgreSQL
Produk baru id=1
Optional[Product[id=1, sku=KOPI-250, name=Kopi Arabika 250 g, price=85000.00, stock=10]]
Optional.empty
Product[id=2, sku=TEH-100, name=Teh Melati 100 g, price=18500.00, stock=40]Beberapa hal yang perlu diperhatikan:
executeQuery()untukSELECT(mengembalikanResultSet);executeUpdate()untukINSERT/UPDATE/DELETE(mengembalikan jumlah baris yang terpengaruh).ResultSetmulai sebelum baris pertama.next()memindahkan kursor dan mengembalikanfalsebila baris habis —ifuntuk satu baris,whileuntuk banyak baris.- Baca kolom dengan nama (
rs.getLong("id")), bukan posisi, agar tidak rusak saat urutan kolom berubah. - Satu method
mapRowmengubah baris menjadi record. Pola "row mapper" ini persis yang nanti dipakai Spring JDBC. findBySkumengembalikanOptional; uang memakaiNUMERIC↔BigDecimal, bukandouble.
Kolom NULL dan tipe primitif
rs.getInt("stock") mengembalikan 0 bila kolomnya NULL — bukan exception. Untuk kolom yang boleh NULL, pakai rs.getObject("stock", Integer.class) yang mengembalikan null, atau cek rs.wasNull() setelah membaca.
SQL injection: kenapa wajib PreparedStatement
Menempelkan input ke teks SQL dengan + membuat input itu ikut menjadi bagian dari perintah SQL. Penyerang cukup mengirim tanda kutip untuk mengubah arti query. Jalankan program ini tepat setelah ProductJdbc (tabel berisi 3 produk):
java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class InjectionDemo {
static final String URL = System.getenv().getOrDefault("DB_URL",
"jdbc:postgresql://localhost:5432/toko?currentSchema=jdbc_demo");
static final String USER = System.getenv().getOrDefault("DB_USER", "app");
static final String PASSWORD = System.getenv().getOrDefault("DB_PASSWORD", "rahasia");
public static void main(String[] args) throws SQLException {
String input = "X' OR '1'='1"; // "SKU" kiriman penyerang
try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) {
// SALAH: input ditempel ke teks SQL
String sql = "SELECT sku FROM product WHERE sku = '" + input + "'";
System.out.println("SQL jadi: " + sql);
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
System.out.println("Statement -> " + count(rs) + " baris");
}
// BENAR: input dikirim sebagai parameter, bukan bagian dari SQL
try (PreparedStatement ps = conn.prepareStatement("SELECT sku FROM product WHERE sku = ?")) {
ps.setString(1, input);
try (ResultSet rs = ps.executeQuery()) {
System.out.println("PreparedStatement -> " + count(rs) + " baris");
}
}
}
}
static int count(ResultSet rs) throws SQLException {
int n = 0;
while (rs.next()) n++;
return n;
}
}Output:
text
SQL jadi: SELECT sku FROM product WHERE sku = 'X' OR '1'='1'
Statement -> 3 baris
PreparedStatement -> 0 barisDengan Statement, kondisinya menjadi "SKU = X atau '1'='1'" — selalu benar, seluruh tabel bocor. PreparedStatement mengirim SQL dan nilai parameter terpisah, sehingga input itu hanya dianggap string SKU. Ingat: parameter ? hanya bisa menggantikan nilai, bukan nama tabel, nama kolom, atau arah ORDER BY. Bila bagian itu harus dinamis (misalnya kolom sorting dari query string), cocokkan dengan daftar yang diizinkan (allowlist), jangan pernah ditempel mentah.
Generated keys dan batch insert
Kolom id dibuat database (GENERATED ALWAYS AS IDENTITY), padahal aplikasi butuh nilainya — misalnya untuk menyimpan item milik order baru. Minta kolom itu dikembalikan saat menyiapkan statement, lalu baca lewat getGeneratedKeys():
java
// Potongan
try (PreparedStatement ps = conn.prepareStatement(sql, new String[] {"id"})) {
// ... setXxx, lalu:
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
long newId = keys.getLong("id");
}
}Untuk banyak baris sekaligus, jangan memanggil executeUpdate() per baris — setiap panggilan adalah satu round trip jaringan. Kumpulkan parameter dengan addBatch(), lalu kirim dengan executeBatch():
java
// Potongan
for (OrderLine line : lines) {
ps.setLong(1, orderId);
ps.setString(2, line.sku());
ps.setInt(3, line.qty());
ps.setBigDecimal(4, line.unitPrice());
ps.addBatch();
}
int[] counts = ps.executeBatch(); // satu elemen per baris yang dikirimBatch lebih cepat lagi di PostgreSQL
Tambahkan parameter URL reWriteBatchedInserts=true agar driver PostgreSQL menggabungkan batch INSERT menjadi INSERT ... VALUES (...), (...) multi-baris. Untuk data sangat besar (ratusan ribu baris), kirim batch per beberapa ratus sampai seribu baris agar memori tetap terkendali.
Transaksi manual
Secara default koneksi JDBC dalam mode auto-commit: setiap statement langsung di-commit sendiri-sendiri. Untuk operasi yang harus "semua atau tidak sama sekali" — seperti checkout yang mengurangi stok, membuat order, dan menyimpan item — matikan auto-commit, lalu akhiri dengan commit() atau rollback():
java
// Potongan
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false); // mulai transaksi
try {
// beberapa UPDATE / INSERT
conn.commit();
} catch (SQLException | RuntimeException e) {
conn.rollback(); // batalkan semua perubahan sejak transaksi dimulai
throw e;
}
}Pola ini penting: rollback() dipanggil untuk semua jenis kegagalan, termasuk exception bisnis seperti OutOfStockException, lalu exception dilempar ulang agar pemanggil tahu. Transaksi terikat pada satu Connection — semua statement dalam transaksi harus memakai objek koneksi yang sama. Isolation level yang kamu pelajari di bab sebelumnya bisa diatur dengan conn.setTransactionIsolation(...); default PostgreSQL adalah READ COMMITTED.
Connection pool dengan HikariCP
Connection pool menyimpan sejumlah koneksi yang sudah terbuka. getConnection() meminjam koneksi dari pool, dan close() mengembalikannya — bukan menutup koneksi TCP. Hasilnya: tidak ada biaya handshake per request, dan jumlah koneksi ke PostgreSQL terkendali (PostgreSQL membuat satu proses per koneksi, dan defaultnya hanya menerima 100 koneksi). HikariCP adalah pool yang paling umum dan menjadi default di Spring Boot.
xml
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>| Setelan | Default | Arti |
|---|---|---|
maximumPoolSize | 10 | jumlah koneksi maksimum di pool |
connectionTimeout | 30000 ms | lama menunggu koneksi kosong sebelum melempar exception |
idleTimeout | 600000 ms | koneksi menganggur lebih lama dari ini boleh ditutup |
maxLifetime | 1800000 ms | umur maksimum koneksi; buat lebih pendek dari batas di server |
poolName | otomatis | nama pool di log dan metrik |
Pool yang lebih besar tidak otomatis lebih cepat: database hanya bisa mengerjakan sedikit query secara benar-benar paralel (kira-kira sebanding jumlah core CPU-nya). Mulai dari pool kecil (5–10), ukur, baru naikkan.
Kenapa ada Spring JDBC dan JPA
Setiap query JDBC butuh try-with-resources bertingkat, setXxx per parameter, mapRow, dan pola commit/rollback berulang — benar tetapi bertele-tele. Karena itu lahir lapisan di atasnya yang akan kamu pakai di level Spring Boot:
| Lapisan | Yang dikerjakan untukmu |
|---|---|
Spring JDBC (JdbcClient) | membuka/menutup resource, mengikat parameter, row mapper, terjemahan exception |
Spring @Transactional | commit/rollback otomatis per method |
| JPA/Hibernate (Spring Data JPA) | memetakan entity ke tabel; SQL dasar dibuat otomatis |
Semuanya tetap memakai DataSource, Connection, dan PreparedStatement di balik layar. Pemahaman bab ini yang membuatmu bisa men-debug lapisan-lapisan itu.
Contoh di dunia backend
Checkout toko online: kurangi stok setiap item (gagal bila stok kurang), simpan order dan ambil id-nya, simpan item dengan batch — semuanya dalam satu transaksi, dengan koneksi dari HikariCP. Pengurangan stok memakai UPDATE ... WHERE stock >= ? sehingga pengecekan dan pengurangan terjadi atomik di database, tanpa celah race condition antara "cek stok" dan "kurangi stok".
java
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import javax.sql.DataSource;
public class CheckoutJdbc {
record OrderLine(String sku, int qty, BigDecimal unitPrice) {}
static class OutOfStockException extends RuntimeException {
OutOfStockException(String sku) {
super("Stok tidak cukup untuk " + sku);
}
}
static class OrderRepository {
private final DataSource dataSource;
OrderRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
long placeOrder(String customer, List<OrderLine> lines) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false); // mulai transaksi
try {
decreaseStock(conn, lines);
long orderId = insertOrder(conn, customer, lines);
insertItems(conn, orderId, lines);
conn.commit();
return orderId;
} catch (SQLException | RuntimeException e) {
conn.rollback(); // batalkan SEMUA perubahan di transaksi ini
throw e;
}
}
}
private void decreaseStock(Connection conn, List<OrderLine> lines) throws SQLException {
String sql = "UPDATE product SET stock = stock - ? WHERE sku = ? AND stock >= ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (OrderLine line : lines) {
ps.setInt(1, line.qty());
ps.setString(2, line.sku());
ps.setInt(3, line.qty());
if (ps.executeUpdate() == 0) {
throw new OutOfStockException(line.sku());
}
}
}
}
private long insertOrder(Connection conn, String customer, List<OrderLine> lines)
throws SQLException {
BigDecimal total = BigDecimal.ZERO;
for (OrderLine line : lines) {
total = total.add(line.unitPrice().multiply(BigDecimal.valueOf(line.qty())));
}
String sql = "INSERT INTO orders (customer, total) VALUES (?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, new String[] {"id"})) {
ps.setString(1, customer);
ps.setBigDecimal(2, total);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
return keys.getLong(1);
}
}
}
private void insertItems(Connection conn, long orderId, List<OrderLine> lines)
throws SQLException {
String sql = "INSERT INTO order_item (order_id, sku, qty, unit_price) VALUES (?, ?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (OrderLine line : lines) {
ps.setLong(1, orderId);
ps.setString(2, line.sku());
ps.setInt(3, line.qty());
ps.setBigDecimal(4, line.unitPrice());
ps.addBatch(); // kumpulkan dulu
}
int[] counts = ps.executeBatch(); // kirim sekaligus
System.out.println(" batch order_item: " + counts.length + " baris");
}
}
}
public static void main(String[] args) throws SQLException {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv().getOrDefault("DB_URL",
"jdbc:postgresql://localhost:5432/toko?currentSchema=jdbc_demo"));
config.setUsername(System.getenv().getOrDefault("DB_USER", "app"));
config.setPassword(System.getenv().getOrDefault("DB_PASSWORD", "rahasia"));
config.setMaximumPoolSize(5);
config.setPoolName("toko-pool");
try (HikariDataSource dataSource = new HikariDataSource(config)) {
setUp(dataSource);
OrderRepository repo = new OrderRepository(dataSource);
long id = repo.placeOrder("budi@example.com", List.of(
new OrderLine("KOPI-250", 2, new BigDecimal("85000.00")),
new OrderLine("TEH-100", 3, new BigDecimal("18500.00"))));
System.out.println("Order " + id + " tersimpan");
printStock(dataSource);
try {
repo.placeOrder("sari@example.com", List.of(
new OrderLine("TEH-100", 1, new BigDecimal("18500.00")),
new OrderLine("KOPI-250", 50, new BigDecimal("85000.00"))));
} catch (OutOfStockException e) {
System.out.println("Gagal: " + e.getMessage() + " -> rollback");
}
printStock(dataSource);
}
}
static void setUp(DataSource ds) throws SQLException {
try (Connection conn = ds.getConnection(); Statement st = conn.createStatement()) {
st.execute("CREATE SCHEMA IF NOT EXISTS jdbc_demo");
st.execute("DROP TABLE IF EXISTS order_item, orders, product");
st.execute("""
CREATE TABLE product (
sku VARCHAR(30) PRIMARY KEY,
stock INT NOT NULL CHECK (stock >= 0))""");
st.execute("""
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer VARCHAR(100) NOT NULL,
total NUMERIC(14,2) NOT NULL)""");
st.execute("""
CREATE TABLE order_item (
order_id BIGINT NOT NULL REFERENCES orders(id),
sku VARCHAR(30) NOT NULL REFERENCES product(sku),
qty INT NOT NULL CHECK (qty > 0),
unit_price NUMERIC(12,2) NOT NULL)""");
st.execute("INSERT INTO product (sku, stock) VALUES ('KOPI-250', 10), ('TEH-100', 40)");
}
}
static void printStock(DataSource ds) throws SQLException {
try (Connection conn = ds.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT sku, stock FROM product ORDER BY sku")) {
StringBuilder sb = new StringBuilder(" stok:");
while (rs.next()) {
sb.append(' ').append(rs.getString("sku")).append('=').append(rs.getInt("stock"));
}
System.out.println(sb);
}
}
}Output (HikariCP juga menulis peringatan No SLF4J providers were found ke stderr karena belum ada library logging — tidak berbahaya):
text
batch order_item: 2 baris
Order 1 tersimpan
stok: KOPI-250=8 TEH-100=37
Gagal: Stok tidak cukup untuk KOPI-250 -> rollback
stok: KOPI-250=8 TEH-100=37Order kedua sempat mengurangi stok TEH-100 sebelum gagal di KOPI-250, tetapi rollback() membatalkan pengurangan itu — stok tetap TEH-100=37. Karena OrderRepository hanya menerima DataSource, test bisa memberinya pool yang menunjuk ke database test.
Pertanyaan interview
1. Apa beda Statement dan PreparedStatement, dan kenapa PreparedStatement lebih disarankan?
Statement mengirim teks SQL utuh, jadi nilai dinamis harus ditempel ke string — rawan SQL injection dan rawan error format (tanda kutip, tanggal, desimal). PreparedStatement memisahkan SQL dan parameter ?; nilai dikirim terpisah dengan tipe yang jelas sehingga tidak bisa mengubah struktur query. Bonusnya, statement yang sama bisa dipakai ulang dan di-batch. Satu-satunya batasan: ? hanya untuk nilai, bukan nama tabel/kolom.
2. Kenapa aplikasi produksi memakai connection pool, dan apa yang terjadi bila koneksi tidak di-close()?
Membuka koneksi database itu mahal (TCP, autentikasi, proses baru di server). Pool menyimpan koneksi terbuka dan meminjamkannya; close() mengembalikannya. Bila kode lupa close(), koneksi bocor; setelah semua slot terpakai, permintaan berikutnya menunggu sampai connectionTimeout lalu gagal. HikariCP punya leakDetectionThreshold untuk mencatat koneksi yang dipinjam terlalu lama.
3. Bagaimana menjalankan beberapa perubahan sebagai satu transaksi dengan JDBC?
Ambil satu Connection, panggil setAutoCommit(false), jalankan semua statement dengan koneksi yang sama, lalu commit() bila semuanya berhasil. Di blok catch panggil rollback() untuk semua jenis exception dan lempar ulang exception-nya. Pengecekan bisnis yang rawan race condition sebaiknya dijadikan kondisi di SQL, misalnya UPDATE ... WHERE stock >= ? lalu cek jumlah baris yang terpengaruh.
Jebakan umum
- Menempel input ke SQL dengan
+. SelaluPreparedStatement+setXxx. Untuk nama kolom dinamis, pakai allowlist. - Lupa menutup
Connection/PreparedStatement/ResultSet. Dengan pool, kebocoran membuat aplikasi macet setelah beberapa menit trafik. Bungkus semuanya dengan try-with-resources. - Index parameter mulai dari 0. Parameter JDBC mulai dari 1;
ps.setString(0, ...)melemparSQLException. - Mengira
getIntmengembalikannull. KolomNULLterbaca0; pakaigetObject(kolom, Integer.class). - Transaksi tanpa
rollbackdi jalur exception bisnis. Hanya menangkapSQLExceptionmembuatRuntimeExceptionlolos tanpa rollback. - Memperbesar
maximumPoolSizeuntuk "mempercepat". Pool terlalu besar membebani database; perbaiki query lambat dan kebocoran koneksi dulu.
Ringkasan
- JDBC adalah interface standar (
Connection,PreparedStatement,ResultSet,DataSource); driver PostgreSQL (org.postgresql:postgresql, scoperuntime) adalah implementasinya. - Selalu gunakan
PreparedStatementdengan parameter?— mencegah SQL injection — dan tutup semua resource dengan try-with-resources. - Petakan baris ke record lewat satu method
mapRow; kembalikanOptionaluntuk pencarian satu baris. - Transaksi manual:
setAutoCommit(false)→ statement →commit(), danrollback()untuk semua kegagalan. getGeneratedKeys()mengambilidbuatan database;addBatch()/executeBatch()menghemat round trip.- Pakai
DataSourcedari connection pool (HikariCP) di aplikasi; Spring JDBC dan JPA membungkus semua pola ini.