โ† Semua pembelajaran / Go Nol โ†’ Enterprise
Fase 4 ยท Database & Persistensi

Transaksi & konsistensi

Transaksi adalah alat yang menjamin "semuanya terjadi atau tidak sama sekali". Yang tidak dijamin siapa pun adalah apa yang terjadi saat dua transaksi menyentuh baris yang sama pada saat yang sama.

Sumber asli pkg.go.dev Resmi Rangkuman ~8 menit baca

Intisari

  • Pola bakunya: tx, err := db.BeginTx(ctx, nil) lalu defer tx.Rollback() โ€” rollback setelah commit tidak berbahaya.
  • Satu transaksi memegang satu koneksi selama hidupnya. Transaksi panjang = koneksi tertahan.
  • Jangan pernah memanggil API luar di dalam transaksi. Timeout HTTP jadi lock database yang ditahan.
  • Isolasi default Postgres adalah Read Committed โ€” cukup untuk hampir semua hal, tapi tidak mencegah lost update.
  • Kunci baris dengan SELECT ... FOR UPDATE, dan selalu kunci dengan urutan yang sama untuk menghindari deadlock.

Pola yang benar

func (r *Repo) Pindah(ctx context.Context, dari, ke int64, jumlah int64) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("mulai transaksi: %w", err)
	}
	// Aman dipanggil setelah Commit: ia mengembalikan ErrTxDone yang diabaikan.
	// Ini yang menjamin tidak ada jalur keluar yang meninggalkan transaksi
	// menggantung โ€” termasuk panic.
	defer func() { _ = tx.Rollback() }()

	var saldo int64
	err = tx.QueryRowContext(ctx,
		`SELECT saldo FROM akun WHERE id = $1 FOR UPDATE`, dari).Scan(&saldo)
	if err != nil {
		return fmt.Errorf("kunci akun %d: %w", dari, err)
	}
	if saldo < jumlah {
		return ErrSaldoKurang
	}

	if _, err := tx.ExecContext(ctx,
		`UPDATE akun SET saldo = saldo - $1 WHERE id = $2`, jumlah, dari); err != nil {
		return fmt.Errorf("kurangi saldo: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE akun SET saldo = saldo + $1 WHERE id = $2`, jumlah, ke); err != nil {
		return fmt.Errorf("tambah saldo: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}

Kenapa defer tx.Rollback() dan bukan rollback di setiap cabang error: karena cabang error bertambah seiring waktu, dan yang baru selalu ada yang lupa. Dengan defer, jalur keluar mana pun โ€” termasuk panic โ€” meninggalkan transaksi dalam keadaan bersih.

Membungkusnya jadi fungsi bantu

func DalamTx(ctx context.Context, db *sql.DB, f 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)          // panic tetap naik, tapi transaksinya bersih
		}
	}()

	if err := f(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	return tx.Commit()
}

// Pemakaian
err := DalamTx(ctx, r.db, func(tx *sql.Tx) error {
	if err := simpanPesanan(ctx, tx, p); err != nil {
		return err
	}
	return kurangiStok(ctx, tx, p.Item)
})

Apa yang tidak boleh ada di dalam transaksi

JanganAkibatnya
Panggilan HTTP ke layanan lainTimeout 30 detik = lock ditahan 30 detik = seluruh sistem antre
Kirim email / pesan antreanTransaksi gagal setelahnya, tapi emailnya sudah terkirim
time.Sleep, retry berjedaKoneksi tertahan tanpa melakukan apa pun
Loop yang memanggil kueri per itemTransaksi panjang; peluang deadlock naik tajam
Menunggu input penggunaTransaksi hidup selama sesi manusia

Pola yang benar untuk "simpan lalu kirim": tulis niat pengiriman ke tabel outbox di dalam transaksi yang sama, lalu biarkan worker terpisah membaca outbox dan benar-benar mengirim. Dengan begitu, "pesanan tersimpan" dan "notifikasi akan dikirim" jadi satu keputusan atomik, tanpa ada panggilan jaringan di dalam transaksi. Dibahas lagi di Fase 5.

Tingkat isolasi

TingkatMencegahBiaya
Read Committed (default Postgres)Dirty readMurah โ€” pakai ini kecuali ada alasan
Repeatable Read+ Non-repeatable readBisa gagal dengan serialization error; harus di-retry
Serializable+ Phantom read, anomali serialisasiPaling ketat; wajib punya logika retry
tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelSerializable,
	ReadOnly:  false,
})

Yang tidak dicegah Read Committed: lost update. Dua permintaan membaca stok = 10, keduanya menghitung 10 โˆ’ 1, keduanya menulis 9. Satu penjualan hilang. Ada tiga obatnya, dari yang paling sederhana:

  1. Update atomik: UPDATE produk SET stok = stok - 1 WHERE id = $1 AND stok >= 1, lalu periksa RowsAffected. Tidak butuh transaksi sama sekali.
  2. Kunci pesimistik: SELECT ... FOR UPDATE sebelum menghitung.
  3. Kunci optimistik: kolom versi, WHERE versi = $lama, gagal = baca ulang dan coba lagi.

Pilihan pertama hampir selalu yang terbaik kalau perubahannya bisa dinyatakan sebagai satu ekspresi SQL.

Deadlock

Transaksi A: kunci akun 1 โ†’ minta kunci akun 2
Transaksi B: kunci akun 2 โ†’ minta kunci akun 1
              โ†ณ keduanya menunggu selamanya; database mematikan salah satu
// Pencegahan: SELALU kunci dengan urutan yang sama, apa pun arah transfernya.
pertama, kedua := dari, ke
if pertama > kedua {
	pertama, kedua = kedua, pertama
}

tx.ExecContext(ctx, `SELECT 1 FROM akun WHERE id = $1 FOR UPDATE`, pertama)
tx.ExecContext(ctx, `SELECT 1 FROM akun WHERE id = $1 FOR UPDATE`, kedua)

Deadlock tidak bisa dihilangkan sepenuhnya, jadi kode yang menyentuh beberapa baris sekaligus juga perlu retry:

func denganRetry(ctx context.Context, maks int, f func() error) error {
	var err error
	for i := range maks {
		if err = f(); err == nil {
			return nil
		}

		var pgErr *pgconn.PgError
		// 40P01 deadlock_detected, 40001 serialization_failure
		if !errors.As(err, &pgErr) ||
			(pgErr.Code != "40P01" && pgErr.Code != "40001") {
			return err          // bukan error yang layak diulang
		}

		select {
		case <-time.After(time.Duration(1<<i) * 10 * time.Millisecond):
		case <-ctx.Done():
			return ctx.Err()
		}
	}
	return err
}

Transaksi baca-saja

// Untuk laporan yang butuh beberapa kueri dari SATU snapshot data
tx, _ := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelRepeatableRead,
	ReadOnly:  true,
})
defer tx.Rollback()

Ini menjamin semua kueri di dalamnya melihat keadaan database yang sama โ€” penting untuk laporan yang totalnya harus konsisten dengan rinciannya. ReadOnly: true juga memungkinkan database mengarahkannya ke replica.

Latihan: buat tabel akun dengan dua baris, lalu jalankan 100 transfer bersamaan dari A ke B dan sebaliknya tanpa pengurutan kunci โ€” amati error deadlock muncul. Pasang pengurutan seperti contoh dan buktikan hilang. Lalu tulis versi "kurangi stok" memakai satu UPDATE ... WHERE stok >= 1 dan bandingkan jumlah barisnya.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.