DB Transaction #
Data yang rusak setengah-jalan adalah salah satu bug yang paling sulit di-debug dan paling mahal diperbaiki. User sudah membayar tapi status order masih pending. Saldo sudah terpotong tapi produk tidak dikirim. Invoice sudah dibuat tapi item tidak terinventaris. Semua ini adalah konsekuensi dari operasi database yang berjalan tanpa transaction yang benar. Transaction adalah mekanisme yang memastikan sekumpulan perubahan database terjadi sebagai satu kesatuan: semua berhasil, atau tidak ada yang berubah sama sekali. Artikel ini membahas transaction dari prinsip ACID, isolation level dan implikasinya, cara implementasi yang benar, apa yang sebaiknya tidak ada di dalam transaction, sampai pola untuk distributed transaction.
Masalah yang Diselesaikan Transaction #
Untuk memahami mengapa transaction penting, pertimbangkan operasi checkout sederhana yang melibatkan tiga langkah:
flowchart TD
Start["Checkout Request"]
subgraph NoTx["Tanpa Transaction"]
N1["1. Kurangi stok produk ✓"]
N2["2. Buat order record ✓"]
N3["3. Proses pembayaran ✗ GAGAL"]
NResult["State: stok berkurang, order ada,\npembayaran tidak ada\n→ DATA RUSAK SETENGAH JALAN"]
N1 --> N2 --> N3 --> NResult
end
subgraph WithTx["Dengan Transaction"]
W1["1. Kurangi stok produk ✓"]
W2["2. Buat order record ✓"]
W3["3. Proses pembayaran ✗ GAGAL"]
Rollback["ROLLBACK semua perubahan"]
WResult["State: tidak ada perubahan\n→ KONSISTEN"]
W1 --> W2 --> W3 --> Rollback --> WResult
end
Start --> NoTx
Start --> WithTx
style NResult fill:#E74C3C,color:#fff
style WResult fill:#27AE60,color:#fff
style Rollback fill:#E67E22,color:#fffTanpa transaction, kegagalan di langkah ketiga meninggalkan sistem dalam keadaan inkonsisten yang tidak ada yang mendeteksi secara otomatis. Dengan transaction, kegagalan apapun di salah satu langkah mengakibatkan semua perubahan dibatalkan sekaligus.
Properti ACID — Fondasi Reliability #
ACID adalah empat properti yang mendefinisikan perilaku transaction yang dapat diandalkan. Memahami masing-masing membantu memahami mengapa transaction bekerja seperti yang diharapkan.
Atomicity — Semua atau Tidak Sama Sekali #
Seluruh operasi dalam satu transaction diperlakukan sebagai satu unit yang tidak bisa dibagi. Jika satu operasi gagal, semua operasi sebelumnya dalam transaction yang sama dibatalkan.
Contoh atomicity:
Transaction: Kurangi saldo A, Tambah saldo B (transfer)
Skenario 1: Keduanya berhasil → COMMIT, kedua perubahan tersimpan
Skenario 2: Kurangi A berhasil, tambah B gagal → ROLLBACK, saldo A kembali
Skenario 3: Server crash di tengah → setelah restart, tidak ada perubahan tersimpan
Consistency — Dari State Valid ke State Valid #
Database selalu berada dalam kondisi valid sebelum dan setelah transaction. Constraint, foreign key, dan aturan bisnis yang didefinisikan di level database tidak bisa dilanggar oleh transaction.
Contoh consistency:
Constraint: saldo tidak boleh negatif (CHECK balance >= 0)
Transaction: kurangi saldo dari 100 menjadi -50
→ Database menolak COMMIT karena melanggar constraint
→ Rollback otomatis
→ Saldo tetap 100
Isolation — Transaction Tidak Saling Mengganggu #
Perubahan yang dibuat oleh satu transaction yang belum di-commit tidak terlihat oleh transaction lain (tergantung isolation level). Ini mencegah berbagai anomali concurrency.
Tanpa isolation yang cukup:
Dirty Read: Transaction A membaca data yang sedang diubah Transaction B
tapi B belum commit. Jika B rollback, A sudah pakai data yang salah.
Lost Update: Transaction A dan B keduanya baca saldo 100.
A update ke 150, B update ke 80. Update A hilang.
Phantom Read: Transaction A hitung jumlah record.
Transaction B insert record baru.
Transaction A hitung lagi, hasilnya berbeda.
Durability — Commit Tidak Bisa Hilang #
Data yang sudah di-commit akan tersimpan secara permanen, bahkan jika terjadi crash sesaat setelah commit. Database menggunakan write-ahead log (WAL) untuk memastikan ini.
Isolation Level — Pilihan yang Menentukan Behavior #
Ini adalah bagian yang paling sering disalahpahami. Database menyediakan beberapa isolation level sebagai trade-off antara konsistensi dan performa. Level yang lebih tinggi memberikan isolasi lebih baik tapi dengan locking yang lebih agresif.
flowchart LR
subgraph Levels["Isolation Levels — dari rendah ke tinggi"]
RU["READ UNCOMMITTED\nBisa baca data belum di-commit\nTidak digunakan di production"]
RC["READ COMMITTED\nHanya baca data sudah di-commit\nDefault di PostgreSQL\nAman untuk kebanyakan use case"]
RR["REPEATABLE READ\nData yang dibaca konsisten selama transaction\nDefault di MySQL/InnoDB\nCocok untuk laporan dan financial"]
SR["SERIALIZABLE\nTransaction berjalan seolah serial\nPaling aman, paling lambat\nHanya untuk use case yang sangat kritis"]
end
RU -->|"Lebih ketat"| RC
RC -->|"Lebih ketat"| RR
RR -->|"Lebih ketat"| SR
style RU fill:#E74C3C,color:#fff
style RC fill:#27AE60,color:#fff
style RR fill:#E67E22,color:#fff
style SR fill:#8E44AD,color:#fffPanduan memilih isolation level:
READ COMMITTED (default PostgreSQL):
Cocok untuk: Mayoritas operasi CRUD biasa
Trade-off: Non-repeatable read bisa terjadi (data bisa berubah di tengah transaction)
Gunakan jika: Tidak ada kebutuhan untuk membaca data yang sama dua kali
dalam satu transaction dengan ekspektasi hasilnya sama
REPEATABLE READ (default MySQL):
Cocok untuk: Laporan, kalkulasi finansial, operasi yang butuh snapshot konsisten
Trade-off: Lebih banyak locking, lebih lambat di concurrent workload
Gunakan jika: Membaca data yang sama lebih dari sekali dan hasilnya harus konsisten
"Baca saldo sekarang untuk kalkulasi, pastikan nilainya tidak berubah"
SERIALIZABLE:
Cocok untuk: Operasi yang paling kritis di mana correctness mutlak diperlukan
Trade-off: Sangat agresif dalam locking, throughput bisa jauh menurun
Gunakan jika: Benar-benar tidak ada alternatif lain
Contoh: alokasi nomor seri yang harus unik secara absolut
Jangan pernah menggunakan isolation level yang lebih tinggi dari yang dibutuhkan dengan alasan “lebih aman”. SERIALIZABLE dengan traffic tinggi bisa menyebabkan banyak transaction gagal karena serialization conflict, yang justru merusak reliability sistem. Pilih level terendah yang masih memberikan garansi yang dibutuhkan.
Implementasi Transaction yang Benar #
Pola Dasar di Go #
// ANTI-PATTERN: Transaction yang tidak di-handle dengan benar
func (s *OrderService) CreateOrder(input CreateOrderInput) error {
tx, _ := s.db.Begin() // tidak handle error Begin()
s.db.Exec("INSERT INTO orders ...") // menggunakan db langsung, bukan tx!
tx.Exec("INSERT INTO order_items ...")
tx.Commit() // tidak handle error Commit()
return nil
}
// BENAR: Transaction dengan error handling yang lengkap
func (s *OrderService) CreateOrder(ctx context.Context, input CreateOrderInput) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// defer rollback — akan no-op jika sudah commit
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-panic setelah rollback
} else if err != nil {
tx.Rollback()
}
}()
// Gunakan tx, bukan s.db, untuk semua operasi dalam transaction
order := &Order{UserID: input.UserID, Total: input.Total}
if err = insertOrder(ctx, tx, order); err != nil {
return fmt.Errorf("insert order: %w", err)
}
for _, item := range input.Items {
if err = insertOrderItem(ctx, tx, order.ID, item); err != nil {
return fmt.Errorf("insert order item: %w", err)
}
}
if err = deductInventory(ctx, tx, input.Items); err != nil {
return fmt.Errorf("deduct inventory: %w", err)
}
// Commit hanya jika semua berhasil
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
Pola Transaction dengan Helper Function #
Untuk menghindari boilerplate error handling yang berulang:
// Helper function untuk menjalankan fungsi dalam transaction
func WithTransaction(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("tx err: %v, rollback err: %v", err, rbErr)
}
return err
}
return tx.Commit()
}
// Penggunaan yang bersih
func (s *OrderService) CreateOrder(ctx context.Context, input CreateOrderInput) error {
return WithTransaction(ctx, s.db, func(tx *sql.Tx) error {
order, err := insertOrder(ctx, tx, input)
if err != nil {
return err
}
if err := insertOrderItems(ctx, tx, order.ID, input.Items); err != nil {
return err
}
return deductInventory(ctx, tx, input.Items)
})
}
Apa yang Boleh dan Tidak Boleh Ada dalam Transaction #
Ini adalah kesalahan yang paling sering ditemukan: memasukkan operasi yang tidak seharusnya ke dalam transaction.
Yang Cocok dalam Transaction #
✓ INSERT, UPDATE, DELETE yang saling bergantung secara logis
✓ State transition (pending → processing → completed)
✓ Operasi yang melibatkan beberapa tabel yang harus konsisten bersama
✓ Deduction dan insertion yang saling terkait (kurangi stok, tambah order)
✓ Idempotency check dan insert (untuk mencegah duplicate)
Yang TIDAK Boleh dalam Transaction #
✗ HTTP call ke service eksternal (payment gateway, SMS gateway, dll)
✗ Kirim email atau push notification
✗ Upload file ke object storage (S3, GCS)
✗ Loop besar atau heavy computation
✗ Cache invalidation atau cache write
✗ Publish event ke message queue
Mengapa ini penting:
// ANTI-PATTERN: External I/O dalam transaction
func (s *OrderService) CreateOrder(ctx context.Context, input CreateOrderInput) error {
tx, _ := s.db.BeginTx(ctx, nil)
defer tx.Rollback()
// Insert order ke DB
order, err := insertOrder(ctx, tx, input)
if err != nil { return err }
// BAHAYA: HTTP call ke payment gateway di dalam transaction!
paymentResult, err := s.paymentGateway.Charge(input.Amount)
if err != nil {
// Transaction sudah dibuka selama request ke payment gateway (bisa 3–10 detik!)
// Lock database masih ada selama waktu ini
return err
}
return tx.Commit()
}
// Konsekuensi: transaction terbuka selama 3-10 detik menunggu response payment
// Lock table/row menghalangi request lain yang ingin modify data yang sama
// Potensi deadlock meningkat signifikan
// BENAR: Pisahkan external I/O dari transaction
func (s *OrderService) CreateOrder(ctx context.Context, input CreateOrderInput) error {
// Langkah 1: Buat order dengan status "pending" — dalam transaction, cepat
var orderID string
err := WithTransaction(ctx, s.db, func(tx *sql.Tx) error {
order, err := insertOrder(ctx, tx, input)
if err != nil { return err }
orderID = order.ID
return insertOrderItems(ctx, tx, order.ID, input.Items)
})
if err != nil {
return err
}
// Langkah 2: Charge payment — di luar transaction
paymentResult, err := s.paymentGateway.Charge(input.Amount)
if err != nil {
// Update order status ke "payment_failed" — transaction baru, cepat
_ = s.updateOrderStatus(ctx, orderID, "payment_failed")
return err
}
// Langkah 3: Konfirmasi order — transaction baru yang singkat
return WithTransaction(ctx, s.db, func(tx *sql.Tx) error {
return confirmOrderWithPayment(ctx, tx, orderID, paymentResult.ID)
})
}
Deadlock — Penyebab dan Pencegahan #
Deadlock terjadi ketika dua atau lebih transaction saling menunggu lock yang dipegang oleh transaction lainnya.
sequenceDiagram
participant T1 as Transaction 1
participant TA as Row A
participant TB as Row B
participant T2 as Transaction 2
T1->>TA: Lock Row A ✓
T2->>TB: Lock Row B ✓
T1->>TB: Minta lock Row B... tunggu
T2->>TA: Minta lock Row A... tunggu
Note over T1,T2: DEADLOCK — keduanya menunggu selamanya
Note over T1,T2: Database mendeteksi dan membatalkan salah satuCara mencegah deadlock:
1. Akses resource dengan urutan yang konsisten
// Jika perlu lock user A dan user B, selalu lock dengan ID lebih kecil dulu
userIDs := []string{recipientID, senderID}
sort.Strings(userIDs) // urutkan terlebih dahulu
for _, id := range userIDs {
lockUser(ctx, tx, id)
}
2. Keep transaction short
→ Transaction yang singkat = lock dipegang lebih sebentar = lebih kecil kemungkinan conflict
3. Gunakan SELECT FOR UPDATE dengan bijak
→ Hanya lock row yang benar-benar akan dimodifikasi
→ Jangan lock row yang hanya dibaca
4. Retry saat terjadi deadlock
→ Database akan mendeteksi dan rollback salah satu transaction
→ Implementasi retry dengan exponential backoff
for attempt := 0; attempt < maxRetries; attempt++ {
err := WithTransaction(ctx, db, fn)
if isDeadlockError(err) {
time.Sleep(backoffDuration(attempt))
continue
}
return err
}
Saga Pattern — Untuk Distributed Transaction #
Ketika operasi bisnis melibatkan beberapa service yang berbeda (microservices), satu database transaction tidak bisa mencakup semuanya. Saga pattern adalah solusinya.
flowchart TD
Start["Checkout Request"]
subgraph Saga["Saga: Checkout Flow"]
S1["Step 1: Order Service\nBuat order → status: pending"]
S2["Step 2: Inventory Service\nKurangi stok"]
S3["Step 3: Payment Service\nProses pembayaran"]
S4["Step 4: Order Service\nUpdate status → completed"]
end
subgraph Compensate["Compensating Transactions\n(jika terjadi kegagalan)"]
C3["Batalkan pembayaran\n(jika step 4 gagal)"]
C2["Kembalikan stok\n(jika step 3 gagal)"]
C1["Batalkan order\n(jika step 2 gagal)"]
end
Start --> S1 --> S2 --> S3 --> S4
S4 -->|"Gagal"| C3
S3 -->|"Gagal"| C2
S2 -->|"Gagal"| C1
style C1 fill:#E74C3C,color:#fff
style C2 fill:#E74C3C,color:#fff
style C3 fill:#E74C3C,color:#fffDua implementasi Saga:
1. Choreography-based Saga:
Setiap service publish event dan subscribe ke event service lain
→ Tidak ada central coordinator
→ Lebih sederhana tapi lebih sulit di-debug
2. Orchestration-based Saga:
Ada central orchestrator yang memanggil setiap service secara berurutan
dan menangani compensating transaction jika ada kegagalan
→ Lebih mudah di-trace dan di-debug
→ Ada single point of failure (orchestrator)
Prinsip Saga:
→ Setiap step harus idempotent (bisa di-retry tanpa double effect)
→ Setiap step harus punya compensating transaction yang jelas
→ Eventual consistency, bukan immediate consistency
Anti-Pattern Transaction yang Harus Dihindari #
Transaction yang Terlalu Lama #
// ✗ ANTI-PATTERN: Transaction dibuka di awal request, commit di akhir
func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
tx, _ := h.db.Begin()
// ... banyak logic, beberapa menit ...
// ... validasi input (tidak perlu dalam transaction) ...
// ... fetch data dari berbagai tabel ...
// ... kalkulasi kompleks ...
// ... external API call (SANGAT SALAH) ...
tx.Exec("INSERT INTO results ...")
tx.Commit()
}
// Lock database dipegang selama seluruh request processing!
// ✓ Solusi: Buka transaction sesaat sebelum write, commit setelah write selesai
func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
// Validasi dan fetching data tanpa transaction
input := parseAndValidate(r)
data := h.fetchRequiredData(ctx, input)
// Hanya bagian write yang dalam transaction, dan dibuat sesingkat mungkin
err := WithTransaction(ctx, h.db, func(tx *sql.Tx) error {
return h.writeResults(ctx, tx, data)
})
}
Mengabaikan Error Dari Commit #
// ✗ ANTI-PATTERN: Error commit diabaikan
tx.Commit() // jika ini gagal, kita tidak tahu
// ✓ Solusi: Error commit harus ditangani
if err := tx.Commit(); err != nil {
// Commit bisa gagal karena serialization conflict, network issue, dll
log.Error("transaction commit failed", "error", err)
return fmt.Errorf("failed to commit: %w", err)
}
Nested Transaction yang Ambigu #
// ✗ ANTI-PATTERN: Nested transaction yang tidak jelas batasnya
func (s *Service) OperationA(ctx context.Context) error {
tx, _ := s.db.Begin()
// ...
s.OperationB(ctx) // OperationB juga membuka transaction sendiri!
tx.Commit()
}
// ✓ Solusi: Pass transaction sebagai parameter, atau gunakan pola UoW
func (s *Service) OperationA(ctx context.Context) error {
return WithTransaction(ctx, s.db, func(tx *sql.Tx) error {
if err := s.doPartA(ctx, tx); err != nil { return err }
return s.doPartB(ctx, tx) // gunakan tx yang sama
})
}
Checklist DB Transaction #
DESAIN TRANSACTION:
□ Setiap transaction merepresentasikan satu unit of work yang jelas
□ Transaction sessingkat mungkin — open late, commit early
□ External I/O (HTTP call, email, file upload) di LUAR transaction
□ Isolation level dipilih berdasarkan kebutuhan, bukan default
IMPLEMENTASI:
□ Semua error dari Begin() dan Commit() ditangani
□ Semua operasi dalam transaction menggunakan tx, bukan db langsung
□ defer Rollback() dipasang segera setelah Begin() berhasil
□ Tidak ada naked recover() yang menyembunyikan error transaction
ERROR HANDLING:
□ Rollback dipanggil jika ada error di tengah transaction
□ Error dari Rollback juga di-log (meski jarang bisa dilakukan banyak)
□ Deadlock error ditangani dengan retry dan backoff
CONCURRENCY:
□ Urutan lock konsisten untuk mencegah deadlock
□ SELECT FOR UPDATE hanya pada row yang akan dimodifikasi
□ Concurrent scenario sudah ditest (tidak hanya unit test sequential)
DATABASE CONSTRAINT:
□ UNIQUE constraint ada untuk data yang harus unik
□ FOREIGN KEY constraint untuk relasi yang harus valid
□ CHECK constraint untuk aturan nilai yang harus selalu terpenuhi
□ NOT NULL untuk field yang tidak boleh kosong
OBSERVABILITY:
□ Transaction error di-log dengan context yang cukup
□ Long-running transaction di-alert (query yang berjalan > threshold)
□ Deadlock rate dipantau
Ringkasan #
- Transaction memastikan semua atau tidak sama sekali — jika satu operasi gagal di tengah, semua perubahan dalam transaction yang sama dibatalkan. Ini mencegah partial update yang menyebabkan data inkonsisten.
- ACID adalah garansi, bukan fitur — Atomicity, Consistency, Isolation, dan Durability adalah properti yang harus dipahami, bukan hanya dihafalkan. Masing-masing menyelesaikan kelas masalah yang berbeda.
- Pilih isolation level yang tepat, bukan yang paling tinggi — READ COMMITTED untuk mayoritas use case, REPEATABLE READ untuk kalkulasi finansial yang butuh snapshot konsisten. SERIALIZABLE hampir selalu berlebihan dan merusak throughput.
- External I/O tidak boleh ada dalam transaction — HTTP call, email, file upload di dalam transaction menyebabkan lock dipegang lebih lama, deadlock lebih sering, dan throughput menurun. Pisahkan external I/O dari database operation.
- Open late, commit early — jangan buka transaction di awal request dan commit di akhir. Buka transaction sesaat sebelum write dimulai, commit segera setelah semua write selesai.
- Selalu handle error dari Commit() — Commit bisa gagal karena serialization conflict atau network issue. Mengabaikan error dari Commit berarti mungkin tidak menyadari bahwa data tidak tersimpan.
- defer Rollback() segera setelah Begin() — pola ini memastikan rollback selalu dipanggil jika ada error, bahkan panic yang tidak terduga.
- Deadlock adalah kondisi yang perlu di-handle, bukan dihindari sepenuhnya — implementasikan retry dengan exponential backoff untuk serialization error dan deadlock. Pastikan urutan pengambilan lock konsisten.
- Database constraint adalah partner transaction — transaction menjaga atomicity, constraint menjaga consistency. Keduanya saling melengkapi dan tidak bisa menggantikan satu sama lain.
- Distributed transaction butuh Saga pattern — ketika operasi melibatkan beberapa service, gunakan Saga dengan compensating transactions alih-alih mencoba membuat satu transaction yang span ke banyak database.