Tampilan
Kenapa penting
Checkout di toko online bukan satu query: kurangi stok, buat pesanan, catat item, catat pembayaran. Kalau server mati setelah stok berkurang tetapi sebelum pesanan tersimpan, stok "hilang" tanpa ada pesanan. Di M06 kamu sudah mengenal ACID dan transaksi JDBC manual dengan setAutoCommit(false), commit(), dan rollback(). Di Spring, semua itu dibungkus satu anotasi: @Transactional. Anotasinya mudah ditulis, tetapi aturannya penuh jebakan — rollback yang tidak terjadi, transaksi yang diam-diam tidak aktif, atau pool koneksi yang habis. Bug transaksi jarang muncul di laptop; ia muncul saat trafik tinggi, dan dampaknya data yang tidak konsisten.
Konsep inti
@Transactional di service
Batas transaksi adalah satu use case bisnis, jadi tempatnya di service — bukan di controller, bukan di setiap method repository. Spring Boot otomatis menyiapkan JpaTransactionManager begitu ada Spring Data JPA di classpath.
java
// Potongan
import org.springframework.transaction.annotation.Transactional;
@Service
public class CheckoutService {
private final ProductRepository products;
private final PurchaseOrderRepository orders;
public CheckoutService(ProductRepository products, PurchaseOrderRepository orders) {
this.products = products;
this.orders = orders;
}
@Transactional
public PurchaseOrder placeOrder(PlaceOrderCommand command) {
Product product = products.findById(command.productId())
.orElseThrow(() -> new ProductNotFoundException(command.productId()));
product.decreaseStock(command.quantity()); // dirty checking → UPDATE saat commit
return orders.save(PurchaseOrder.create(product, command.quantity()));
}
}Alurnya: saat placeOrder dipanggil, transaksi dibuka; semua repository di dalamnya memakai koneksi dan persistence context yang sama; bila method selesai normal → commit (Hibernate mem-flush perubahan entity); bila melempar RuntimeException → rollback, dan stok tidak jadi berkurang. Pakai anotasi dari org.springframework.transaction.annotation, bukan jakarta.transaction.Transactional — keduanya didukung, tetapi hanya versi Spring yang punya atribut readOnly, propagation, dan isolation lengkap.
Proxy dan jebakan self-invocation
Spring tidak mengubah kode kamu. Ia membungkus bean dengan proxy: object perantara yang membuka transaksi, memanggil method asli, lalu commit atau rollback. Konsekuensinya, hanya panggilan yang melewati proxy yang mendapat transaksi. Contoh berikut meniru mekanismenya dengan java.lang.reflect.Proxy:
java
import java.lang.reflect.Proxy;
public class SelfInvocationDemo {
interface OrderService {
void placeOrder(String orderId);
void saveWithTransaction(String orderId);
}
static class DefaultOrderService implements OrderService {
public void placeOrder(String orderId) {
System.out.println("placeOrder(" + orderId + ") memanggil method lain lewat this");
this.saveWithTransaction(orderId); // panggilan internal: tidak lewat proxy
}
public void saveWithTransaction(String orderId) {
System.out.println(" menyimpan " + orderId);
}
}
public static void main(String[] args) {
OrderService target = new DefaultOrderService();
// Proxy sederhana yang meniru cara Spring membungkus bean @Transactional
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class<?>[] {OrderService.class},
(p, method, methodArgs) -> {
System.out.println("[proxy] BEGIN sebelum " + method.getName());
Object result = method.invoke(target, methodArgs);
System.out.println("[proxy] COMMIT setelah " + method.getName());
return result;
});
System.out.println("--- Panggilan dari luar ke saveWithTransaction ---");
proxy.saveWithTransaction("ORD-1");
System.out.println("--- Panggilan dari luar ke placeOrder ---");
proxy.placeOrder("ORD-2");
}
}text
--- Panggilan dari luar ke saveWithTransaction ---
[proxy] BEGIN sebelum saveWithTransaction
menyimpan ORD-1
[proxy] COMMIT setelah saveWithTransaction
--- Panggilan dari luar ke placeOrder ---
[proxy] BEGIN sebelum placeOrder
placeOrder(ORD-2) memanggil method lain lewat this
menyimpan ORD-2
[proxy] COMMIT setelah placeOrderPerhatikan bagian kedua: saveWithTransaction dipanggil lewat this, jadi proxy tidak tahu dan tidak membuka apa pun untuknya. Di Spring, bila hanya saveWithTransaction yang diberi @Transactional, panggilan dari placeOrder berjalan tanpa transaksi — tanpa error, tanpa peringatan. Solusinya: pasang @Transactional di method yang dipanggil dari luar, atau pindahkan method transaksional ke bean lain. Aturan terkait: method private tidak pernah dicegat proxy, dan method final tidak bisa di-override oleh proxy berbasis class. Biasakan method transaksional berupa method public di bean.
readOnly = true
Untuk use case yang hanya membaca, tandai @Transactional(readOnly = true). Hibernate lalu tidak perlu melakukan dirty checking dan flush, dan driver bisa memberi petunjuk ke database bahwa koneksi hanya membaca. Pola yang umum: anotasi readOnly di level class, lalu override di method yang menulis — persis seperti implementasi bawaan Spring Data.
java
// Potongan
@Service
@Transactional(readOnly = true)
public class OrderService {
public OrderDetail getDetail(Long id) { /* hanya membaca */ }
@Transactional // override: transaksi tulis
public void cancel(Long id) { /* mengubah status */ }
}Propagation: bila method transaksional memanggil method transaksional
Propagation menentukan perilaku saat method @Transactional dipanggil ketika transaksi sudah berjalan.
| Propagation | Perilaku | Contoh pemakaian |
|---|---|---|
REQUIRED (default) | ikut transaksi yang ada; buat baru bila belum ada | hampir semua method service |
REQUIRES_NEW | tangguhkan transaksi luar, buka transaksi baru yang commit/rollback sendiri | log audit yang harus tersimpan walau transaksi utama gagal |
MANDATORY | wajib sudah ada transaksi; bila tidak, exception | method internal yang tidak boleh dipanggil di luar transaksi |
NOT_SUPPORTED | jalankan tanpa transaksi | jarang; operasi yang sengaja di luar transaksi |
Dengan REQUIRED, method luar dan dalam berbagi satu transaksi fisik. Bila method dalam melempar RuntimeException, transaksi ditandai rollback-only. Menangkap exception itu di method luar tidak menghapus tandanya, sehingga saat commit Spring melempar UnexpectedRollbackException. REQUIRES_NEW memang memisahkan hasilnya, tetapi ia mengambil koneksi kedua dari pool selama transaksi luar masih memegang koneksi pertama — di bawah beban tinggi ini bisa menghabiskan pool. Pakai seperlunya.
Rollback rules: unchecked vs checked
Ini sumber bug paling klasik. Secara default Spring melakukan rollback hanya untuk RuntimeException dan Error. Checked exception (turunan Exception biasa) → transaksi tetap di-commit. Aturan ini berasal dari anggapan lama bahwa checked exception adalah "hasil bisnis alternatif", bukan kegagalan.
java
// Potongan
@Transactional(rollbackFor = PaymentDeclinedException.class)
public PurchaseOrder checkout(CheckoutCommand command) throws PaymentDeclinedException {
// ...
}
@Transactional(noRollbackFor = CouponExpiredException.class)
public PurchaseOrder applyCoupon(Long orderId, String code) {
// CouponExpiredException tidak membatalkan perubahan lain
}Cara paling sederhana dan konsisten: jadikan exception domain kamu turunan RuntimeException (seperti yang sudah kamu lakukan untuk OrderNotFoundException di M07). Sejak Spring Framework 6.2 kamu juga bisa mengubah default untuk seluruh aplikasi dengan @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS). Ingat juga: exception yang kamu tangkap dan "telan" di dalam method transaksional tidak memicu rollback sama sekali, karena proxy tidak pernah melihatnya.
Isolation
Isolation mengatur seberapa jauh transaksi yang berjalan bersamaan saling melihat perubahan (dirty read, non-repeatable read, phantom read dari M06). Default @Transactional adalah Isolation.DEFAULT, artinya mengikuti database — READ COMMITTED di PostgreSQL. Kamu bisa menaikkannya per method:
java
// Potongan
@Transactional(isolation = Isolation.REPEATABLE_READ)
public MonthlyReport buildMonthlyReport(YearMonth month) { /* beberapa query yang harus konsisten */ }Di PostgreSQL, REPEATABLE_READ dan SERIALIZABLE bisa membatalkan transaksi dengan serialization failure saat terjadi konflik, sehingga pemanggil harus siap mencoba ulang. Untuk kebanyakan kasus "jangan sampai dua orang menimpa data yang sama", locking (di bawah) lebih tepat sasaran daripada menaikkan isolation.
Optimistic locking dengan @Version
Masalah lost update: dua admin membaca stok yang sama, keduanya menyimpan, dan simpanan kedua menimpa yang pertama tanpa jejak. Solusinya: tambahkan kolom versi. Setiap UPDATE menyertakan where version = <versi saat dibaca> dan menaikkan versinya. Bila versinya sudah berubah, tidak ada baris yang ter-update — konflik terdeteksi. Simulasinya:
java
import java.util.HashMap;
import java.util.Map;
public class OptimisticLockDemo {
record Stock(String sku, int quantity, long version) {}
static class StockTable {
private final Map<String, Stock> rows = new HashMap<>();
void insert(Stock s) { rows.put(s.sku(), s); }
Stock select(String sku) { return rows.get(sku); }
// Meniru: UPDATE stock SET quantity = ?, version = version + 1
// WHERE sku = ? AND version = ?
int update(String sku, int newQuantity, long expectedVersion) {
Stock current = rows.get(sku);
if (current == null || current.version() != expectedVersion) {
return 0; // tidak ada baris yang cocok
}
rows.put(sku, new Stock(sku, newQuantity, current.version() + 1));
return 1;
}
}
public static void main(String[] args) {
StockTable table = new StockTable();
table.insert(new Stock("KOPI-250G", 10, 0));
Stock readByAdminA = table.select("KOPI-250G");
Stock readByAdminB = table.select("KOPI-250G");
int a = table.update("KOPI-250G", readByAdminA.quantity() - 3, readByAdminA.version());
System.out.println("Admin A: baris terupdate = " + a);
int b = table.update("KOPI-250G", readByAdminB.quantity() - 5, readByAdminB.version());
System.out.println("Admin B: baris terupdate = " + b
+ (b == 0 ? " -> konflik, kirim 409 Conflict" : ""));
System.out.println("Isi tabel: " + table.select("KOPI-250G"));
}
}text
Admin A: baris terupdate = 1
Admin B: baris terupdate = 0 -> konflik, kirim 409 Conflict
Isi tabel: Stock[sku=KOPI-250G, quantity=7, version=1]Di JPA kamu tidak menulis logika itu sendiri. Cukup tambahkan field ber-@Version; Hibernate yang menyusun klausa where version = ? dan menaikkan nilainya. Bila konflik, Spring menerjemahkan exception Hibernate menjadi ObjectOptimisticLockingFailureException (turunan OptimisticLockingFailureException).
java
// Potongan
@Entity
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int stock;
@Version
private long version; // jangan diubah manual oleh kode aplikasi
}Pessimistic lock (sekilas)
Optimistic locking cocok bila konflik jarang. Untuk baris yang diperebutkan sangat sering — misalnya stok produk flash sale — kamu bisa mengunci barisnya sejak dibaca dengan SELECT ... FOR UPDATE. Transaksi lain yang ingin mengunci baris yang sama harus menunggu sampai transaksi pertama selesai.
java
// Potongan
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}Harganya: transaksi lain menunggu, dan risiko deadlock naik bila urutan penguncian tidak konsisten. Transaksi yang memegang lock harus sangat singkat.
Jangan memanggil HTTP eksternal di dalam transaksi
Selama transaksi terbuka, satu koneksi dari pool HikariCP tertahan beserta lock baris yang sudah diambil. Bila di tengahnya kamu memanggil payment gateway yang butuh 3 detik, koneksi itu menganggur selama 3 detik. Dengan pool 10 koneksi (default HikariCP), cukup belasan checkout bersamaan untuk membuat request lain antre lalu timeout. Lebih buruk lagi: gateway bisa sukses menagih pelanggan, lalu commit database gagal — uang terpotong, pesanan tidak ada. Polanya: pecah menjadi langkah-langkah dengan transaksi singkat, dan lakukan panggilan eksternal di antara transaksi.
Contoh di dunia backend
Checkout dipecah menjadi dua bean: OrderPlacementService memegang transaksi singkat, sedangkan CheckoutFacade mengatur urutan dan memanggil payment gateway di luar transaksi.
java
// Potongan
@Service
public class OrderPlacementService {
private final ProductRepository products;
private final PurchaseOrderRepository orders;
public OrderPlacementService(ProductRepository products, PurchaseOrderRepository orders) {
this.products = products;
this.orders = orders;
}
@Transactional
public PurchaseOrder reserve(CheckoutCommand command) {
Product product = products.findById(command.productId())
.orElseThrow(() -> new ProductNotFoundException(command.productId()));
product.decreaseStock(command.quantity()); // @Version mencegah stok minus akibat lost update
return orders.save(PurchaseOrder.pending(product, command.quantity()));
}
@Transactional
public void markPaid(Long orderId, String paymentReference) {
PurchaseOrder order = orders.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.markPaid(paymentReference);
}
@Transactional
public void cancelAndRestock(Long orderId) {
PurchaseOrder order = orders.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel(); // juga mengembalikan stok produk
}
}java
// Potongan — sengaja TANPA @Transactional
@Service
public class CheckoutFacade {
private final OrderPlacementService placement;
private final PaymentGatewayClient paymentGateway;
public CheckoutFacade(OrderPlacementService placement, PaymentGatewayClient paymentGateway) {
this.placement = placement;
this.paymentGateway = paymentGateway;
}
public PurchaseOrder checkout(CheckoutCommand command) throws PaymentDeclinedException {
PurchaseOrder order = placement.reserve(command); // transaksi 1, lalu commit
try {
String reference = paymentGateway.charge(order.getId(), order.getTotalAmount()); // HTTP, tanpa transaksi
placement.markPaid(order.getId(), reference); // transaksi 2
} catch (PaymentDeclinedException e) {
placement.cancelAndRestock(order.getId()); // transaksi kompensasi
throw e;
}
return order;
}
}Konflik versi dipetakan ke 409 Conflict dengan ProblemDetail seperti di M07:
java
// Potongan
@RestControllerAdvice
public class ConcurrencyExceptionHandler {
@ExceptionHandler(OptimisticLockingFailureException.class)
public ProblemDetail handleConflict(OptimisticLockingFailureException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Data sudah diubah oleh pengguna lain. Muat ulang lalu coba lagi.");
problem.setTitle("Konflik pembaruan");
return problem;
}
}Untuk form edit, kirim version ke klien dan minta klien mengirimkannya kembali. Di service, bandingkan dengan versi entity yang baru dimuat; bila berbeda, lempar OptimisticLockingFailureException sendiri — jangan menyalin nilai versi dari request ke entity.
Pertanyaan interview
1. Kenapa @Transactional kadang "tidak bekerja"?
Penyebab tersering: self-invocation (method dipanggil lewat this sehingga tidak melewati proxy), method private atau final, class yang bukan bean Spring (dibuat dengan new), exception yang ditangkap dan ditelan di dalam method, atau exception checked yang secara default tidak memicu rollback. Semua berakar pada fakta bahwa transaksi diterapkan oleh proxy di sekitar bean.
2. Apa beda optimistic dan pessimistic locking, dan kapan memilih masing-masing?
Optimistic locking tidak mengunci apa pun; ia mendeteksi konflik saat UPDATE lewat kolom versi, lalu gagal dan meminta klien mencoba ulang. Cocok bila konflik jarang, seperti edit data produk oleh admin. Pessimistic locking mengunci baris sejak dibaca (SELECT ... FOR UPDATE) sehingga transaksi lain menunggu. Cocok untuk baris yang sangat sering diperebutkan dalam transaksi singkat, dengan harga throughput lebih rendah dan risiko deadlock.
3. Kapan memakai REQUIRES_NEW?
Saat sebagian pekerjaan harus commit atau rollback terpisah dari transaksi pemanggil — misalnya log audit percobaan pembayaran yang harus tersimpan walaupun checkout gagal. Kekurangannya: transaksi luar ditangguhkan sambil tetap memegang koneksi, dan transaksi baru mengambil koneksi kedua, sehingga pemakaian berlebihan bisa menghabiskan pool.
Jebakan umum
- Mengandalkan rollback untuk checked exception. Default-nya commit. Pakai
rollbackForatau exception turunanRuntimeException. - Self-invocation.
this.method()melewati proxy; anotasi@Transactionaldi method itu diabaikan tanpa peringatan. - Menangkap exception di dalam method transaksional lalu melanjutkan. Bila exception datang dari method lain yang ikut transaksi yang sama, hasilnya
UnexpectedRollbackExceptionsaat commit. - Memanggil HTTP, mengirim email, atau menunggu antrean di dalam transaksi. Koneksi dan lock tertahan; pindahkan ke luar transaksi.
@Transactionaldi controller atau di setiap method repository. Batas transaksi = satu use case di service.- Mengubah field
@Versionsecara manual untuk "memaksa" simpan. Bandingkan versi dari klien, lalu lempar konflik.
Ringkasan
@Transactionaldi service membuat satu use case all-or-nothing: commit bila selesai normal, rollback bila adaRuntimeException.- Transaksi diterapkan oleh proxy; panggilan lewat
this, methodprivate, dan object non-bean tidak ikut transaksi. readOnly = trueuntuk use case baca;REQUIREDuntuk hampir semua kasus,REQUIRES_NEWhanya untuk pekerjaan yang harus berdiri sendiri.- Checked exception tidak memicu rollback secara default — atur
rollbackForatau pakai exception unchecked. @Versionmencegah lost update; petakanOptimisticLockingFailureExceptionke409 Conflict.- Jaga transaksi tetap singkat: jangan memanggil layanan eksternal di dalamnya.