Migrasi Database Tanpa Downtime dengan Prisma

Foto oleh Jemimus on flickr
Expand-contract adalah strategi migrasi di mana Anda menambahkan struktur skema baru berdampingan dengan yang lama, memindahkan data dan kode aplikasi secara bertahap lewat langkah-langkah kecil yang bisa dibatalkan, dan baru menghapus struktur lama setelah tidak ada lagi yang bergantung padanya. Pola ini menghindari downtime yang muncul akibat mengubah bentuk kolom dalam deploy yang sama saat kode aplikasi mengasumsikan perubahan itu sudah aktif.
Menambah kolom nullable biasa adalah perubahan metadata semata dan selesai dalam hitungan milidetik. Menambah constraint NOT NULL atau default volatile memaksa Postgres memvalidasi atau menulis ulang setiap baris yang ada, dan penulisan ulang itu menahan lock ACCESS EXCLUSIVE selama seluruh prosesnya, memblokir setiap pembacaan dan penulisan lain ke tabel tersebut sampai selesai.
Tulis skrip mandiri yang menyusuri tabel dalam batch kecil berurutan menggunakan primary key sebagai cursor, melakukan commit tiap batch dalam transaksi pendeknya sendiri, bukan satu UPDATE raksasa. Tambahkan jeda singkat di antara batch dan arahkan query verifikasi ke read replica agar backfill tidak pernah berebut lock atau koneksi dengan traffic produksi.
Tidak, kecuali dengan memulihkan dari backup atau snapshot point-in-time recovery. Menghapus kolom adalah satu-satunya langkah dalam pola expand-contract yang tidak bisa dibatalkan lewat rollback aplikasi biasa, itulah sebabnya langkah ini harus selalu menjadi migrasi khusus tersendiri yang dirilis terpisah dari perubahan kode apa pun, dengan backup baru yang diambil segera sebelumnya.
Jalankan prisma migrate dev dengan flag create-only untuk menghasilkan file migration.sql tanpa menerapkannya. Anda kemudian bisa membuka file itu, menghapus constraint yang otomatis ditambahkan CLI, memecahnya menjadi statement yang lebih aman, atau menambahkan SQL kustom seperti RENAME COLUMN, sebelum menjalankan prisma migrate dev lagi untuk menerapkan versi yang sudah diedit.

Foto oleh Jemimus on flickr
Deploy yang paling menakutkan bagi tim backend mana pun adalah migrasi skema. Menambah fitur risikonya rendah. Mengubah bentuk tabel yang sedang dihantam traffic produksi adalah permainan yang sama sekali berbeda, karena untuk sementara waktu, kode aplikasi lama dan kode aplikasi baru harus sama-sama bisa bekerja terhadap database yang sama, dan satu statement ALTER yang salah bisa menjatuhkan seluruh layanan.
Tulisan ini membahas pola expand-contract untuk Prisma dan Postgres: cara menambah kolom tanpa mengunci tabel, cara melakukan backfill jutaan baris tanpa membuat connection pool kehabisan koneksi, cara menjalankan jendela dual-write yang aman, dan cara menghapus kolom lama belakangan tanpa ada yang menyadarinya. Setiap perintah di sini bisa langsung dijalankan hari ini dengan Prisma Migrate.
Migrasi yang naif mengubah nama kolom, mengubah tipenya, atau menambah constraint NOT NULL dalam satu langkah, lalu merilis kode aplikasi baru yang berasumsi perubahan tersebut sudah aktif. Masalahnya, deployment tidak pernah instan. Selalu ada jendela waktu di mana instance server lama masih berjalan terhadap skema baru, atau instance baru berjalan terhadap skema lama, terutama pada rolling deploy di belakang load balancer. Jika migrasi dan perubahan kode tidak dipisah, jendela waktu itu menghasilkan query yang gagal, error 500, atau bahkan kerusakan data secara diam-diam. Postgres memperbesar risiko ini: beberapa bentuk ALTER TABLE mengambil lock ACCESS EXCLUSIVE, dan meskipun penambahan kolom biasa umumnya hanya perubahan metadata yang cepat, menambah kolom dengan default yang volatile, constraint NOT NULL yang butuh scan validasi penuh, atau mengubah tipe kolom bisa memaksa Postgres menulis ulang seluruh tabel beserta indeksnya, memblokir setiap pembaca dan penulis lain sampai proses itu selesai.
Jangan pernah menghapus atau mengubah fungsi sesuatu yang masih dipakai aplikasi yang sedang berjalan dalam deploy yang sama dengan perubahan itu: perluas skema dengan menambah struktur baru berdampingan dengan yang lama, pindahkan data dan kode secara bertahap lewat langkah-langkah kecil yang bisa dibatalkan, dan baru persempit dengan menghapus struktur lama setelah tidak ada lagi yang membacanya. Tabel di bawah ini menunjukkan garis waktu rollout yang diikuti tulisan ini, menggunakan tabel Invoice fiktif yang memigrasikan kolom amount dari integer sen ke kolom Decimal yang layak.
| Fase | Yang Terjadi | Kode Lama Masih Berfungsi? |
|---|---|---|
| 1. Expand | Tambahkan kolom nullable baru lewat migrasi yang cepat dan aman dari lock | Ya |
| 2. Backfill | Isi kolom baru secara bertahap dalam batch kecil dari data yang sudah ada | Ya |
| 3. Dual-write | Rilis kode yang menulis ke kedua kolom; pembacaan masih memakai kolom lama | Ya |
| 4. Cutover | Rilis kode yang membaca dan menulis hanya ke kolom baru | Ya, data lama tidak tersentuh |
| 5. Contract | Hapus kolom lama pada rilis terpisah di kemudian hari | Tidak bisa rollback setelah titik ini |
Aturan emas fase expand adalah: tambahkan kolomnya, tapi jangan sematkan constraint NOT NULL atau default volatile dalam statement yang sama. Postgres bisa menambah kolom nullable biasa sebagai perubahan metadata murni dalam hitungan milidetik, karena tidak perlu menyentuh satu pun baris yang sudah ada. Begitu Anda menambahkan NOT NULL atau nilai default yang bukan konstanta tetap, Postgres harus memvalidasi atau menulis ulang setiap baris, dan penulisan ulang itu mengambil lock eksklusif selama seluruh prosesnya. Workflow default Prisma Migrate menjalankan migrasi secara otomatis, tapi untuk hal yang sensitif sebaiknya gunakan flag create-only supaya Anda bisa meninjau dan mengedit SQL yang dihasilkan sebelum menyentuh database sama sekali. Ini memberi kesempatan untuk membuang constraint NOT NULL yang ditambahkan CLI, atau memecah satu migrasi menjadi dua statement terpisah yang lebih aman.
// schema.prisma — expand phase: add the new column alongside the old one
model Invoice {
id Int @id @default(autoincrement())
amountCents Int? // old: integer cents, still read by the current release
amount Decimal? @db.Decimal(12, 2) // new: proper decimal, nullable for now
status String
}
# Generate a draft migration without applying it
npx prisma migrate dev --create-only --name add-invoice-amount-decimal
# Edit the generated migration.sql before it runs:
# prisma/migrations/2026XXXXXXXXXX_add_invoice_amount_decimal/migration.sql
ALTER TABLE "Invoice" ADD COLUMN "amount" DECIMAL(12,2);
-- No DEFAULT, no NOT NULL yet: this is a metadata-only change,
-- so Postgres does not rewrite the table and the ACCESS EXCLUSIVE
-- lock it takes is held for milliseconds, not minutes.
# Now apply it
npx prisma migrate devMenambahkan constraint NOT NULL, default volatile seperti fungsi timestamp, atau kolom generated semuanya memaksa Postgres menulis ulang seluruh tabel beserta indeksnya di bawah lock ACCESS EXCLUSIVE. Pada tabel besar, penulisan ulang itu bisa memakan waktu bermenit-menit, dan selama itu setiap pembacaan dan penulisan ke tabel tersebut akan terblokir. Selalu tambahkan kolom sebagai nullable dulu, backfill datanya, dan baru tambahkan NOT NULL setelah setiap baris sudah memiliki nilai.
Setelah kolom baru ada, Anda perlu mengisinya untuk setiap baris yang sudah ada tanpa berebut koneksi atau lock dengan traffic produksi. Satu statement UPDATE tunggal di tabel dengan jutaan baris akan menahan lock pada setiap baris yang disentuhnya sampai transaksi selesai, yang bisa menghentikan query lain selama seluruh proses berjalan. Pendekatan yang lebih aman adalah skrip yang menyusuri tabel dalam batch berurutan, menggunakan primary key sebagai cursor, dan melakukan commit tiap batch dalam transaksi pendeknya sendiri. Ini membuat jendela lock tiap langkah kecil, memungkinkan pencatatan progres agar bisa dilanjutkan setelah crash, dan memungkinkan Anda mengatur kecepatan jika terlihat replication lag atau tekanan pada connection pool mulai meningkat. Jalankan sebagai job terpisah, bukan di dalam request handler, dan arahkan query verifikasinya ke read replica agar tidak pernah berebut dengan pembacaan produksi.
// scripts/backfill-invoice-amount.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const BATCH_SIZE = 500
async function backfill() {
let cursor: number | undefined = undefined
let migrated = 0
while (true) {
const rows = await prisma.invoice.findMany({
where: { amount: null },
take: BATCH_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { id: 'asc' },
})
if (rows.length === 0) break
await prisma.$transaction(
rows.map((row) =>
prisma.invoice.update({
where: { id: row.id },
data: { amount: (row.amountCents ?? 0) / 100 },
})
)
)
cursor = rows[rows.length - 1].id
migrated += rows.length
console.log(`Backfilled ${migrated} rows...`)
// Small pause keeps replication lag and connection pool pressure low
await new Promise((r) => setTimeout(r, 50))
}
console.log(`Done. ${migrated} rows backfilled.`)
}
backfill().finally(() => prisma.$disconnect())Tambahkan jeda singkat di antara batch. Loop rapat yang melakukan backfill secepat mungkin yang diizinkan database bisa membuat connection pool kehabisan koneksi dan memicu lonjakan replication lag pada read replica, meskipun setiap transaksi individual kecil dan cepat.
Sebelum bisa mempercayai kolom baru, rilis versi di mana kode aplikasi menulis ke kolom lama dan baru pada setiap insert dan update, sementara pembacaan masih memakai kolom lama. Inilah jaring pengamannya: jika backfill melewatkan sebuah kasus khusus atau kolom baru punya bug halus, kolom lama tetap menjadi sumber kebenaran dan tidak ada yang rusak dari sisi pengguna. Jaga jendela ini sesingkat mungkin secara wajar, tapi cukup panjang untuk menangkap satu siklus bisnis penuh dari penulisan data, termasuk job batch atau webhook mana pun yang menulis ke tabel di luar jalur request normal.
Setelah query rekonsiliasi bersih selama jendela waktu yang Anda pilih, rilis versi cutover: kode aplikasi membaca dan menulis hanya ke kolom baru, dan kolom lama menjadi beban mati, bukan lagi dependensi. Ini masih bisa dibatalkan, karena kolom lama masih ada dengan nilai terakhirnya, sehingga Anda bisa melakukan rollback rilis tanpa migrasi data. Baru setelah cutover stabil di produksi Anda melakukan contract: menghapus kolom lama dalam migrasi khusus tersendiri, dirilis terpisah dari perubahan kode apa pun. Memisahkannya penting karena menghapus kolom bersifat destruktif dan tidak boleh digabung dengan perubahan skema atau fitur lain yang tidak berkaitan.
Inti dari pola ini adalah pada setiap langkah kecuali yang terakhir, Anda bisa membatalkan rilis aplikasi tanpa menyentuh database sama sekali. Itulah yang membuat expand-contract benar-benar tanpa downtime, bukan sekadar downtime yang lebih singkat.
Dalam praktiknya ini menjadi lima pull request kecil, membosankan, dan bisa ditinjau satu per satu, alih-alih satu pull request besar yang berisiko: tambahkan kolom nullable, jalankan skrip backfill sebagai langkah deploy atau job terjadwal, rilis perubahan kode dual-write, rilis perubahan kode cutover, dan akhirnya hapus kolom lama. Setiap tahap di-deploy secara independen lewat perintah migrate deploy yang sama yang sudah dijalankan pipeline CI/CD Anda, jadi tidak ada yang aneh dari proses rilisnya sendiri, hanya disiplin untuk tidak menggabungkan lima langkah aman menjadi satu langkah berisiko.
Perlakukan setiap migrasi yang menyentuh tabel dengan traffic produksi yang berarti sebagai kandidat pola ini secara default, bukan sebagai kasus khusus yang baru dipakai ketika sesuatu sudah rusak. Pull request tambahan itu murah; insiden akibat penulisan ulang tabel di jam kerja tidak.