📚 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:
- Menilai apakah sebuah perubahan skema bisa di-deploy tanpa downtime, atau harus pakai maintenance window — dan berapa biaya lock-nya.
- Menulis urutan migrasi expand→migrate→contract untuk kasus rename, ubah tipe, NOT NULL, split tabel, drop kolom.
- Mengetahui lock level tiap DDL di Postgres/MySQL dan kenapa lock queue lebih berbahaya daripada lock itu sendiri.
- 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:
- app di-stop total (downtime), atau
- perubahan backward compatible secara alami.
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:
- Batch by PK range, bukan
LIMIT/OFFSET(OFFSET makin lambat, tidak stabil). - Commit tiap batch (1k–10k baris). Transaksi panjang = bloat, autovacuum tertahan, lag replika naik.
- Idempotent:
WHERE new_col IS NULLsupaya aman diulang setelah crash. - Throttle berdasarkan lag replika & load DB, bukan timer tetap.
- Backfill dari replika baca hanya untuk menghitung; tulis tetap ke primary.
-- 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:
- Trigger DB (
BEFORE INSERT/UPDATE): konsisten untuk semua writer, termasuk job & manual query. Tapi menambah beban write, sulit di-debug, dan harus dihapus di fase contract. - App-level dual-write: eksplisit, mudah di-rollback. Tapi tidak atomik dengan tabel lain kecuali dalam satu transaksi, dan tidak mencakup writer non-app (backoffice, ETL). Kalau ada writer lain, trigger lebih benar.
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:
- Tabel kecil & lock singkat.
ALTER TABLEpada tabel 100 ribu baris = milidetik. Expand–contract untuk ini = over-engineering murni. Ukur dulu:SELECT pg_size_pretty(pg_total_relation_size('t'))dan ujiEXPLAIN (ANALYZE)/staging. - Aplikasi single-instance, tanpa rolling deploy. Kalau hanya ada satu proses dan deploy = restart, dual-write tidak punya pembaca kedua. Migrasi satu fase + restart sudah cukup.
- Internal tool, batch job, analytics warehouse. Downtime 5 menit tidak berdampak. Zero-downtime = biaya tanpa manfaat.
- Tim belum punya observability. Tanpa metrik error per versi kode dan tanpa cara memverifikasi "kolom lama sudah tidak dibaca", fase contract adalah tembakan gelap. Prasyarat, bukan opsional.
- Belum ada disiplin deploy harian. Expand–contract butuh 3 deploy terkoordinasi. Kalau rilis tim ini sebulan sekali, timeline jadi 3 bulan dan dual-write hidup terlalu lama — risiko lebih besar dari downtime yang dihindari.
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
- "DDL transaksional Postgres berarti aman." Transaksional ≠ murah. Kamu bisa rollback, tapi rollback migrasi 40 menit juga 40 menit, dan selama itu lock/vacuum terdampak. Transaksional hanya berarti atomik.
- "Expand–contract cuma untuk rename kolom." Ini pola umum untuk semua perubahan breaking: ubah tipe, NOT NULL, split tabel, ubah semantik nilai, ganti PK, dan juga API (tambah field → deprecate → hapus, jangan ganti field langsung).
- "Backfill selesai = boleh drop." Belum. Yang menentukan adalah apakah masih ada pembaca kolom lama. Data terisi ≠ kode sudah pindah.
- "Zero-downtime = tanpa risiko." Zero-downtime memindahkan risiko dari "outage terencana" ke "bug dual-write dan inkonsistensi data". Inkonsistensi data lebih mahal daripada downtime 2 menit.
- "Tambah index selalu aman." Index memperlambat write dan menambah storage.
CONCURRENTLYaman untuk availability, bukan gratis untuk throughput. - "Bisa drop kolom, kan ada backup." Restore backup = kehilangan semua write sejak backup. Bukan rollback.
✅ Cek Pemahaman
- 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. - Tim menjalankan
ALTER TABLE ... ADD CONSTRAINT ... NOT VALIDlaluVALIDATE CONSTRAINTdi jam sibuk. Apa yang bisa tetap menyebabkan outage, dan pengaturan apa yang mencegahnya? - 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
- Skema harus kompatibel dengan dua versi kode selama rolling deploy. Ini invariant-nya.
- Lock level + lock queue menentukan downtime, bukan ukuran tabel saja.
lock_timeout+ retry = pertahanan wajib terhadap DDL di produksi.- Backfill: batch by PK, commit per batch, idempotent, throttle pakai lag replika.
- Contract phase adalah kewajiban berdeadline, bukan follow-up opsional.
- Expand–contract mahal dalam waktu dan kompleksitas. Untuk tabel kecil atau app dengan maintenance window, satu migrasi + downtime lebih rasional.
- MySQL ≠ Postgres. Perilaku DDL berbeda; pilih tool yang sesuai, jangan porting asumsi.
➡️ Eksplorasi Lanjut
- Logical replication cutover: bangun tabel baru, replikasi dari lama, switch saat lag ~0. Alternatif untuk perubahan yang tidak bisa di-expand (ganti PK, restrukturisasi besar).
- Tool:
gh-ost,pt-online-schema-change,pg_repack,strong_migrations(Rails),squawk/eugene(linter migrasi Postgres). Linter migrasi adalah investasi kecil dengan hasil besar: menolak DDL berbahaya sebelum masuk review. - Shadow read & data reconciliation: cara membuktikan kolom baru benar sebelum jadi sumber kebenaran.
- Schema versioning vs migration ordering di sistem multi-service — siapa boleh mengubah tabel bersama, dan bagaimana menghindari shared-database coupling yang membuat expand–contract mustahil.
- Bounded context & data ownership (nyambung ke DDD): migrasi paling mudah ketika satu tabel hanya dimiliki satu service.