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
.
"""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:
=== Transaksi eksplisit, proses berhenti sebelum COMMIT ===
setelah recovery: Budi: 500000, Siti: 200000
=== Autocommit, proses berhenti setelah debit ===
setelah recovery: Budi: 400000, Siti: 200000Atomicity 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.
"""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:
=== 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:
- Baris 4-11 (
Participant.__init__): tiap peserta menyimpan status (siaga,prepared,committed,aborted) dan kunci yang dipegang selamaprepared— meniru baris database yang terkunci menunggu keputusan. - Baris 13-19 (
prepare): fase 1 — participant memvalidasi; lolos berarti menulis janji dan menjawabYES, gagal berarti menjawabNO. - Baris 32-45 (voting dan keputusan): coordinator mengirim
PREPAREke semua participant; statuspreparedmenandakan kunci sudah dipegang. Satu suaraNOcukup untuk meng-abortsemuanya, semuaYESberarti koordinator mengirimCOMMITke semua. - Baris 46-55 (jalur blokir dan skenario): coordinator berhenti setelah voting, sehingga participant yang menjawab
YESterjebak 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.
| Aspek | Two-phase commit | Saga |
|---|---|---|
| Sifat hasil | atomik: semua atau tidak sama sekali | konsisten pada akhirnya lewat kompensasi |
| Kunci lintas node | ya, selama fase prepared | tidak, tiap langkah transaksi lokal singkat |
| Kegagalan coordinator | participant memblokir | langkah berhenti, kompensasi berjalan |
| Cocok untuk | database internal satu sistem | alur lintas service atau lintas perusahaan |
"""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:
=== 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 konsistenOutput 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.