Langsung ke konten
Migrasi Skema Database Tanpa Downtime: Expand and Contract dari Kasus Production yang Saya Alami
Kembali ke Blog
Database·6 menit baca·

Migrasi Skema Database Tanpa Downtime: Expand and Contract dari Kasus Production yang Saya Alami

Cara saya melakukan migrasi skema database tanpa downtime di production dengan pola expand-contract, menghindari lock tabel PostgreSQL, dan rollback yang aman. Panduan praktisi backend.

TH
Tomi Hartanto·Senior Software Engineer

Jumat malam tahun lalu, saya menambah satu kolom NOT NULL ke tabel orders yang isinya 40 juta baris. Migrasinya jalan 45 menit, dan selama 45 menit itu setiap transaksi checkout pelanggan menggantung sampai timeout. Bukan karena PostgreSQL lambat — tapi karena ALTER TABLE ... SET NOT NULL mengambil ACCESS EXCLUSIVE LOCK di tabel tersebut, dan semua query lain antre di belakangnya. Sejak itu saya tidak pernah lagi melihat migrasi database sebagai sekadar "jalankan migrate di CI". Artikel ini adalah rangkuman pola yang sekarang saya pakai untuk migrasi skema tanpa downtime, khususnya di PostgreSQL + Prisma.

Ilustrasi antrean query menggantung menunggu lock tabel

Masalah Sebenarnya Bukan Downtime Deployment, Tapi Lock

Banyak tim mengira "tanpa downtime" berarti zero-downtime deployment — blue-green, rolling restart, dsb. Itu bagian dari solusi, tapi masalah yang lebih licik ada di level database: banyak operasi DDL di PostgreSQL membutuhkan lock eksklusif, dan lock itu tidak menunggu dengan sopan. Query SELECT biasa pun akan tertahan sampai DDL selesai.

Beberapa operasi yang sering menjebak:

  • ALTER TABLE ... ADD COLUMN ... NOT NULL tanpa default: cepat (hanya metadata), tapi tidak di semua versi dan kombinasi.
  • ADD COLUMN ... DEFAULT x non-volatile: sejak PostgreSQL 11 ini cuma perubahan metadata, jadi cepat. Ini pengecualian yang menguntungkan.
  • SET NOT NULL di kolom existing: full table scan untuk validasi. Di tabel 40 juta baris, itu 45 menit saya kemarin.
  • Membuat index tanpa CONCURRENTLY: blok semua write.
  • Drop column, ganti tipe kolom: lock eksklusif plus rewrite tabel.

Pendekatan expand and contract — yang dibahas panjang dalam panduan zero-downtime database migrations — intinya sederhana: jangan pernah mengubah skema dan aplikasi dalam satu langkah. Pecah jadi beberapa deploy yang masing-masing kompatibel dengan skema lama maupun baru.

Pola Expand and Contract, Versi Praktis

Alurnya untuk kasus "tambah kolom wajib" seperti kasus saya:

Deploy 1 (expand): tambah kolom nullable, tanpa default.

ALTER TABLE orders ADD COLUMN payment_channel TEXT;
-- di Prisma: buat migration.sql manual, jangan generate polos

Kode aplikasi di deploy ini menulis ke kedua kolom (lama dan baru) tapi masih membaca dari kolom lama. Ini fokusnya: read path tidak berubah sama sekali.

Backfill bertahap: isi data lama bukan dengan satu UPDATE raksasa, tapi batch:

UPDATE orders
SET payment_channel = legacy_channel
WHERE payment_channel IS NULL
  AND id IN (
    SELECT id FROM orders
    WHERE payment_channel IS NULL
    LIMIT 10000
  );

Saya jalankan ini via background job — pendekatan queue yang mirip dengan yang saya tulis di artikel tentang background job dan BullMQ. Batch kecil + jeda antar batch = lock row yang singkat, tidak mengganggu transaksi user. Di proyek terakhir, backfill 40 juta baris selesai dalam 3 jam tanpa ada keluhan latency.

Deploy 2 (switch read): aplikasi mulai membaca kolom baru, tapi masih menulis keduanya. Ini jendela rollback paling murah — kalau ada bug, tinggal balik ke deploy 1.

Deploy 3 (contract): baru di sini set constraint dan bersihkan:

-- PostgreSQL 12+: validasi tanpa menahan write lama
ALTER TABLE orders
  ADD CONSTRAINT orders_payment_channel_not_null
  CHECK (payment_channel IS NOT NULL) NOT VALID;

ALTER TABLE orders VALIDATE CONSTRAINT orders_payment_channel_not_null;

ALTER TABLE orders ALTER COLUMN payment_channel SET NOT NULL;
-- cek constraint NOT VALID memungkinkan SET NOT NULL skip full scan

Trik CHECK ... NOT VALID lalu VALIDATE CONSTRAINT ini yang menyelamatkan saya. VALIDATE hanya butuh SHARE UPDATE EXCLUSIVE LOCK, jadi write tetap jalan. Terakhir, hapus kolom lama — biasanya saya tunda beberapa minggu, bukan langsung, kalau-kalau ada laporan atau script lama yang masih mereferensikannya.

Diagram empat fase expand contract: add column, backfill, switch read, drop column

Alat Bantu dan Kapan Tidak Perlu Repot Sebesar Itu

Kalau tim Anda pakai Django, library django-pg-zero-downtime-migrations mengotomatiskan sebagian besar pola di atas — dia menulis ulang migrasi Django jadi varian yang aman lock. Menariknya, topik ini juga sudah diminati lama: pertanyaan klasik di Stack Overflow soal deploy aplikasi enterprise dengan perubahan skema SQL tanpa downtime sudah ada sejak 2011, dan jawabannya masih relevan: pisahkan deployment kode dari perubahan skema.

Tapi jujur: pola expand-contract itu mahal secara koordinasi — tiga deploy untuk satu kolom. Untuk tabel kecil (ratusan ribu baris, traffic rendah), SET NOT NULL langsung selesai dalam hitungan detik dan tidak akan terasa. Saya tetap pakai jalur cepat kalau tabelnya kecil dan saya yakin tidak ada long-running transaction yang sedang berjalan. Yang penting adalah tahu ukuran tabel dan pola aksesnya sebelum memutuskan.

Dengan Prisma khususnya, hati-hati: prisma migrate dev yang generate schema diff polos sering menghasilkan DDL yang tidak aman lock-nya (misal DROP COLUMN langsung). Kebiasaan saya sekarang: selalu baca migration.sql yang di-generate, edit manual bila perlu, dan jalankan lewat prisma migrate deploy yang sudah saya review.

Checklist yang Saya Pakai Sebelum Setiap Migrasi Production

Ini versi ringkas yang sekarang jadi standar di tim saya, terinspirasi setelah dua kali kena masalah lock:

  1. Cek pg_stat_activity untuk long-running transaction sebelum DDL — satu transaksi idle-in-transaction bisa membuat CREATE INDEX CONCURRENTLY menggantung berjam-jam.
  2. Selalu lock_timeout sebelum DDL berat: SET lock_timeout = '5s'; — lebih baik DDL gagal dan retry daripada menggantungkan seluruh tabel.
  3. Index selalu dengan CREATE INDEX CONCURRENTLY, meski lambat dan tidak bisa jalan dalam satu transaksi.
  4. Satu DDL per migration file — rollback dan analisis jadi jauh lebih mudah.
  5. Deploy kode dulu yang kompatibel dengan kedua versi skema, baru jalankan contract phase.

Terakhir, soal kolom yang masih dipakai foreign key atau view yang luput — PostgreSQL akan menolak drop dan itulah penyelamat Anda. Yang lebih berbahaya adalah kode aplikasi lama di worker yang belum dideploy dan masih menulis ke kolom lama. Sinkronkan versi kode semua worker sebelum contract phase, bukan sesudahnya.

Kalau saya harus merangkum satu hal: migrasi tanpa downtime itu 20% soal SQL, 80% soal urutan deploy. Kalau Anda bisa menjamin setiap versi kode kompatibel dengan dua versi skema berturut-turut, hampir semua masalah di artikel ini hilang dengan sendirinya.

Kebiasaan menulis SET lock_timeout sebelum DDL saja sudah menyelamatkan saya dari insiden dua kali. Mulai dari situ saja kalau pola lengkapnya terasa berat.

Sumber

TH

Tomi Hartanto

Senior Software Engineer

Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.