Satu operasi bisnis “Budi memesan penerbangan” sebenarnya mengubah dua service sekaligus: svc-penerbangan menempati kursi dan svc-tagihan membebankan biaya. Di monolith, kedua perubahan berlindung dalam satu BEGIN ... COMMIT — bila tagihan ditolak, rollback mengembalikan kursi seketika. Di microservices, kedua perubahan menempel di dua database yang berbeda dalam dua proses yang berbeda, dan tidak ada satu pun Connection yang bisa menggenggam keduanya. Chapter ini membuktikan mengapa atomisitas berhenti di batas proses, membangun protokol two-phase commit (2PC) yang mencoba memulihkannya, lalu menimbang mengapa dunia microservices umumnya memilih jalan lain: konsistensi eventual dengan kompensasi.
Kenapa ACID Berhenti di Batas Proses #
Transaksi ACID adalah kontrak antara satu aplikasi dan satu penyimpanan transaksional: semua yang terjadi di antara BEGIN dan COMMIT diawasi log transaksi milik penyimpanan itu. Melewati batas proses, tiga pilar kontrak itu runtuh sekaligus. Atomisitas hilang karena tidak ada pengawas tunggal yang mengatur komit dua database. Isolasi hilang karena lock di database A tidak terlihat oleh transaksi di database B. Dan muncul pemain baru yang tidak ada di transaksi lokal: jaringan bisa gagal di antara dua langkah mana pun — khususnya persis di antara “pesan komit terkirim” dan “pesan komit diterima”, meninggalkan satu pihak sudah menempel dan satu pihak masih ragu.
Bahaya Komit Lokal #
Cara paling naif menyelesaikan alur lintas service adalah komit lokal berurutan: langkah pertama kommit, lalu langkah kedua. Bagian pertama program kita memperlihatkan akibatnya:
import java.util.ArrayList;
import java.util.List;
/** Transaksi lintas service: bahaya komit lokal, lalu two-phase commit (2PC) mini. */
public class TransaksiLintasService {
/** Satu service peserta dengan perubahan dua tahap: siapkan lalu kommit/batalkan. */
static class Peserta {
private final String nama;
private String tersiap = null; // perubahan tahap PREPARE (belum menempel)
private String menempel = null; // hasil kommit terakhir
Peserta(String nama) {
this.nama = nama;
}
/** Fase 1: peserta menyiapkan perubahan dan memberi suara. */
boolean siapkan(String perubahan, boolean setuju) {
if (!setuju) {
tersiap = null;
return false;
}
tersiap = perubahan;
return true;
}
/** Fase 2a: kommit menempelkan perubahan yang disiapkan. */
void kommit() {
menempel = tersiap;
tersiap = null;
}
/** Fase 2b: abort membuang perubahan yang disiapkan. */
void batalkan() {
tersiap = null;
}
String status() {
return nama + ": menempel=" + menempel
+ (tersiap != null ? ", tersiap=" + tersiap : "");
}
}
public static void main(String[] args) {
System.out.println("== MASALAH: komit lokal langkah demi langkah ==");
komitLokal();
System.out.println();
System.out.println("== 2PC: semua setuju -> kommit bersama ==");
duaFase(true);
System.out.println();
System.out.println("== 2PC: satu menolak -> abort bersama ==");
duaFase(false);
}
/** Tanpa koordinator: langkah pertama menempel, langkah kedua gagal di tengah. */
static void komitLokal() {
List<String> jejak = new ArrayList<>();
jejak.add("svc-penerbangan kommit : kursi 12A dipesan untuk Budi");
jejak.add("svc-tagihan gagal : kartu ditolak, tidak ada yang dikommit");
jejak.add("keadaan akhir : kursi 12A terpakai tanpa pembayaran");
jejak.add("kesimpulan : atomisitas tidak melintasi proses");
for (String j : jejak) {
System.out.println(" " + j);
}
}
/** Two-phase commit terhadap dua peserta. */
static void duaFase(boolean pembayaranSetuju) {
Peserta penerbangan = new Peserta("svc-penerbangan");
Peserta tagihan = new Peserta("svc-tagihan");
// FASE 1 (PREPARE): koordinator mengumpulkan suara semua peserta
boolean suaraTerbang = penerbangan.siapkan("kursi 12A untuk Budi", true);
boolean suaraBayar = tagihan.siapkan("tagih Rp1200000", pembayaranSetuju);
System.out.println(" fase 1 (prepare): penerbangan=" + suaraTerbang
+ ", tagihan=" + suaraBayar);
// FASE 2 (COMMIT/ABORT): satu keputusan untuk semua peserta
if (suaraTerbang && suaraBayar) {
penerbangan.kommit();
tagihan.kommit();
System.out.println(" fase 2 (commit) : kedua perubahan menempel bersama");
} else {
penerbangan.batalkan();
tagihan.batalkan();
System.out.println(" fase 2 (abort) : kedua perubahan dibatalkan bersama");
}
System.out.println(" " + penerbangan.status());
System.out.println(" " + tagihan.status());
}
}Output yang Dihasilkan:
== MASALAH: komit lokal langkah demi langkah ==
svc-penerbangan kommit : kursi 12A dipesan untuk Budi
svc-tagihan gagal : kartu ditolak, tidak ada yang dikommit
keadaan akhir : kursi 12A terpakai tanpa pembayaran
kesimpulan : atomisitas tidak melintasi proses
== 2PC: semua setuju -> kommit bersama ==
fase 1 (prepare): penerbangan=true, tagihan=true
fase 2 (commit) : kedua perubahan menempel bersama
svc-penerbangan: menempel=kursi 12A untuk Budi
svc-tagihan: menempel=tagih Rp1200000
== 2PC: satu menolak -> abort bersama ==
fase 1 (prepare): penerbangan=true, tagihan=false
fase 2 (abort) : kedua perubahan dibatalkan bersama
svc-penerbangan: menempel=null
svc-tagihan: menempel=nullJejak pada blok komitLokal() adalah skenario paling ringan dari kebocoran semacam itu: kursi 12A terbakar tanpa pembayaran sampai proses pembersihan manual datang. Varian lebih buruk ada di sisi sebaliknya — pembayaran berhasil lalu pemesanan kursi gagal: pelanggan sudah ditagih untuk tiket yang tidak pernah terbit. Tanpa pengawas bersama, setiap kegagalan di tengah alur meninggalkan keadaan setengah jadi.
Two-Phase Commit #
Protokol klasik untuk masalah ini adalah 2PC, dikelola oleh satu koordinator (di Java: transaction manager seperti Narayana yang mengimplementasikan Jakarta Transactions/XA). Protokolnya memakai dua putaran pesan:
- Fase 1 — PREPARE. Koordinator bertanya ke setiap peserta: “bisakah kamu menempelkan perubahan ini?” Peserta menulis perubahan ke penyimpanan lokal tanpa mengommit, menahan lock, lalu memilih
YESatauNO. - Fase 2 — COMMIT atau ABORT. Bila seluruh suara
YES, koordinator mengirimCOMMITke semua; bila satu saja menjawabNO, koordinator mengirimABORTke semua.
Kedua skenario dijalankan fungsi duaFase(...) pada program di atas. Mari kita bedah:
class Peserta: setiap peserta memegang dua wadah —tersiap(perubahan fase 1, belum menempel) danmenempel(hasil kommit). Pemisahan dua wadah inilah essence PREPARE: peserta berjanji mengetahui cara menyelesaikan atau membatalkan perubahannya sendiri.siapkan(perubahan, setuju): fase 1. Peserta yang tidak sanggup menjawabfalse— pada kasus kedua,svc-tagihanmenolak karena kartu ditolak.- Keputusan bulat
if (suaraTerbang && suaraBayar): koordinator hanya mengommit bila seluruh suara setuju. Satufalsecukup untuk membatalkan semuanya — itulah sisi atomisitas yang dipulihkan: kedua peserta berakhir pada keadaan yang sama. kommit()danbatalkan(): fase 2. Kommit memindahkantersiapkemenempel; abort membuangnya. Keluaran skenario kedua menunjukkanmenempel=nulldi kedua peserta — kursi tidak terbakar.
Jalan Lain: Konsistensi Eventual #
Alternatif yang dipilih mayoritas sistem modern mengubah bentuk pertanyaannya. Alih-alih “bagaimana mengommit dua database sekaligus?”, pertanyaannya menjadi: “bagaimana menyelesaikan alur panjang yang tiap langkahnya komit lokal, dengan cara membatalkan efeknya bila belakangan gagal?” Ciri sistem begini:
| Aspek | 2PC / XA | Alur berkompensasi (saga) |
|---|---|---|
| Atomisitas | benar-benar serentak | eventual — sejenak tidak konsisten |
| Lock lintas service | ditahan sampai selesai | tidak ada |
| Keadaan di tengah alur | tidak terlihat | terlihat (kursi bisa saja terpesan dulu) |
| Kegagalan koordinator | peserta terblokir | alur lanjut dengan kompensasi |
| Dukungan penyimpanan | perlu XA | cukup transaksi lokal + messaging |
Baris “keadaan di tengah alur terlihat” bukan kelemahan sepele: pelanggan bisa melihat kursinya terpesan walau pembayaran belum selesai, dan pembatalan pesawat nyata butuh langkah kompensasi sungguhan (kursi dilepas, dana dikembalikan) — bukan rollback otomatis lintas proses. Operasi bisnis perlu dirancang untuk perjalanan setengah jadi ini.
Nama pola penyelesaiinya adalah saga: rangkaian transaksi lokal berjalan maju, dengan satu aksi kompensasi untuk tiap langkah yang sudah menempel. Chapter berikutnya membangun saganya lengkap — orchestrator, daftar kompensasi, dan keadaan akhir yang bersih — dalam bentuk yang bisa dijalankan.
Referensi #
- microservices.io: Pola Saga
- microservices.io: Database per Service
- Jakarta EE: Transactions Specification
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
TransaksiLintasService.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
/** Transaksi lintas service: bahaya komit lokal, lalu two-phase commit (2PC) mini. */
public class TransaksiLintasService {
/** Satu service peserta dengan perubahan dua tahap: siapkan lalu kommit/batalkan. */
static class Peserta {
private final String nama;
private String tersiap = null; // perubahan tahap PREPARE (belum menempel)
private String menempel = null; // hasil kommit terakhir
Peserta(String nama) {
this.nama = nama;
}
/** Fase 1: peserta menyiapkan perubahan dan memberi suara. */
boolean siapkan(String perubahan, boolean setuju) {
if (!setuju) {
tersiap = null;
return false;
}
tersiap = perubahan;
return true;
}
/** Fase 2a: kommit menempelkan perubahan yang disiapkan. */
void kommit() {
menempel = tersiap;
tersiap = null;
}
/** Fase 2b: abort membuang perubahan yang disiapkan. */
void batalkan() {
tersiap = null;
}
String status() {
return nama + ": menempel=" + menempel
+ (tersiap != null ? ", tersiap=" + tersiap : "");
}
}
public static void main(String[] args) {
System.out.println("== MASALAH: komit lokal langkah demi langkah ==");
komitLokal();
System.out.println();
System.out.println("== 2PC: semua setuju -> kommit bersama ==");
duaFase(true);
System.out.println();
System.out.println("== 2PC: satu menolak -> abort bersama ==");
duaFase(false);
}
/** Tanpa koordinator: langkah pertama menempel, langkah kedua gagal di tengah. */
static void komitLokal() {
List<String> jejak = new ArrayList<>();
jejak.add("svc-penerbangan kommit : kursi 12A dipesan untuk Budi");
jejak.add("svc-tagihan gagal : kartu ditolak, tidak ada yang dikommit");
jejak.add("keadaan akhir : kursi 12A terpakai tanpa pembayaran");
jejak.add("kesimpulan : atomisitas tidak melintasi proses");
for (String j : jejak) {
System.out.println(" " + j);
}
}
/** Two-phase commit terhadap dua peserta. */
static void duaFase(boolean pembayaranSetuju) {
Peserta penerbangan = new Peserta("svc-penerbangan");
Peserta tagihan = new Peserta("svc-tagihan");
// FASE 1 (PREPARE): koordinator mengumpulkan suara semua peserta
boolean suaraTerbang = penerbangan.siapkan("kursi 12A untuk Budi", true);
boolean suaraBayar = tagihan.siapkan("tagih Rp1200000", pembayaranSetuju);
System.out.println(" fase 1 (prepare): penerbangan=" + suaraTerbang
+ ", tagihan=" + suaraBayar);
// FASE 2 (COMMIT/ABORT): satu keputusan untuk semua peserta
if (suaraTerbang && suaraBayar) {
penerbangan.kommit();
tagihan.kommit();
System.out.println(" fase 2 (commit) : kedua perubahan menempel bersama");
} else {
penerbangan.batalkan();
tagihan.batalkan();
System.out.println(" fase 2 (abort) : kedua perubahan dibatalkan bersama");
}
System.out.println(" " + penerbangan.status());
System.out.println(" " + tagihan.status());
}
}