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:

text
== 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=null

Jejak 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:

  1. 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 YES atau NO.
  2. Fase 2 — COMMIT atau ABORT. Bila seluruh suara YES, koordinator mengirim COMMIT ke semua; bila satu saja menjawab NO, koordinator mengirim ABORT ke semua.

Kedua skenario dijalankan fungsi duaFase(...) pada program di atas. Mari kita bedah:

  1. class Peserta: setiap peserta memegang dua wadah — tersiap (perubahan fase 1, belum menempel) dan menempel (hasil kommit). Pemisahan dua wadah inilah essence PREPARE: peserta berjanji mengetahui cara menyelesaikan atau membatalkan perubahannya sendiri.
  2. siapkan(perubahan, setuju): fase 1. Peserta yang tidak sanggup menjawab false — pada kasus kedua, svc-tagihan menolak karena kartu ditolak.
  3. Keputusan bulat if (suaraTerbang && suaraBayar): koordinator hanya mengommit bila seluruh suara setuju. Satu false cukup untuk membatalkan semuanya — itulah sisi atomisitas yang dipulihkan: kedua peserta berakhir pada keadaan yang sama.
  4. kommit() dan batalkan(): fase 2. Kommit memindahkan tersiap ke menempel; abort membuangnya. Keluaran skenario kedua menunjukkan menempel=null di 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:

Aspek2PC / XAAlur berkompensasi (saga)
Atomisitasbenar-benar serentakeventual — sejenak tidak konsisten
Lock lintas serviceditahan sampai selesaitidak ada
Keadaan di tengah alurtidak terlihatterlihat (kursi bisa saja terpesan dulu)
Kegagalan koordinatorpeserta terblokiralur lanjut dengan kompensasi
Dukungan penyimpananperlu XAcukup 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 #

Berkas Sumber

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

TransaksiLintasService.java

Unduh berkas
java
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());
    }
}