Belajar Golang dari Nol #23: Transaksi Database dan Migrasi Skema
Di Belajar Golang dari Nol #22 kita keluar rumah sebentar. Kita belajar memanggil API pihak ketiga dengan HTTP client. Sekarang kita pulang lagi ke database. Ada dua pekerjaan rumah yang belum selesai sejak bagian 12. Dua-duanya baru terasa sakit saat aplikasi kamu benar-benar dipakai orang. Kalau kamu baru bergabung di tengah seri, cek dulu daftar lengkap seri supaya tidak ada yang terlewat.
Dua Masalah yang Belum Kita Jawab
Di bagian 12 kita sudah bisa insert, update, dan query lewat database/sql. Di bagian 16 kita rapikan dengan repository pattern. Cukup untuk latihan. Belum cukup untuk production. Ada dua lubang besar.
Masalah pertama: operasi berangkai yang harus semua-atau-tidak-sama-sekali. Contoh paling klasik ada di aplikasi toko. Saat ada order masuk, kamu harus memotong stok produk dan mencatat order baru. Dua query berbeda. Kalau query pertama sukses lalu query kedua gagal, datamu rusak. Stok sudah berkurang, tapi ordernya tidak ada.
Masalah kedua: mengubah struktur tabel di database yang sudah berisi data. Di bagian 12 kita membuat tabel lewat CREATE TABLE IF NOT EXISTS di kode Go. Itu jalan sekali, lalu diam selamanya. Begitu aplikasi live dan kamu butuh kolom baru, cara itu tidak menolong sama sekali. Kamu tidak mungkin drop tabel yang isinya data pelanggan.
Jawaban untuk masalah pertama adalah transaksi. Jawaban untuk masalah kedua adalah migrasi skema. Kita bahas satu per satu.
Kenapa Transaksi Itu Penting
Cerita nyata dulu. Bayangkan API toko online kamu menerima order. Kodenya menjalankan dua langkah: potong stok, lalu insert order. Suatu malam, koneksi database putus sepersekian detik. Tepat di antara dua langkah itu. Stok kaos sudah terpotong satu. Order gagal tercatat. Pembeli tidak dapat apa-apa, tapi stok di sistem berkurang.
Kejadian seperti ini tidak muncul di laptop kamu saat development. Ia muncul di production, jam dua pagi, saat traffic sedang ramai. Selisihnya kecil, satu dua unit. Tapi menumpuk. Akhir bulan tim gudang bingung. Stok di sistem bilang habis, stok fisik masih ada. Ini yang biasa disebut stok bocor.
Transaksi menutup lubang ini. Konsepnya sederhana. Kamu membungkus beberapa query jadi satu paket. Paket itu hanya punya dua kemungkinan akhir. Semua query berhasil dan disimpan permanen, itu namanya commit. Atau salah satu gagal dan semuanya dibatalkan seolah tidak pernah terjadi, itu namanya rollback. Tidak ada kondisi setengah jadi.
Anatomi Transaksi di Go
Di database/sql, transaksi dimulai dengan db.BeginTx. Fungsi ini menerima context, jadi ilmu dari bagian 17 langsung terpakai. Kalau request dibatalkan atau timeout, transaksinya ikut dibatalkan. Setelah itu semua query dijalankan lewat objek tx, bukan db.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// jalankan query lewat tx, bukan db
_, err = tx.ExecContext(ctx, "UPDATE produk SET stok = stok - 1 WHERE id = ?", 1)
if err != nil {
return err
}
return tx.Commit()
Perhatikan baris defer tx.Rollback(). Ini pola paling penting di sini. Ditulis sekali, tepat setelah BeginTx berhasil. Logikanya begini. Kalau ada error di tengah dan fungsi return lebih awal, defer memastikan rollback tetap jalan. Transaksi tidak menggantung dan mengunci baris di database.
Lalu bagaimana kalau semuanya sukses sampai Commit? Bukankah defer tetap memanggil Rollback setelahnya? Benar, tetap dipanggil. Tapi tidak berbahaya. Transaksi yang sudah di-commit statusnya sudah selesai. Rollback setelah commit hanya mengembalikan error sql.ErrTxDone dan tidak melakukan apa-apa ke database. Karena kita tidak memeriksa nilai baliknya, error itu lewat begitu saja. Satu baris defer, dua skenario aman.
Contoh Utuh: BuatOrder dalam Satu Transaksi
Sekarang kita gabungkan jadi method repository yang layak production. Method ini memotong stok dan mencatat order dalam satu transaksi. Kalau stok kurang, semuanya batal.
var ErrStokKurang = errors.New("stok tidak mencukupi")
type OrderRepository struct {
db *sql.DB
}
func (r *OrderRepository) BuatOrder(ctx context.Context, produkID int64, jumlah int) (int64, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("mulai transaksi: %w", err)
}
defer tx.Rollback()
// langkah 1: potong stok, hanya kalau stok cukup
res, err := tx.ExecContext(ctx,
"UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?",
jumlah, produkID, jumlah,
)
if err != nil {
return 0, fmt.Errorf("potong stok: %w", err)
}
baris, err := res.RowsAffected()
if err != nil {
return 0, err
}
if baris == 0 {
// tidak ada baris berubah, artinya stok kurang.
// return di sini memicu defer tx.Rollback()
return 0, ErrStokKurang
}
// langkah 2: catat order
hasil, err := tx.ExecContext(ctx,
"INSERT INTO orders (produk_id, jumlah, status) VALUES (?, ?, 'baru')",
produkID, jumlah,
)
if err != nil {
return 0, fmt.Errorf("simpan order: %w", err)
}
orderID, err := hasil.LastInsertId()
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit: %w", err)
}
return orderID, nil
}
Ikuti alur gagalnya. Misal stok tinggal 2 dan pembeli minta 5. Query update tidak menemukan baris yang cocok karena syarat stok >= 5 tidak terpenuhi. RowsAffected mengembalikan 0. Fungsi return dengan ErrStokKurang. Defer menjalankan rollback. Insert order tidak pernah terjadi, dan stok tidak tersentuh. Database kembali bersih, seolah request itu tidak pernah datang.
Dua Pembeli, Satu Barang Terakhir
Ada satu jebakan lagi yang perlu kamu tahu, walau kita bahas ringan saja. Bayangkan stok tinggal satu, lalu dua request masuk bersamaan. Kalau kodemu bergaya cek dulu baru update, dua-duanya bisa lolos. Request A membaca stok, dapat 1. Sebelum A sempat update, request B juga membaca stok, dapat 1 juga. Keduanya merasa berhak. Keduanya memotong stok. Hasil akhir stok jadi minus satu, dan dua orang membeli barang yang cuma ada satu.
Masalah semacam ini disebut race condition di level database, dan topik isolasi transaksi bisa jadi satu artikel sendiri. Untungnya, untuk kasus stok ada solusi praktis yang sudah kamu lihat di kode tadi. Jangan pisahkan cek dan update. Gabungkan keduanya dalam satu query atomik:
UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?
Database menjamin satu statement UPDATE dieksekusi utuh, satu per satu terhadap baris yang sama. Request pertama yang sampai akan memotong stok. Request kedua menemukan syarat stok >= ? sudah tidak terpenuhi, sehingga RowsAffected bernilai 0 dan order ditolak rapi. RowsAffected sendiri sudah kamu kenal sejak bagian 12. Sekarang dia naik pangkat, dari sekadar info jadi penjaga gerbang.
Masalah Kedua: Skema yang Ikut Bertumbuh
Aplikasi yang hidup pasti berubah. Bulan ini tabel produk cukup dengan nama, harga, dan stok. Bulan depan tim minta kolom foto. Di sinilah pendekatan bagian 12 mentok. CREATE TABLE IF NOT EXISTS hanya bekerja saat tabel belum ada. Kalau tabel sudah ada, statement itu diam saja. Dia tidak bisa menambah kolom, tidak bisa mengubah tipe data, tidak bisa apa-apa.
Solusi naifnya mengerikan: drop tabel lalu buat ulang. Di development mungkin tidak apa-apa. Di production itu artinya menghapus seluruh data pelanggan. Jelas bukan pilihan.
Solusi yang dipakai industri adalah migrasi skema. Idenya begini. Setiap perubahan struktur database ditulis sebagai file SQL bernomor urut. File pertama membuat tabel. File kedua menambah kolom. File ketiga menambah index. Dan seterusnya. Setiap perubahan punya pasangan: file up untuk menerapkan perubahan, dan file down untuk membatalkannya. Database mencatat sudah sampai nomor berapa dia bermigrasi. Jadi database lama dan database baru bisa dibawa ke struktur yang sama, cukup dengan menjalankan migrasi yang belum dieksekusi.
Praktik dengan golang-migrate
Tool paling populer di ekosistem Go untuk urusan ini adalah golang-migrate. Kita pakai versi CLI-nya. Install dulu:
go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Lalu buat folder migrasi dan file pertamanya:
mkdir -p db/migrations
migrate create -ext sql -dir db/migrations -seq buat_tabel_produk
Perintah itu menghasilkan dua file kosong dengan format nama yang penting kamu pahami: 000001_buat_tabel_produk.up.sql dan 000001_buat_tabel_produk.down.sql. Angka di depan adalah urutan eksekusi. Isi file up dengan struktur tabel kita:
-- 000001_buat_tabel_produk.up.sql
CREATE TABLE produk (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
harga INT NOT NULL,
stok INT NOT NULL DEFAULT 0
);
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
produk_id BIGINT NOT NULL,
jumlah INT NOT NULL,
status VARCHAR(20) NOT NULL,
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (produk_id) REFERENCES produk(id)
);
File down berisi kebalikannya. Urutannya dibalik karena orders bergantung pada produk:
-- 000001_buat_tabel_produk.down.sql
DROP TABLE orders;
DROP TABLE produk;
Jalankan migrasinya:
migrate -path db/migrations \
-database "mysql://user:rahasia@tcp(localhost:3306)/toko" up
Kalau kamu intip databasenya sekarang, ada satu tabel tambahan bernama schema_migrations. Tabel ini milik golang-migrate. Isinya nomor versi terakhir yang sudah diterapkan. Berkat tabel ini, menjalankan migrate up dua kali aman. Migrasi yang sudah jalan tidak diulang.
Sekarang skenario kolom baru. Di bagian 20 kita belajar upload file, dan produk kita butuh kolom untuk menyimpan nama file fotonya. Jangan sentuh file 000001. Buat migrasi kedua:
migrate create -ext sql -dir db/migrations -seq tambah_kolom_foto
-- 000002_tambah_kolom_foto.up.sql
ALTER TABLE produk ADD COLUMN foto VARCHAR(255) NULL;
-- 000002_tambah_kolom_foto.down.sql
ALTER TABLE produk DROP COLUMN foto;
Jalankan migrate up lagi. Golang-migrate melihat database sudah di versi 1, jadi hanya file 000002 yang dieksekusi. Data produk lama tetap utuh, hanya bertambah satu kolom kosong. Kalau ternyata ada masalah, migrate down 1 mengembalikan satu langkah ke belakang lewat file down.
Aturan Emas Migrasi di Production
Tiga aturan ini pendek, tapi menyelamatkan banyak database.
- Jangan pernah edit migrasi lama yang sudah jalan. File migrasi yang sudah diterapkan itu sejarah. Kalau butuh perubahan, selalu bikin file migrasi baru. Mengedit file lama membuat database yang sudah bermigrasi dan yang belum jadi punya struktur berbeda, dan itu sulit sekali dilacak.
- Backup dulu sebelum migrate di production. File down membantu, tapi ada perubahan yang tidak bisa dibalik.
DROP COLUMNyang sudah jalan tidak bisa mengembalikan isi kolomnya. Rutinitasmysqldumpyang kita bahas di bagian 15 adalah jaring pengamanmu di sini. - Jalankan migrasi sebagai bagian dari deploy. Urutannya: backup, migrate up, baru jalankan versi aplikasi terbaru. Dengan begitu kode baru tidak pernah bertemu skema lama.
Latihan: Rangkai Semuanya
Waktunya latihan utuh. Susun project kecil dengan struktur ini: dua file migrasi seperti di atas di folder db/migrations, lalu OrderRepository dengan method BuatOrder yang sudah kita tulis. Sambungkan ke handler HTTP sederhana:
func (h *OrderHandler) Buat(w http.ResponseWriter, r *http.Request) {
var req struct {
ProdukID int64 `json:"produk_id"`
Jumlah int `json:"jumlah"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "body tidak valid", http.StatusBadRequest)
return
}
orderID, err := h.repo.BuatOrder(r.Context(), req.ProdukID, req.Jumlah)
if errors.Is(err, ErrStokKurang) {
http.Error(w, "stok tidak mencukupi", http.StatusConflict)
return
}
if err != nil {
http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int64{"order_id": orderID})
}
Lalu uji alurnya dengan curl. Pertama, jalankan migrasi dan isi satu produk dengan stok 3. Kemudian coba order yang melebihi stok:
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"produk_id": 1, "jumlah": 10}'
Kamu akan menerima status 409 dengan pesan stok tidak mencukupi. Sekarang cek stoknya lewat endpoint produk atau langsung di database. Stok masih 3, tidak tersentuh sama sekali. Itu bukti rollback bekerja. Terakhir, order dengan jumlah 2. Order tercatat, stok jadi 1, dan keduanya berubah bersama dalam satu transaksi. Coba juga tembak dua request bersamaan saat stok tinggal satu, dan lihat sendiri hanya satu yang berhasil.
Penutup
Hari ini kamu menutup dua lubang production yang paling sering menggigit pemula. Transaksi memastikan operasi berangkai selesai utuh atau batal total, dengan pola BeginTx, defer tx.Rollback(), dan Commit. Migrasi skema membuat struktur database bisa bertumbuh bersama aplikasimu tanpa mengorbankan data, dengan file up dan down yang berurutan dan tercatat rapi di schema_migrations.
Di bagian 24 kita naik satu level lagi soal performa. Judulnya “Caching dengan Redis: Bikin API Terasa Instan”. Sampai ketemu di sana.
Kalau kamu sedang membangun aplikasi yang datanya tidak boleh bocor, misalnya sistem inventaris atau POS dengan stok yang harus akurat, tim kami terbiasa menangani kasus seperti ini. Lihat layanan pengembangan sistem aplikasi Arrazy untuk cerita lengkapnya.
Artikel Lainnya di Kategori Golang
Golang 4 Agustus 2026
Belajar Golang dari Nol #9: Interface, Kontrak yang Bikin Kode Fleksibel
Lanjutan seri Go pemula: kenali interface, implementasi implisit, fmt.Stringer, sampai latihan sistem pembayaran dengan dua metode berbeda.
Baca Artikel
Golang 8 Agustus 2026
Belajar Golang dari Nol #13: Middleware dan Auth Sederhana
API produkmu kini punya penjaga: middleware logging, API key lewat environment variable, chain middleware, sampai sekilas CORS dan arah auth user.
Baca Artikel
Golang 28 Juli 2026
Belajar Golang dari Nol #2: Variabel, Tipe Data, dan Zero Value
Lanjutan seri Go pemula: tiga cara deklarasi variabel, tipe data dasar, zero value, konstanta, konversi tipe, plus latihan program hitung belanja.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel