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.
Intisari
- Pola bakunya:
tx, err := db.BeginTx(ctx, nil)laludefer 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
| Jangan | Akibatnya |
|---|---|
| Panggilan HTTP ke layanan lain | Timeout 30 detik = lock ditahan 30 detik = seluruh sistem antre |
| Kirim email / pesan antrean | Transaksi gagal setelahnya, tapi emailnya sudah terkirim |
time.Sleep, retry berjeda | Koneksi tertahan tanpa melakukan apa pun |
| Loop yang memanggil kueri per item | Transaksi panjang; peluang deadlock naik tajam |
| Menunggu input pengguna | Transaksi 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
| Tingkat | Mencegah | Biaya |
|---|---|---|
| Read Committed (default Postgres) | Dirty read | Murah โ pakai ini kecuali ada alasan |
| Repeatable Read | + Non-repeatable read | Bisa gagal dengan serialization error; harus di-retry |
| Serializable | + Phantom read, anomali serialisasi | Paling 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:
- Update atomik:
UPDATE produk SET stok = stok - 1 WHERE id = $1 AND stok >= 1, lalu periksaRowsAffected. Tidak butuh transaksi sama sekali. - Kunci pesimistik:
SELECT ... FOR UPDATEsebelum menghitung. - 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.