Checkout satu pesanan menyentuh dua database: service pesanan menulis order baru, service stok memotong persediaan. Ketika penulisan pertama sukses lalu penulisan kedua gagal, data menjadi tidak konsisten — pesanan tercatat tetapi stok tidak berkurang. Pada satu database, masalah ini selesai oleh transaksi BEGIN-COMMIT; pada dua node terpisah, tidak ada jurnal bersama yang bisa mengembalikan kedua penulisan sekaligus. Distributed transactions adalah keluarga protokol untuk menjaga sifat semua-atau-tidak-sama-sekali lintas node. Bab ini membahas two-phase commit sebagai protokol klasiknya, kelemahan blokirnya, lalu pola saga yang menggantikannya di arsitektur microservices.

Atomicity yang Dulu Gratis #

Sebelum melintasi node, mari kita lihat apa yang diberikan satu database secara gratis. Skrip berikut menjalankan transfer antar rekening lalu menghentikan proses di tengah jalan — sebelum COMMIT. Pada mode transaksi eksplisit, recovery membatalkan seluruh transaksi; pada mode autocommit, setiap statement langsung permanen sehingga debit ditinggalkan tanpa kredit. Detail perilaku transaksi ada di dokumentasi sqlite3 Python .

python
"""Kontras atomicity lokal: transaksi eksplisit vs autocommit pada SQLite."""
import sqlite3
from pathlib import Path

DB = "transfer.db"
Path(DB).unlink(missing_ok=True)  # mulai dari berkas bersih tiap eksekusi
koneksi = sqlite3.connect(DB)
koneksi.executescript("""
CREATE TABLE rekening (nama TEXT PRIMARY KEY, saldo INTEGER NOT NULL);
INSERT INTO rekening VALUES ('Budi', 500000), ('Siti', 200000);
""")

print("=== Transaksi eksplisit, proses berhenti sebelum COMMIT ===")
koneksi.execute("BEGIN")
koneksi.execute("UPDATE rekening SET saldo = saldo - 100000 WHERE nama = 'Budi'")
koneksi.rollback()  # recovery: transaksi tanpa COMMIT dibatalkan utuh
print("  setelah recovery:", ", ".join(
    f"{n}: {s}" for n, s in koneksi.execute("SELECT nama, saldo FROM rekening")))

print("=== Autocommit, proses berhenti setelah debit ===")
koneksi.isolation_level = None  # setiap statement langsung permanen
koneksi.execute("UPDATE rekening SET saldo = saldo - 100000 WHERE nama = 'Budi'")
print("  setelah recovery:", ", ".join(
    f"{n}: {s}" for n, s in koneksi.execute("SELECT nama, saldo FROM rekening")))
koneksi.close()

Output yang Dihasilkan:

text
=== Transaksi eksplisit, proses berhenti sebelum COMMIT ===
  setelah recovery: Budi: 500000, Siti: 200000
=== Autocommit, proses berhenti setelah debit ===
  setelah recovery: Budi: 400000, Siti: 200000

Atomicity lokal bekerja karena satu jurnal tunggal mencatat seluruh perubahan. Begitu penulisan tersebar di dua database, masing-masing punya jurnal sendiri dan tidak ada satu pihak yang tahu kondisi pihak lain — di sinilah protokol koordinasi diperlukan.

Two-Phase Commit #

Two-phase commit (2PC) menambahkan satu koordinator di atas para participant. Fase pertama adalah voting: coordinator mengirim PREPARE, tiap participant memvalidasi, menuliskan janji ke jurnal lokal, lalu menjawab YES atau NO. Fase kedua adalah eksekusi keputusan: semua YES berarti COMMIT untuk semua, satu NO berarti ABORT untuk semua. PostgreSQL menyediakan protokol ini lewat perintah PREPARE TRANSACTION yang didokumentasikan di dokumentasi PostgreSQL . Simulasi berikut menjalankan tiga skenario, termasuk kegagalan coordinator.

python
"""Simulasi two-phase commit (2PC): coordinator, dua participant, tiga skenario."""


class Participant:
    """Meniru database peserta transaksi."""

    def __init__(self, nama, lolos_validasi=True):
        self.nama = nama
        self.lolos = lolos_validasi
        self.status = "siaga"   # siaga | prepared | committed | aborted
        self.kunci = None       # data dikunci selama prepared

    def prepare(self, kunci):
        """Fase 1: participant memvalidasi dan menjanjikan kesediaan commit."""
        if self.lolos:
            self.status, self.kunci = "prepared", kunci
            return "YES"
        self.status = "aborted"
        return "NO"

    def commit(self):
        self.status, self.kunci = "committed", None

    def abort(self):
        self.status, self.kunci = "aborted", None


def dua_fasa(nama, koordinator_hidup=True, stok_lolos=True):
    print(f"=== {nama} ===")
    peserta = [Participant("pesanan-db"), Participant("stok-db", stok_lolos)]

    print("Fase 1 (prepare/voting):")
    for p in peserta:
        print(f"  PREPARE {p.nama:12s} -> {p.prepare('checkout#77')} (status={p.status})")

    if "aborted" in (p.status for p in peserta):
        for p in peserta:
            p.abort()
        print("Fase 2: keputusan ABORT ->",
              ", ".join(f"{p.nama}: {p.status}" for p in peserta))
    elif koordinator_hidup:
        for p in peserta:
            p.commit()
        print("Fase 2: keputusan COMMIT ->",
              ", ".join(f"{p.nama}: {p.status}" for p in peserta))
    else:
        print("Fase 2: coordinator berhenti sebelum menulis keputusan")
        for p in peserta:
            print(f"  {p.nama:12s} -> status={p.status}, kunci={p.kunci} "
                  "(BLOKIR: tak bisa commit maupun abort)")


dua_fasa("Skenario 1: semua peserta setuju")
dua_fasa("Skenario 2: stok-db menolak (validasi gagal)", stok_lolos=False)
dua_fasa("Skenario 3: coordinator berhenti setelah voting", koordinator_hidup=False)

Output yang Dihasilkan:

text
=== Skenario 1: semua peserta setuju ===
Fase 1 (prepare/voting):
  PREPARE pesanan-db   -> YES (status=prepared)
  PREPARE stok-db      -> YES (status=prepared)
Fase 2: keputusan COMMIT -> pesanan-db: committed, stok-db: committed
=== Skenario 2: stok-db menolak (validasi gagal) ===
Fase 1 (prepare/voting):
  PREPARE pesanan-db   -> YES (status=prepared)
  PREPARE stok-db      -> NO (status=aborted)
Fase 2: keputusan ABORT -> pesanan-db: aborted, stok-db: aborted
=== Skenario 3: coordinator berhenti setelah voting ===
Fase 1 (prepare/voting):
  PREPARE pesanan-db   -> YES (status=prepared)
  PREPARE stok-db      -> YES (status=prepared)
Fase 2: coordinator berhenti sebelum menulis keputusan
  pesanan-db   -> status=prepared, kunci=checkout#77 (BLOKIR: tak bisa commit maupun abort)
  stok-db      -> status=prepared, kunci=checkout#77 (BLOKIR: tak bisa commit maupun abort)

Mari kita telaah baris-baris kode di atas:

  1. Baris 4-11 (Participant.__init__): tiap peserta menyimpan status (siaga, prepared, committed, aborted) dan kunci yang dipegang selama prepared — meniru baris database yang terkunci menunggu keputusan.
  2. Baris 13-19 (prepare): fase 1 — participant memvalidasi; lolos berarti menulis janji dan menjawab YES, gagal berarti menjawab NO.
  3. Baris 32-45 (voting dan keputusan): coordinator mengirim PREPARE ke semua participant; status prepared menandakan kunci sudah dipegang. Satu suara NO cukup untuk meng-abort semuanya, semua YES berarti koordinator mengirim COMMIT ke semua.
  4. Baris 46-55 (jalur blokir dan skenario): coordinator berhenti setelah voting, sehingga participant yang menjawab YES terjebak memegang kunci karena sudah berjanji siap commit; tiga skenario di akhir berkas mengeksekusi setuju penuh, penolakan, dan kegagalan coordinator.

Skenario 3 adalah kelemahan struktural 2PC: participant yang menjawab YES tidak boleh memutuskan sendiri, karena keputusan COMMIT mungkin sudah ditulis coordinator sebelum berhenti. Selama menunggu, kunci tidak dilepas dan kapasitas participant terpakai sia-sia. Inilah alasan utama microservices modern menghindari 2PC untuk alur lintas service yang panjang.

Pola Saga #

Saga menyerah dalam hal atomik, lalu membelinya dengan kompensasi: alur dipecah menjadi transaksi lokal singkat, dan tiap langkah punya aksi pembatal. Bila seluruh langkah berhasil, saga cukup berjalan lurus sampai tuntas; bila satu langkah gagal, aksi pembatal dijalankan mundur untuk langkah-langkah yang sudah selesai.

AspekTwo-phase commitSaga
Sifat hasilatomik: semua atau tidak sama sekalikonsisten pada akhirnya lewat kompensasi
Kunci lintas nodeya, selama fase preparedtidak, tiap langkah transaksi lokal singkat
Kegagalan coordinatorparticipant memblokirlangkah berhenti, kompensasi berjalan
Cocok untukdatabase internal satu sistemalur lintas service atau lintas perusahaan
python
"""Simulasi saga: checkout tiga langkah dengan aksi kompensasi bila satu langkah gagal."""

LANGKAH = [("buat_pesanan", "batalkan_pesanan"), ("potong_stok", "kembalikan_stok"),
           ("charge_kartu", "refund_kartu")]


def jalankan_saga(gagal_di=None):
    print(f"=== Saga checkout (gagal di: {gagal_di or 'tidak ada'}) ===")
    selesai = []
    for nama, kompensasi in LANGKAH:
        if nama == gagal_di:
            print(f"  langkah {nama}: GAGAL")
            for dilakukan, aksi in reversed(selesai):
                print(f"  kompensasi {aksi} (membatalkan {dilakukan})")
            print("  saga berakhir: dibatalkan, data kembali konsisten")
            return
        print(f"  langkah {nama}: sukses")
        selesai.append((nama, kompensasi))
    print("  saga tuntas: pesanan dibuat, stok terpotong, kartu terbayar")


jalankan_saga("charge_kartu")

Output yang Dihasilkan:

text
=== Saga checkout (gagal di: charge_kartu) ===
  langkah buat_pesanan: sukses
  langkah potong_stok: sukses
  langkah charge_kartu: GAGAL
  kompensasi kembalikan_stok (membatalkan potong_stok)
  kompensasi batalkan_pesanan (membatalkan buat_pesanan)
  saga berakhir: dibatalkan, data kembali konsisten

Output tersebut menunjukkan inti saga: kegagalan charge_kartu memicu kembalikan_stok lalu batalkan_pesanan — urutan mundur persis seperti reversed(selesai). Tidak ada node yang memblokir; hanya butuh setiap aksi kompensasi benar-benar idempotent, karena kompensasi sendiri bisa gagal lalu diulang.

Koordinasi, lokalitas, paralelisme, dan kini transaksi — setiap bab memilih mekanisme dan membayar harganya. Chapter Trade-off Distributed Systems merangkum seluruh pilihan tersebut ke dalam satu kerangka keputusan.