Migrasi skema
Migrasi bukan sekadar cara membuat tabel. Ia adalah kontrak antara versi lama dan versi baru aplikasimu yang selama rolling update berjalan bersamaan.
Intisari
- Satu perubahan = dua berkas:
NNNN_nama.up.sqldan.down.sql. Versi tercatat di tabel di database. - Migrasi dijalankan sebagai langkah terpisah saat deploy, bukan di entrypoint container web.
- Selama rolling update, kode lama dan baru berjalan bersamaan โ jadi setiap migrasi harus kompatibel dua arah.
- Ganti nama kolom = tiga deploy, bukan satu.
CREATE INDEX CONCURRENTLYdi Postgres agar tabel besar tidak terkunci โ dan ia tidak boleh di dalam transaksi.
Bentuk berkasnya
migrations/
โโโ 000001_buat_produk.up.sql
โโโ 000001_buat_produk.down.sql
โโโ 000002_tambah_index_nama.up.sql
โโโ 000002_tambah_index_nama.down.sql
โโโ 000003_tambah_kolom_slug.up.sql
โโโ 000003_tambah_kolom_slug.down.sql
-- 000001_buat_produk.up.sql
CREATE TABLE produk (
id bigserial PRIMARY KEY,
nama text NOT NULL,
harga bigint NOT NULL CHECK (harga >= 0),
dibuat timestamptz NOT NULL DEFAULT now(),
dihapus_pada timestamptz
);
-- 000001_buat_produk.down.sql
DROP TABLE produk;
migrate -path migrations -database "$DATABASE_URL" up
migrate -path migrations -database "$DATABASE_URL" down 1
migrate -path migrations -database "$DATABASE_URL" version
migrate -path migrations -database "$DATABASE_URL" force 3 # setelah gagal
Menyematkan migrasi ke dalam binari
//go:embed migrations/*.sql
var migrasiFS embed.FS
func Migrasi(dsn string) error {
src, err := iofs.New(migrasiFS, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithSourceInstance("iofs", src, dsn)
if err != nil {
return err
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrasi: %w", err)
}
return nil
}
Ini yang membuat cmd/migrate/main.go jadi satu binari mandiri yang bisa dijalankan
sebagai ECS run-task saat deploy โ tanpa perlu menyalin berkas SQL ke mana pun. Image yang sama
berisi server dan migrasinya, jadi versi skema dan versi kode tidak pernah berbeda.
Di mana migrasi dijalankan
| Cara | Layak? | Alasan |
|---|---|---|
| Di entrypoint container web | โ | Sepuluh task menjalankannya bersamaan saat scale-out |
Di init() aplikasi | โ | Sama, plus startup jadi lambat dan bisa gagal separuh jalan |
| Langkah pipeline sebelum deploy | โ | Sekali, terkendali, gagalnya menghentikan deploy |
| ECS run-task khusus | โ | Image dan jaringan yang sama dengan aplikasi |
| Manual oleh manusia | โ | Cepat atau lambat ada yang lupa |
golang-migrate mengambil advisory lock Postgres sehingga dua proses tidak menjalankan migrasi yang sama bersamaan. Itu melindungi dari kerusakan, tapi tidak menyelesaikan masalah sesungguhnya: kalau migrasi berada di entrypoint, sembilan task lain akan menunggu lock itu sebelum bisa melayani permintaan โ dan deploy-mu jadi jauh lebih lama daripada seharusnya.
Kompatibel dua arah
Selama rolling update:
waktu โ
v1 v1 v1 v1 โ semua lama
v1 v1 v1 v2 โ BERCAMPUR: v1 dan v2 memakai SKEMA YANG SAMA
v1 v1 v2 v2
v2 v2 v2 v2 โ semua baru
Karena baris tengah itu ada โ selama beberapa menit di setiap deploy โ skema harus bisa dipakai oleh kedua versi kode sekaligus.
| Perubahan | Aman? | Catatan |
|---|---|---|
| Tambah tabel | โ | Kode lama tidak tahu ia ada |
| Tambah kolom nullable / berdefault | โ | INSERT kode lama tetap sah |
Tambah kolom NOT NULL tanpa default | โ | INSERT kode lama langsung gagal |
| Hapus kolom | โ | SELECT kode lama gagal |
| Ganti nama kolom | โ | Butuh tiga deploy |
| Ganti tipe kolom | โ ๏ธ | Melebarkan biasanya aman; menyempitkan tidak |
| Tambah index | โ | Pakai CONCURRENTLY di tabel besar |
Tambah constraint NOT NULL | โ ๏ธ | Isi dulu, validasi belakangan |
Ganti nama kolom dalam tiga deploy
Deploy 1 migrasi: ALTER TABLE produk ADD COLUMN judul text;
kode : TULIS ke nama DAN judul; BACA dari nama
Deploy 2 migrasi: UPDATE produk SET judul = nama WHERE judul IS NULL;
kode : TULIS ke keduanya; BACA dari judul
Deploy 3 migrasi: ALTER TABLE produk DROP COLUMN nama;
kode : hanya judul
Terasa berlebihan sampai kamu pernah mengalami deploy yang gagal di tengah dan harus mengembalikan versi
lama โ dengan skema yang sudah tidak mendukungnya lagi. Ini juga alasan rollback tidak boleh
mengandalkan down.sql: mengembalikan kode itu murah, mengembalikan skema yang sudah
kehilangan data itu mustahil.
Migrasi pada tabel besar
-- โ mengunci seluruh tabel; di 50 juta baris bisa berjam-jam
CREATE INDEX idx_produk_nama ON produk (nama);
-- โ
tidak mengunci tulisan. TIDAK BOLEH di dalam transaksi โ
-- beri tahu golang-migrate lewat komentar berikut.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_produk_nama ON produk (nama);
-- 000004_index_nama.up.sql
-- migrate:no-transaction
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_produk_nama ON produk (nama);
| Operasi | Di tabel besar |
|---|---|
ADD COLUMN dengan default | Cepat di Postgres 11+ (tidak menulis ulang tabel) |
ALTER COLUMN TYPE | Menulis ulang seluruh tabel โ jadwalkan, atau pakai kolom baru |
SET NOT NULL | Memindai seluruh tabel; pakai CHECK ... NOT VALID lalu VALIDATE |
UPDATE semua baris | Pecah jadi batch; satu transaksi raksasa membengkakkan WAL |
Setiap ALTER TABLE mengantre di belakang transaksi yang sedang berjalan โ dan
selama menunggu, ia memblokir semua kueri baru ke tabel itu. Satu transaksi lama yang lupa
di-commit bisa membuat migrasi sepele membekukan seluruh aplikasi. Pasang
SET lock_timeout = '3s' di awal migrasi supaya ia menyerah dan gagal, alih-alih menahan
produksi.
Latihan: tulis migrasi yang menambah kolom slug, jalankan up lalu
down, dan periksa isi tabel schema_migrations di antara keduanya. Lalu tulis
rencana tiga-deploy lengkap (berkas SQL + perubahan kode per langkah) untuk mengganti nama
harga jadi harga_rupiah tanpa jeda.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.