September 27, 2026

Migrasi Database.

Kenapa perubahan skema perlu dikelola lewat migrasi, konsep up/down dan versioning, pola zero-downtime expand-contract, contoh Laravel dan SQL murni, serta backup dengan mysqldump.

Migrasi database adalah cara mengelola perubahan skema (tabel, kolom, index) sebagai file kode yang berurutan dan ter-versi, bukan sebagai perintah yang diketik manual di phpMyAdmin . Ibaratnya Git untuk struktur database.


1. Kenapa Perlu Migrasi?

Tanpa migrasi, cerita klasiknya begini: developer A menambah kolom di laptopnya, lupa memberitahu, lalu aplikasi di staging error “Unknown column”. Migrasi menyelesaikan ini:

Masalah tanpa migrasiDengan migrasi
Skema tiap environment berbeda-bedaSemua environment menjalankan file yang sama
Tidak tahu perubahan apa yang sudah diterapkanAda tabel pencatat versi
Perubahan skema tidak ikut code reviewMigrasi masuk commit & pull request
Setup project baru butuh dump manualCukup migrate dari nol
Deploy butuh langkah manualMigrasi dijalankan otomatis di pipeline

2. Konsep Dasar

Up dan down

Setiap migrasi punya dua arah:

  • up: menerapkan perubahan (misalnya CREATE TABLE).
  • down: membatalkannya (misalnya DROP TABLE).

Versioning

Setiap file diberi nomor urut atau timestamp, misalnya 2026_09_27_134000_create_orders_table. Tool migrasi menyimpan daftar migrasi yang sudah dijalankan di tabel khusus (Laravel: migrations, Flyway: flyway_schema_history). Saat migrate dijalankan, hanya file yang belum tercatat yang dieksekusi, sesuai urutan.

Aturan emas

Penting: Migrasi yang sudah dijalankan di environment bersama (staging/produksi) tidak boleh diedit. Jika ada kesalahan, buat migrasi baru yang memperbaikinya. Mengedit file lama tidak akan dijalankan ulang di server yang sudah mencatatnya, sehingga skema antar-environment menjadi berbeda.


3. Contoh dengan Laravel

Membuat file migrasi:

php artisan make:migration create_orders_table
php artisan make:migration add_notes_to_orders_table --table=orders

Isi migrasi (Laravel 9+ memakai anonymous class):

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('customer_id')->constrained()->restrictOnDelete();
            $table->decimal('total', 12, 2)->default(0);
            $table->enum('status', ['pending', 'paid', 'cancelled'])->default('pending');
            $table->timestamps();

            $table->index(['customer_id', 'status']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Menambah kolom:

public function up(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->text('notes')->nullable()->after('status');
    });
}

public function down(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->dropColumn('notes');
    });
}

Perintah yang sering dipakai:

PerintahFungsi
php artisan migrateJalankan migrasi yang belum dijalankan
php artisan migrate:statusLihat status tiap migrasi
php artisan migrate --pretendTampilkan SQL tanpa menjalankannya
php artisan migrate:rollbackBatalkan batch terakhir
php artisan migrate:rollback --step=1Batalkan satu migrasi terakhir
php artisan migrate:fresh --seedDrop semua tabel, migrasi ulang, lalu seed
php artisan migrate --forceWajib di produksi (melewati konfirmasi)

Peringatan: migrate:fresh, migrate:reset, dan migrate:refresh menghapus data. Hanya untuk lokal/testing, jangan pernah di produksi.


4. Migrasi dengan SQL Murni

Tanpa framework, pola yang sama bisa dibuat dengan file SQL bernomor dan tabel pencatat:

migrations/
├── 001_create_customers.up.sql
├── 001_create_customers.down.sql
├── 002_create_orders.up.sql
├── 002_create_orders.down.sql
└── 003_add_notes_to_orders.up.sql
-- 003_add_notes_to_orders.up.sql
ALTER TABLE orders ADD COLUMN notes TEXT NULL AFTER status;

-- 003_add_notes_to_orders.down.sql
ALTER TABLE orders DROP COLUMN notes;

Tabel pencatat versi:

CREATE TABLE IF NOT EXISTS schema_migrations (
  version     VARCHAR(50) PRIMARY KEY,
  applied_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Runner sederhana dengan Bash:

#!/usr/bin/env bash
set -euo pipefail
DB="toko"

for f in migrations/*.up.sql; do
  v=$(basename "$f" .up.sql)
  applied=$(mysql -N -e "SELECT COUNT(*) FROM schema_migrations WHERE version='$v'" "$DB")
  if [ "$applied" = "0" ]; then
    echo "→ applying $v"
    mysql "$DB" < "$f"
    mysql -e "INSERT INTO schema_migrations (version) VALUES ('$v')" "$DB"
  fi
done

Kredensial sebaiknya disimpan di ~/.my.cnf (bagian [client], permission 600) supaya password tidak muncul di command line. Untuk kebutuhan serius, gunakan tool siap pakai seperti Flyway, Liquibase, golang-migrate, atau dbmate.

Catatan: Karena DDL di MySQL memicu implicit commit, migrasi yang berisi beberapa ALTER tidak atomik. Jika statement ketiga gagal, dua statement pertama sudah permanen. Buat satu migrasi untuk satu perubahan logis, dan pastikan migrasi aman dijalankan ulang (IF NOT EXISTS) bila memungkinkan.


5. Zero-Downtime: Pola Expand-Contract

Saat deploy, ada jeda ketika kode lama dan kode baru berjalan bersamaan (rolling deploy, beberapa server, worker yang belum restart). Perubahan skema yang merusak kode lama, seperti rename atau drop kolom, akan menimbulkan error di jeda tersebut.

Solusinya adalah expand-contract (parallel change): pecah perubahan yang merusak menjadi beberapa deploy yang masing-masing aman.

Contoh: mengganti nama kolom name menjadi full_name.

TahapSkemaKode aplikasi
1. ExpandTambah kolom full_name (nullable)Masih membaca & menulis name
2. Dual write—Menulis ke kedua kolom, masih membaca name
3. BackfillSalin data lama name → full_name secara bertahap—
4. Switch read—Membaca full_name, tetap dual write
5. Stop old write—Hanya menulis full_name
6. ContractDrop kolom name—

Backfill bertahap agar tidak mengunci tabel lama-lama:

UPDATE users SET full_name = name
WHERE full_name IS NULL AND id BETWEEN 1 AND 10000;
-- lanjutkan per 10.000 id

Setiap tahap bisa di-rollback dengan aman karena kode lama masih kompatibel dengan skema yang ada.

Aturan praktis perubahan skema

PerubahanAman langsung?Catatan
Tambah tabel baruYa
Tambah kolom nullable / ber-defaultYaMySQL 8.0.12+ mendukung ALGORITHM=INSTANT untuk ADD COLUMN
Tambah indexUmumnya yaOnline (INPLACE, DML tetap jalan), tapi berat di tabel besar
Tambah kolom NOT NULL tanpa defaultTidakInsert dari kode lama akan gagal
Rename kolom/tabelTidakPakai expand-contract
Drop kolomTidak langsungHapus pemakaian di kode dulu, deploy, baru drop
Ubah tipe kolomTidakBiasanya rebuild tabel (copy), lock lama; pakai kolom baru + backfill

Untuk tabel sangat besar, tool seperti gh-ost atau pt-online-schema-change melakukan perubahan lewat tabel bayangan tanpa lock panjang.


6. Backup Sebelum Migrasi: mysqldump

Selalu backup sebelum migrasi produksi, sekecil apa pun perubahannya.

# backup satu database (InnoDB) tanpa mengunci tabel
mysqldump --single-transaction --routines --triggers --events \
  -u root -p toko > toko_$(date +%F_%H%M).sql

# versi terkompresi
mysqldump --single-transaction --routines --events -u root -p toko \
  | gzip > toko_$(date +%F).sql.gz

# hanya struktur, tanpa data
mysqldump --no-data -u root -p toko > toko_schema.sql

# satu tabel saja
mysqldump --single-transaction -u root -p toko orders > orders.sql
OpsiFungsi
--single-transactionSnapshot konsisten untuk InnoDB tanpa lock tabel (tidak konsisten untuk MyISAM)
--routinesSertakan stored procedure & function (default tidak ikut)
--eventsSertakan event scheduler (default tidak ikut)
--triggersSertakan trigger (sudah aktif secara default)
--no-dataHanya DDL
--databases db1 db2Beberapa database sekaligus, termasuk CREATE DATABASE

Restore:

mysql -u root -p toko < toko_2026-09-27_1340.sql
gunzip < toko_2026-09-27.sql.gz | mysql -u root -p toko

Tips: Backup yang tidak pernah dites restore-nya belum bisa disebut backup. Sesekali restore ke database kosong dan cek isinya. Untuk database besar, pertimbangkan MySQL Shell dump utilities (util.dumpInstance()) yang paralel dan lebih cepat.


7. Checklist Deploy Migrasi

  1. Migrasi sudah dites di lokal dan di staging dengan data mirip produksi.
  2. down() ditulis dan dites (walaupun di produksi lebih sering roll forward).
  3. Cek SQL yang akan dijalankan (migrate --pretend).
  4. Backup database.
  5. Perkirakan durasi untuk tabel besar; jadwalkan saat trafik rendah.
  6. Perubahan yang merusak dipecah dengan expand-contract.
  7. Jalankan migrasi sebelum kode baru aktif (untuk tahap expand) dan setelah kode lama hilang (untuk tahap contract).

8. Kesalahan Umum

MasalahSolusi
Mengedit migrasi lama yang sudah jalan di produksiBuat migrasi baru
down() kosong atau salahTulis kebalikan yang tepat, atau lempar exception jika memang tidak bisa dibalik
Migrasi data (update ribuan baris) digabung dengan perubahan skemaPisahkan; jalankan backfill bertahap
Migrasi bergantung pada Model Eloquent terbaruPakai query builder/SQL di migrasi, karena model bisa berubah di masa depan
Konflik urutan migrasi antar branchRebase dan periksa migrate:status sebelum merge
Error Specified key was too long di MySQL lamaGunakan MySQL 5.7.7+/8 dengan utf8mb4 + InnoDB DYNAMIC, atau batasi panjang string

Referensi

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.

Comments