Transfer uang antar dua rekening harus bersifat semua-atau-tidak-terjadi: bila penarikan berhasil tetapi penyetoran gagal, uang menguap tanpa jejak. Transaction adalah fitur database yang menjalankan rangkaian operasi seperti itu sebagai satu kesatuan yang utuh. Package database/sql menyediakan transaksi melalui Begin, Commit, dan Rollback. Pada chapter ini kita bedah jaminan ACID, mempelajari pola defer tx.Rollback(), lalu membuktikan semantik rollback lewat simulasi in-memory tanpa database aktif.

Empat Jaminan ACID #

Setiap huruf pada ACID adalah satu jaminan berbeda yang diberikan database kepada transaksi kita:

HurufJaminanArti praktis
A — AtomicitySemua atau tidak sama sekaliBila satu statement gagal, seluruh transaksi dibatalkan
C — ConsistencyState selalu validConstraint dan foreign key terjaga di akhir transaksi
I — IsolationTransaksi tidak saling mengintipPerubahan belum terlihat database lain sampai Commit
D — DurabilityCommit = permanenData yang di-commit selamat dari crash database

Transfer dua rekening memakai jaminan A: kedua statement UPDATE sama-sama masuk atau sama-sama dibuang, dan jaminan I menjaga transaksi paralel tidak membaca saldo setengah terpotong. Tanpa transaksi, kita harus menulis kode pemulihan manual — kode yang hampir selalu salah.

Transaksi dengan database/sql #

Pola standar transaksi di database/sql terdiri dari tiga langkah: Begin() memulai transaksi, semua query dijalankan lewat objek *sql.Tx, dan Commit() menutupnya. Karena chapter ini berjalan tanpa PostgreSQL aktif, fragmen berikut menunjukkan pola nyatanya; aplikasi lengkap ada pada proyek REST API PostgreSQL :

go
func Transfer(db *sql.DB, dari, ke string, jumlah int) error {
        tx, err := db.Begin() // ambil satu koneksi dan mulai transaksi
        if err != nil {
                return fmt.Errorf("begin: %w", err)
        }
        defer tx.Rollback() // aman: no-op setelah Commit sukses

        if _, err := tx.Exec("UPDATE rekening SET saldo = saldo - $1 WHERE pemilik = $2", jumlah, dari); err != nil {
                return fmt.Errorf("tarik: %w", err)
        }
        if _, err := tx.Exec("UPDATE rekening SET saldo = saldo + $1 WHERE pemilik = $2", jumlah, ke); err != nil {
                return fmt.Errorf("setor: %w", err)
        }
        return tx.Commit() // satu-satunya titik perubahan menjadi permanen
}

Fungsi Begin() mengembalikan *sql.Tx, bukan *sql.DB. Objek tx mengikat satu koneksi dari pool selama transaksi berjalan, sehingga semua statement mengalir melalui sesi database yang sama.

Baris defer tx.Rollback() adalah asuransi: bila fungsi kembali lebih awal karena error, Rollback pasti terpanggil dan koneksi kembali ke pool. Setelah Commit() sukses, pemanggilan Rollback() menjadi no-op karena *sql.Tx menandai dirinya selesai. Tanpa defer ini, satu return err yang terlupakan membuat koneksi tertahan selamanya.

go
// SALAH: query lewat db berjalan di luar transaksi
db.Exec("UPDATE rekening SET saldo = saldo - $1 WHERE pemilik = $2", jumlah, dari)

// benar: query lewat tx ikut transaksi
tx.Exec("UPDATE rekening SET saldo = saldo + $1 WHERE pemilik = $2", jumlah, ke)

Siklus Hidup Transaksi #

State transaksi hanya punya dua jalan keluar. Commit() menerapkan seluruh perubahan sekaligus; Rollback() membuang seluruhnya tanpa sisa. Tidak ada kondisi “setengah commit”:

Setelah selesai, objek tx menolak pemanggilan lebih lanjut dengan error sql.ErrTxDone.

Demo Rollback Tanpa Database #

Semantik ACID bisa dibuktikan tanpa PostgreSQL dengan simulasi in-memory. Berkas transaction-demo.go membangun TxStore yang menyimpan saldo pada map, lengkap dengan Begin, Commit, dan Rollback yang meniru perilaku database. Berkas lengkapnya tersedia di bagian bawah halaman dan berikut isi utuhnya:

go
package main

import (
	"errors"
	"fmt"
)

// TxStore meniru tabel database berisi saldo rekening.
// Semua perubahan hanya terjadi lewat transaksi: Begin, Commit, atau Rollback.
type TxStore struct {
	saldo map[string]int
}

// Tx menampung perubahan sementara. Perubahan baru menyentuh
// store saat Commit dipanggil; Rollback membuang seluruhnya.
type Tx struct {
	store   *TxStore
	pending map[string]int
	selesai bool
}

// ErrTxDitutup dikembalikan bila transaksi dipakai setelah Commit atau Rollback.
var ErrTxDitutup = errors.New("transaksi sudah ditutup")

// Begin memulai transaksi dengan menyalin state store saat ini.
func (s *TxStore) Begin() *Tx {
	pending := make(map[string]int, len(s.saldo))
	for k, v := range s.saldo {
		pending[k] = v
	}
	return &Tx{store: s, pending: pending}
}

func (tx *Tx) Tarik(rekening string, jumlah int) error {
	if tx.selesai {
		return ErrTxDitutup
	}
	if jumlah > tx.pending[rekening] {
		return errors.New("saldo tidak cukup")
	}
	tx.pending[rekening] -= jumlah
	return nil
}

func (tx *Tx) Setor(rekening string, jumlah int) error {
	if tx.selesai {
		return ErrTxDitutup
	}
	tx.pending[rekening] += jumlah
	return nil
}

// Commit menerapkan seluruh perubahan pending ke store, lalu menutup transaksi.
func (tx *Tx) Commit() {
	tx.store.saldo = tx.pending
	tx.selesai = true
}

// Rollback membuang seluruh perubahan pending, lalu menutup transaksi.
func (tx *Tx) Rollback() {
	tx.selesai = true
}

func (s *TxStore) Saldo(rekening string) int {
	return s.saldo[rekening]
}

func main() {
	store := &TxStore{saldo: map[string]int{
		"Budi": 100000,
		"Siti": 50000,
	}}
	fmt.Printf("awal          : Budi=%d Siti=%d\n", store.Saldo("Budi"), store.Saldo("Siti"))

	// Transaksi 1: transfer sukses, ditutup dengan Commit.
	tx := store.Begin()
	if err := tx.Tarik("Budi", 30000); err != nil {
		tx.Rollback()
		fmt.Println("transaksi 1 gagal:", err)
		return
	}
	if err := tx.Setor("Siti", 30000); err != nil {
		tx.Rollback()
		fmt.Println("transaksi 1 gagal:", err)
		return
	}
	tx.Commit()
	fmt.Printf("setelah commit: Budi=%d Siti=%d\n", store.Saldo("Budi"), store.Saldo("Siti"))

	// Transaksi 2: penarikan melebihi saldo, gagal, ditutup dengan Rollback.
	tx2 := store.Begin()
	if err := tx2.Tarik("Siti", 999999); err != nil {
		fmt.Println("transaksi 2 gagal:", err)
		tx2.Rollback()
	}
	fmt.Printf("setelah rollback: Budi=%d Siti=%d\n", store.Saldo("Budi"), store.Saldo("Siti"))
}

Jalankan berkas tersebut dari direktori chapter:

bash
go run transaction-demo.go

Output yang Dihasilkan:

text
awal          : Budi=100000 Siti=50000
setelah commit: Budi=70000 Siti=80000
transaksi 2 gagal: saldo tidak cukup
setelah rollback: Budi=70000 Siti=80000

Baris terakhir membuktikan atomicity: transaksi kedua gagal di tengah jalan, dan saldo kembali utuh ke nilai sebelum transaksi dimulai. Mari kita telaah bagian-bagian penting berkas tersebut:

  1. Baris 10-12 (type TxStore): mendeklarasikan penyimpanan saldo pada map — tiruan satu tabel database.
  2. Baris 26-32 (Begin): menyalin seluruh isi map ke pending. Salinan inilah “ruang kerja” transaksi; store asli belum tersentuh.
  3. Baris 34-43 (Tarik): mengubah saldo di pending, bukan di store. Bila saldo kurang, method mengembalikan error tanpa mengubah apa pun — begitu pula statement gagal di database nyata.
  4. Baris 53-57 (Commit): mengganti isi store dengan pending lewat satu assignment. Atomik: tidak ada pembaca yang melihat state setengah terganti.
  5. Baris 59-62 (Rollback): hanya menandai transaksi selesai — seluruh pending dibuang dan store tidak pernah diubah.
  6. Baris 91-95 (transaksi 2): penarikan 999999 melebihi saldo sehingga error dikembalikan dan Rollback() dipanggil. Baris 96 lalu membuktikan saldo Siti tetap 80000 — state utuh.

Isolation Level dan Retry #

PostgreSQL menjalankan transaksi dengan isolation level default Read Committed: setiap statement melihat data yang sudah di-commit terbaru. Level lebih ketat seperti Serializable dipilih lewat db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}), tetapi level tersebut membuka kemungkinan database membatalkan transaksi kita karena bentrok dengan transaksi lain.

Bentrok itu dilaporkan sebagai error serialization failure (SQLSTATE 40001). Penanganan yang benar bukan menyerah, melainkan menjalankan ulang seluruh transaksi dari awal — dengan jeda singkat dan batas maksimal percobaan. Aplikasi dengan pgx memeriksa kode error tersebut lewat pgconn.PgError sebelum memutuskan mencoba lagi.

Dengan transaksi, data tetap konsisten bahkan ketika operasi gagal di tengah jalan. Namun struktur tabel itu sendiri juga berkembang seiring umur aplikasi — tugas migration, yang kita bahas pada chapter berikutnya.

Referensi #

Berkas Sumber

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

transaction-demo.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
)

// TxStore meniru tabel database berisi saldo rekening.
// Semua perubahan hanya terjadi lewat transaksi: Begin, Commit, atau Rollback.
type TxStore struct {
	saldo map[string]int
}

// Tx menampung perubahan sementara. Perubahan baru menyentuh
// store saat Commit dipanggil; Rollback membuang seluruhnya.
type Tx struct {
	store   *TxStore
	pending map[string]int
	selesai bool
}

// ErrTxDitutup dikembalikan bila transaksi dipakai setelah Commit atau Rollback.
var ErrTxDitutup = errors.New("transaksi sudah ditutup")

// Begin memulai transaksi dengan menyalin state store saat ini.
func (s *TxStore) Begin() *Tx {
	pending := make(map[string]int, len(s.saldo))
	for k, v := range s.saldo {
		pending[k] = v
	}
	return &Tx{store: s, pending: pending}
}

func (tx *Tx) Tarik(rekening string, jumlah int) error {
	if tx.selesai {
		return ErrTxDitutup
	}
	if jumlah > tx.pending[rekening] {
		return errors.New("saldo tidak cukup")
	}
	tx.pending[rekening] -= jumlah
	return nil
}

func (tx *Tx) Setor(rekening string, jumlah int) error {
	if tx.selesai {
		return ErrTxDitutup
	}
	tx.pending[rekening] += jumlah
	return nil
}

// Commit menerapkan seluruh perubahan pending ke store, lalu menutup transaksi.
func (tx *Tx) Commit() {
	tx.store.saldo = tx.pending
	tx.selesai = true
}

// Rollback membuang seluruh perubahan pending, lalu menutup transaksi.
func (tx *Tx) Rollback() {
	tx.selesai = true
}

func (s *TxStore) Saldo(rekening string) int {
	return s.saldo[rekening]
}

func main() {
	store := &TxStore{saldo: map[string]int{
		"Budi": 100000,
		"Siti": 50000,
	}}
	fmt.Printf("awal          : Budi=%d Siti=%d\n", store.Saldo("Budi"), store.Saldo("Siti"))

	// Transaksi 1: transfer sukses, ditutup dengan Commit.
	tx := store.Begin()
	if err := tx.Tarik("Budi", 30000); err != nil {
		tx.Rollback()
		fmt.Println("transaksi 1 gagal:", err)
		return
	}
	if err := tx.Setor("Siti", 30000); err != nil {
		tx.Rollback()
		fmt.Println("transaksi 1 gagal:", err)
		return
	}
	tx.Commit()
	fmt.Printf("setelah commit: Budi=%d Siti=%d\n", store.Saldo("Budi"), store.Saldo("Siti"))

	// Transaksi 2: penarikan melebihi saldo, gagal, ditutup dengan Rollback.
	tx2 := store.Begin()
	if err := tx2.Tarik("Siti", 999999); err != nil {
		fmt.Println("transaksi 2 gagal:", err)
		tx2.Rollback()
	}
	fmt.Printf("setelah rollback: Budi=%d Siti=%d\n", store.Saldo("Budi"), store.Saldo("Siti"))
}