← semua materi
05:00 Arsitektur · 21 Oct 2026 · 05:01

📚 Topik: Zero-downtime DB migration — expand–contract (parallel change) Kategori: Database · Level: advanced · Sesi: 05:00 Deep Learning


🎯 Tujuan Belajar

Setelah sesi ini kamu bisa:

  1. Menilai apakah sebuah perubahan skema bisa di-deploy tanpa downtime, atau harus pakai maintenance window — dan berapa biaya lock-nya.
  2. Menulis urutan migrasi expand→migrate→contract untuk kasus rename, ubah tipe, NOT NULL, split tabel, drop kolom.
  3. Mengetahui lock level tiap DDL di Postgres/MySQL dan kenapa lock queue lebih berbahaya daripada lock itu sendiri.
  4. Mendesain backfill yang tidak bikin bloat, replication lag, atau deadlock.

🧠 Penjelasan Inti

Masalah yang diselesaikan: rolling deploy membuat dua versi kode hidup bersamaan.

Saat deploy, kamu punya N pod versi lama dan N pod versi baru sekaligus. Kalau migrasi mengubah skema secara breaking (rename kolom, drop kolom, ubah tipe), salah satu versi kode pasti error. Migrasi satu-fase hanya aman kalau:

Expand–contract memecah satu perubahan breaking jadi tiga fase, sehingga skema selalu kompatibel dengan dua versi kode sekaligus:

Fase 1 EXPAND    : tambah struktur baru, tanpa hapus apa pun.
                   Skema = superset. Kode lama tetap jalan.
Fase 2 MIGRATE   : tulis ke struktur baru (dual-write / trigger),
                   backfill data lama, baca dari baru (feature flag).
Fase 3 CONTRACT  : setelah kode lama benar-benar hilang dari cluster,
                   hapus struktur lama.

Aturan tunggal yang menentukan segalanya:

Setiap migrasi harus kompatibel dengan kode versi N dan N+1 secara bersamaan.

Konsekuensi: satu perubahan konseptual = ≥3 deploy, bukan 1.


⚙️ Cara Kerja Internal

1. Lock level — ini yang menentukan downtime

Postgres (DIKETAHUI, stabil sejak lama):

Operasi Lock Rewrite tabel? Blocking?
ADD COLUMN nullable, tanpa default ACCESS EXCLUSIVE (instan) tidak ya, tapi milidetik
ADD COLUMN ... DEFAULT <non-volatile> ACCESS EXCLUSIVE tidak (PG11+) milidetik
ADD COLUMN ... DEFAULT now()/volatile ACCESS EXCLUSIVE ya seluruh tabel
ALTER COLUMN TYPE varchar(50)→varchar(100) ACCESS EXCLUSIVE tidak milidetik
ALTER COLUMN TYPE int→bigint ACCESS EXCLUSIVE ya seluruh tabel
CREATE INDEX SHARE tidak blokir semua write
CREATE INDEX CONCURRENTLY SHARE UPDATE EXCLUSIVE tidak write jalan
ADD CONSTRAINT ... NOT VALID ACCESS EXCLUSIVE (instan) tidak milidetik
VALIDATE CONSTRAINT SHARE UPDATE EXCLUSIVE tidak (scan saja) write jalan
SET NOT NULL tanpa CHECK valid ACCESS EXCLUSIVE + full scan tidak seluruh scan
DROP COLUMN ACCESS EXCLUSIVE (instan, metadata) tidak milidetik

PG11+ menyimpan nilai default di pg_attribute.attmissingval (default "di-attach" saat row dibaca), bukan menulis ulang heap. Ini alasan ADD COLUMN DEFAULT jadi operasi metadata. Sebelum PG11, ini full rewrite — masih sumber outage di deployment lama.

PG12+: SET NOT NULL bisa skip full scan kalau sudah ada CHECK (col IS NOT NULL) VALID. Jadi urutan aman: ADD CONSTRAINT ... NOT VALID → VALIDATE CONSTRAINT → SET NOT NULL.

2. Lock queue — bahaya yang sering diabaikan

ACCESS EXCLUSIVE yang menunggu masuk ke antrian lock memblokir semua lock berikutnya, termasuk SELECT. Satu DDL lambat (misal SET NOT NULL full scan 40 detik pada tabel 200 juta baris) = seluruh trafik tabel itu berhenti 40 detik, walau DDL-nya sendiri "cuma" butuh lock singkat.

Mitigasi standar:

SET lock_timeout = '2s';
SET statement_timeout = '30s';
-- lalu retry di loop aplikasi/migration runner

Kalau lock_timeout tercapai, DDL gagal cepat, antrian tidak menumpuk, retry beberapa detik kemudian saat lock bebas. Ini praktik wajib, bukan opsional.

3. Backfill — bagian yang paling sering bikin insiden

Aturan:

-- pseudocode worker
loop:
  rows = SELECT id FROM t WHERE new_col IS NULL ORDER BY id LIMIT 5000
  if empty: break
  UPDATE t SET new_col = f(old_col) WHERE id = ANY(rows)
  commit
  sleep_if(replication_lag > threshold)

4. Dual-write

Dua opsi, keduanya punya jebakan:

Baca: dual-read dengan perbandingan (shadow read) sebelum switch penuh. Log mismatch, jangan langsung percaya kolom baru.

5. Timeline konkret — rename name → full_name

Deploy 1 (expand)
  ALTER TABLE users ADD COLUMN full_name text;
  CREATE TRIGGER ... BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION sync_full_name();  -- name <-> full_name
  Kode: tulis ke keduanya, baca dari `name` (belum berubah perilaku)

Backfill
  UPDATE users SET full_name = name WHERE full_name IS NULL;  -- batch

Deploy 2 (switch read)
  Kode: baca dari `full_name`. Tulis masih dual (trigger jaga sinkron).
  Pantau error rate + null count beberapa hari/beberapa rilis.

Deploy 3 (contract)
  Verifikasi tidak ada query yang menyentuh `name` (pg_stat_statements,
  audit log, grep codebase, log kolom).
  DROP TRIGGER; DROP COLUMN name;

Tiga deploy, minimal dua kali jeda observasi. Total bisa 1–3 minggu untuk tabel panas. Ini biaya sebenarnya dari zero-downtime — waktu, bukan uang.

6. Ubah tipe yang butuh rewrite (int → bigint)

Tidak bisa ALTER TYPE langsung (full rewrite + ACCESS EXCLUSIVE). Pola:

1. ADD COLUMN id_big bigint;
2. trigger dual-write: id_big = id
3. backfill batch
4. deploy kode baca id_big
5. contract: drop trigger, drop id, rename id_big -> id
   (rename = metadata-only, ACCESS EXCLUSIVE milidetik)

Biaya: storage ~2x untuk kolom itu, dua index selama transisi.


⚖️ Trade-off & Kapan JANGAN Dipakai

Aspek Expand–Contract Maintenance Window
Downtime ~0 menit–jam
Kompleksitas kode tinggi (dua jalur, flag, trigger) rendah
Storage sementara +1 kolom/tabel/index 0
Waktu kalender hari–minggu jam
Risiko bug dual-write, backfill lag, lupa contract outage terencana
Butuh rolling deploy + traffic kontinu izin bisnis untuk downtime

Jangan pakai expand–contract kalau:

Trade-off tersembunyi: setiap expand–contract menambah technical debt sementara yang harus dilunasi. Migrasi yang di-expand tapi tidak pernah di-contract meninggalkan kolom zombie, trigger sinkron, dan dua sumber kebenaran. Ini kegagalan paling umum. Aturan: contract phase masuk backlog dengan deadline, bukan "nanti".

Alternatif yang sering lebih tepat: logical replication cutover (bikin tabel baru, replikasi, switch), atau pg_repack / gh-ost / pt-online-schema-change yang mengotomatiskan pola ini di level tool.


🏭 Contoh Produksi & Failure Mode

1. Lock queue pileup. DDL SET NOT NULL menunggu karena ada transaksi panjang; semua SELECT di belakangnya ikut antre. Efek: aplikasi tampak "hang" total padahal CPU DB idle. Ini pola outage paling klasik. Perbaikan: lock_timeout + retry (lihat atas).

2. CREATE INDEX (non-concurrent) di jam sibuk. SHARE lock memblokir semua INSERT/UPDATE selama build. Untuk tabel besar: menit. Selalu CONCURRENTLY untuk tabel produksi. Catatan: CONCURRENTLY tidak bisa di dalam transaksi, dan kalau gagal meninggalkan index INVALID yang harus di-DROP INDEX CONCURRENTLY lalu diulang. Cek pg_index.indisvalid.

3. Backfill satu transaksi besar. UPDATE t SET ... tanpa WHERE pada 50 juta baris: transaksi berjalan 40 menit, bloat meledak, autovacuum tidak bisa membersihkan, lag replika naik ke menit-menit, read replica mulai timeout. Satu query, seluruh sistem degradasi.

4. Lupa contract. Setahun kemudian masih ada trigger dual-write. Setiap write bayar 2x. Kolom lama masih ada, index-nya masih di-maintain, dan tidak ada yang berani drop karena "takut ada yang pakai". Ini utang yang berbunga.

5. Drop kolom sebelum kode lama hilang. Rollback deploy (atau satu pod lama yang masih hidup karena canary macet) langsung error column does not exist. Aturan: contract hanya setelah versi kode yang membaca kolom lama benar-benar tidak ada di cluster — bukan setelah "kayaknya sudah".

6. MySQL beda aturan. DDL MySQL = implicit commit, tidak transaksional. ALTER TABLE di MySQL 8 dengan ALGORITHM=INSTANT bisa metadata-only untuk operasi tertentu, tapi banyak operasi lain tetap COPY (rebuild penuh + lock write). Di MySQL, tool seperti gh-ost (triggerless, pakai binlog) atau pt-online-schema-change (trigger-based) sering lebih praktis daripada expand–contract manual. Jangan asumsikan perilaku Postgres berlaku.

7. Partitioned table. DDL pada parent menyebar ke semua partisi. Backfill harus per partisi; attach partisi baru (ATTACH PARTITION) butuh validasi constraint agar tidak scan penuh.


⚠️ Miskonsepsi Umum


✅ Cek Pemahaman

  1. Kamu harus mengubah varchar(50) → varchar(120) pada tabel 300 juta baris di Postgres 15. Berapa fase yang dibutuhkan, lock apa yang muncul, dan apakah perlu expand–contract? Jelaskan alasan per langkah.
  2. Tim menjalankan ALTER TABLE ... ADD CONSTRAINT ... NOT VALID lalu VALIDATE CONSTRAINT di jam sibuk. Apa yang bisa tetap menyebabkan outage, dan pengaturan apa yang mencegahnya?
  3. Setelah backfill 100% selesai dan kode baru membaca kolom baru, apa bukti minimum yang kamu kumpulkan sebelum menjalankan DROP COLUMN? Sebutkan minimal tiga sumber bukti.

🔑 Poin Kunci


➡️ Eksplorasi Lanjut