Ketika Migrasi Database Merusak Aplikasi yang Sedang Berjalan

Tim Anda baru saja men-deploy fitur baru. Peluncuran terlihat bersih. Namun lima menit kemudian, engineer on-call mengirimkan tangkapan layar log error. Instance aplikasi lama crash dengan error database. Query SELECT * FROM users WHERE status = 'active' tiba-tiba gagal. Apa yang terjadi?

Anda mengubah kolom status dari VARCHAR menjadi INT di migrasi. Kode aplikasi baru menangani integer dengan baik. Namun selama rolling update, instance aplikasi lama dan baru berjalan berdampingan. Instance lama masih mengharapkan string. Skema database berubah di bawah mereka, dan mereka pun rusak.

Inilah ketegangan inti dari migrasi database dalam deployment modern: database bersifat bersama, tetapi versi aplikasi tidak.

Masalah Database Bersama

Ketika Anda melakukan deployment dengan rolling update, blue-green deployment, atau canary release, beberapa versi aplikasi berjalan secara bersamaan. Semuanya terhubung ke database yang sama. Namun setiap versi memiliki ekspektasi berbeda tentang skema.

Aplikasi lama mengharapkan kolom, tipe data, dan constraint tertentu. Aplikasi baru mengharapkan struktur yang sedikit berbeda. Keduanya harus bekerja dengan benar selama periode transisi. Jika migrasi Anda memutus kompatibilitas dengan aplikasi lama, Anda akan mendapatkan error produksi.

Ini bukan masalah teoretis. Ini terjadi setiap kali migrasi mengubah sesuatu yang bergantung pada kode yang sedang berjalan.

Kompatibilitas Mundur: Aturan yang Tidak Bisa Ditawar

Aturan dasarnya sederhana: setiap migrasi harus kompatibel mundur dengan aplikasi lama. Kode lama harus bisa membaca dan menulis data tanpa error, bahkan setelah migrasi dijalankan.

Pertimbangkan dua migrasi SQL ini untuk melihat perbedaannya:

-- Aman: Menambahkan kolom nullable dengan nilai default
-- Aplikasi lama masih bisa INSERT tanpa menyebut phone_number
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) DEFAULT NULL;

-- Merusak: Mengubah tipe kolom dari VARCHAR ke INT
-- SELECT * FROM users WHERE status = 'active' dari aplikasi lama akan gagal
-- karena 'active' adalah string, bukan integer
ALTER TABLE users ALTER COLUMN status TYPE INT USING status::integer;

Beberapa perubahan secara alami kompatibel mundur. Menambahkan kolom nullable dengan nilai default, misalnya, tidak merusak query yang ada. INSERT INTO users (name, email) dari aplikasi lama masih berfungsi karena kolom phone yang baru menerima null.

Perubahan lain langsung memutus kompatibilitas. Mengubah tipe kolom, mengganti nama kolom, menambahkan constraint NOT NULL ke kolom yang sudah terisi, atau menambahkan foreign key yang tidak bisa dipenuhi data yang ada akan menyebabkan error di instance aplikasi lama.

Aturan ini tidak opsional. Jika Anda tidak bisa menjamin kompatibilitas mundur, Anda tidak bisa melakukan deployment dengan aman tanpa downtime.

Pola Expand-Contract

Cara teraman untuk menangani perubahan yang merusak adalah pola expand-contract, kadang disebut dual-write. Idenya adalah melakukan perubahan secara bertahap, tidak pernah menghapus struktur lama sampai semua instance aplikasi telah diperbarui.

Diagram urutan berikut mengilustrasikan garis waktu pola expand-contract, menunjukkan bagaimana instance aplikasi lama dan baru berinteraksi dengan database selama setiap fase.

sequenceDiagram participant OldApp as Aplikasi Lama participant DB as Database participant NewApp as Aplikasi Baru Note over OldApp,NewApp: Fase 1: Expand DB->>DB: Tambah kolom baru (pertahankan lama) OldApp->>DB: Baca/Tulis kolom lama (OK) NewApp->>DB: Tulis kedua kolom (OK) Note over OldApp,NewApp: Fase 2: Migrasi Data DB->>DB: Backfill kolom baru dari kolom lama Note over OldApp,NewApp: Fase 3: Perbarui Semua Aplikasi OldApp->>NewApp: Deploy versi baru NewApp->>DB: Baca/Tulis kedua kolom (OK) Note over OldApp,NewApp: Fase 4: Contract DB->>DB: Hapus kolom lama NewApp->>DB: Baca/Tulis hanya kolom baru (OK)

Fase 1: Expand. Tambahkan struktur baru tanpa menghapus yang lama. Jika Anda ingin mengganti status (VARCHAR) dengan status_id (INT), tambahkan kolom baru sambil mempertahankan yang lama. Aplikasi baru menulis ke kedua kolom. Aplikasi lama terus menggunakan status. Keduanya berfungsi.

Fase 2: Migrasi data. Backfill kolom baru dengan nilai yang dikonversi dari kolom lama. Ini bisa dijalankan sebagai background job atau langkah migrasi terpisah.

Fase 3: Perbarui kode aplikasi. Deploy versi aplikasi baru ke semua instance. Sekarang setiap instance yang berjalan mengetahui kedua kolom.

Fase 4: Contract. Dalam deployment terpisah, hapus kolom lama. Pada titik ini, tidak ada aplikasi yang berjalan bergantung padanya.

Pola ini menambah kompleksitas. Kode aplikasi Anda perlu menangani logika dual-write selama transisi. Anda memiliki kolom ekstra yang harus dipertahankan sementara. Namun ini adalah harga yang harus dibayar untuk menghindari downtime dan error selama deployment.

Kompatibilitas Maju: Arah Sebaliknya

Kompatibilitas mundur melindungi kode aplikasi lama. Kompatibilitas maju melindungi kode aplikasi baru ketika database belum sepenuhnya termigrasi.

Pertimbangkan skenario di mana Anda men-deploy aplikasi baru terlebih dahulu, tetapi migrasi belum dijalankan di semua replika database. Kode baru perlu menangani format skema lama dan baru. Jika ia membaca status sebagai VARCHAR tetapi mengharapkan INT, ia harus menangani konversi dengan baik.

Kompatibilitas maju lebih sulit dicapai dan biasanya memiliki batasan. Ini berarti kode baru Anda harus defensif terhadap data yang dibacanya. Ia tidak boleh berasumsi bahwa skema sudah berubah. Ini sering berarti menambahkan logika fallback atau konversi data di lapisan aplikasi sampai migrasi selesai.

Lebih dari Sekadar Kolom: Index, Constraint, dan Foreign Key

Kompatibilitas tidak hanya tentang kolom dan tipe data. Index, constraint, dan foreign key juga bisa merusak aplikasi yang sedang berjalan.

Menambahkan foreign key baru dapat menyebabkan query INSERT atau UPDATE yang ada gagal jika data yang direferensikan tidak ada. Menambahkan constraint UNIQUE ke kolom yang sebelumnya mengizinkan duplikat akan merusak query apa pun yang mencoba memasukkan nilai duplikat. Bahkan menambahkan index dapat menyebabkan masalah performa jika database mengunci tabel selama pembuatan index.

Setiap perubahan skema perlu dievaluasi dampaknya terhadap kode aplikasi yang sedang berjalan. Tanyakan pada diri sendiri: apakah perubahan ini akan menyebabkan query dari aplikasi lama gagal? Apakah akan mengubah perilaku dengan cara yang tidak diharapkan oleh kode lama?

Daftar Periksa Praktis untuk Migrasi yang Aman

Sebelum menjalankan migrasi di produksi, verifikasi poin-poin ini:

  • Dapatkah aplikasi lama membaca semua data yang ada tanpa error setelah migrasi?
  • Dapatkah aplikasi lama menulis data baru tanpa error setelah migrasi?
  • Apakah semua kolom baru nullable atau memiliki nilai default?
  • Apakah constraint baru sudah berlaku untuk data yang ada?
  • Apakah migrasi akan menyebabkan table lock yang memblokir query?
  • Apakah ada rencana rollback jika terjadi kesalahan?

Kesimpulan

Migrasi database selama deployment tanpa downtime memerlukan perlakuan terhadap skema sebagai antarmuka bersama antara versi aplikasi. Setiap migrasi harus kompatibel mundur dengan kode lama. Perubahan yang merusak memerlukan pola expand-contract, menambahkan struktur baru terlebih dahulu dan menghapus yang lama hanya setelah semua instance diperbarui.

Database adalah sumber kebenaran tunggal yang digunakan bersama oleh semua versi aplikasi. Jika Anda mengubahnya secara sembarangan, Anda merusak kode yang berjalan. Rancang migrasi Anda seperti jembatan yang bisa dilintasi dengan aman oleh aplikasi lama dan baru. Hanya setelah semua instance pindah ke sisi baru, Anda boleh memodifikasi jembatan itu sendiri.