Pola Expand/Contract untuk Migrasi Skema yang Aman

Foto oleh FOTO:Fortepan — ID 91477 : Adományozó/Donor: UVATERV. via Wikimedia Commons (CC BY-SA 3.0)
Expand/contract, juga disebut parallel change, adalah teknik mengubah skema database tanpa downtime. Alih-alih satu migrasi yang merusak, Anda memperluas skema untuk menahan bentuk lama dan baru, memigrasi data dan kode aplikasi melalui langkah-langkah yang kompatibel mundur, lalu berkontraksi dengan menghapus bentuk lama setelah tidak ada yang memakainya. Setiap keadaan antara menjaga aplikasi berjalan tetap bekerja.
Selama rolling deploy, versi lama dan baru aplikasi Anda berjalan bersamaan selama beberapa menit. Satu rename menciptakan jendela di mana satu versi aplikasi mengharapkan nama kolom lama dan yang lain mengharapkan yang baru, jadi salah satunya rusak. Expand/contract menghindari ini dengan menjaga kedua kolom hidup sampai setiap instance aplikasi yang ter-deploy membaca yang baru.
Pada PostgreSQL 11 ke atas, menambah kolom dengan default konstan adalah perubahan metadata-only yang cepat dan tidak menulis ulang tabel. Namun default volatile seperti UUID acak tetap memaksa rewrite penuh di bawah lock ACCESS EXCLUSIVE, dan menambah NOT NULL memindai setiap baris. Langkah expand yang aman menambah kolom sebagai nullable dan menunda constraint ke langkah berikutnya.
Jangan pernah menjalankan satu UPDATE raksasa di jutaan baris — itu menahan lock sepanjang transaksi dan membengkakkan tabel. Sebaliknya update secara batch kecil, beberapa ribu baris per transaksi, dengan commit di antara batch dan jeda singkat. Ini menjaga lock tetap pendek, membiarkan autovacuum mengejar, dan membatasi dampak kegagalan hanya ke batch saat ini.
Menambah NOT NULL langsung memindai seluruh tabel di bawah lock berat. Sebaliknya tambahkan constraint CHECK dengan klausa NOT VALID, yang instan karena melewati baris yang ada, lalu jalankan VALIDATE CONSTRAINT terpisah. Validasi memakai lock SHARE UPDATE EXCLUSIVE yang lebih ringan yang tidak memblokir baca dan tulis bersamaan.

Foto oleh FOTO:Fortepan — ID 91477 : Adományozó/Donor: UVATERV. via Wikimedia Commons (CC BY-SA 3.0)
Ringkasan Utama
Expand/contract memecah satu perubahan skema berisiko menjadi lima deploy yang masing-masing tetap kompatibel mundur: perluas skema, backfill data, tulis ganda agar kedua kolom hidup, alihkan baca ke kolom baru, lalu kontraksi dengan menghapus kolom lama. Tidak ada satu langkah pun yang merusak aplikasi berjalan, jadi Anda memigrasi database live tanpa downtime.
Deploy paling menakutkan yang saya jalankan bukanlah perubahan kode. Itu adalah perubahan skema pada tabel yang sedang dibaca dan ditulis oleh layanan live ribuan kali per detik. Ganti nama kolom dalam satu migrasi dan Anda punya jendela di mana aplikasi yang ter-deploy mengharapkan nama lama sementara database hanya punya yang baru, atau sebaliknya. Selama rolling deploy kedua versi aplikasi berjalan bersamaan, jadi tidak ada momen di mana rename yang merusak itu aman.
Solusinya adalah pola dengan beberapa nama: expand/contract, atau parallel change. Alih-alih satu perubahan atomik, Anda membuat skema menahan bentuk lama dan baru pada saat yang sama, bermigrasi melewatinya dalam langkah-langkah kecil yang kompatibel mundur, dan hanya menghapus bentuk lama setelah tidak ada lagi yang membacanya. Saya menggunakannya untuk setiap perubahan skema destruktif pada tabel yang penting.
Seluruh pola ini adalah sebuah janji: tidak pernah ada titik di mana database dan aplikasi yang sedang ter-deploy berselisih tentang apa yang harus ada. Setiap langkah adalah commit sendiri dan deploy sendiri. Anda tidak pernah menggabungkan expand dengan contract dalam rilis yang sama. Berikut bentuknya, memakai contoh klasik: memecah satu kolom full_name menjadi first_name dan last_name.
| Langkah | Aksi | Aplikasi tetap jalan karena |
|---|---|---|
| 1. Expand | Tambah kolom baru yang nullable; jangan sentuh yang lama | Kode lama mengabaikan kolom yang tidak dikenalnya |
| 2. Backfill | Salin data yang ada ke kolom baru secara batch | Baca masih pakai kolom lama; yang baru sedang diisi |
| 3. Tulis ganda | Deploy kode yang menulis ke kolom lama dan baru | Kedua kolom tetap sinkron untuk setiap baris baru |
| 4. Alihkan | Deploy kode yang membaca dari kolom baru | Kolom baru kini terisi penuh dan tepercaya |
| 5. Kontraksi | Berhenti menulis kolom lama, lalu hapus | Tidak ada lagi yang membaca atau menulisnya |
Langkah expand harus murah. Pada PostgreSQL 11 ke atas, menambah kolom dengan default konstan adalah operasi metadata-only yang disimpan di katalog sistem dan diterapkan saat baca, jadi tidak lagi menulis ulang tabel. Sebelum versi 11, statement yang sama menulis ulang setiap baris di bawah lock ACCESS EXCLUSIVE, yang pada tabel besar berarti menit-menit lalu lintas terblokir. Bahkan di Postgres modern, default volatile seperti UUID acak tetap memaksa rewrite penuh, jadi tambahkan kolom sebagai nullable dan backfill terpisah.
-- Expand: cheap, metadata-only, no table rewrite.
-- Add columns as NULLABLE. Do NOT add NOT NULL yet --
-- an unpopulated NOT NULL column would reject every insert.
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name text;Jangan pernah menambah kolom NOT NULL di langkah expand. Aplikasi lama yang masih berjalan tidak tahu kolom itu ada dan tidak akan menyuplai nilai, jadi setiap insert-nya gagal. Tambah sebagai nullable sekarang; tambahkan constraint NOT NULL hanya setelah backfill, dan bahkan setelah itu tambahkan NOT VALID lebih dulu.
Satu UPDATE users SET ... di jutaan baris memakan satu transaksi panjang, menahan row lock sepanjang waktu, dan membengkakkan tabel dengan dead tuple yang lalu harus dikejar vacuum. Batch-kan. Update beberapa ribu baris per transaksi, commit, tidur sebentar, ulangi. Ini menjaga setiap lock tetap pendek, membiarkan autovacuum mengejar, dan berarti kegagalan hanya me-rollback batch saat ini alih-alih pekerjaan berjam-jam.
-- Backfill loop: run outside a single transaction.
-- Each batch commits on its own.
DO $$
DECLARE
rows_updated integer;
BEGIN
LOOP
UPDATE users
SET first_name = split_part(full_name, ' ', 1),
last_name = substr(full_name, length(split_part(full_name,' ',1)) + 2)
WHERE first_name IS NULL
AND id IN (
SELECT id FROM users WHERE first_name IS NULL LIMIT 5000
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT; -- procedural COMMIT, Postgres 11+
PERFORM pg_sleep(0.1);
END LOOP;
END $$;Setelah backfill mencakup baris historis, Anda masih punya celah: baris yang ditulis antara backfill dan switch hanya akan mengisi kolom lama. Tulis ganda menutupnya. Deploy kode aplikasi yang menulis baik full_name lama maupun first_name/last_name baru pada setiap insert dan update. Kini kedua representasi tetap identik ke depan. Hanya setelah deploy itu ter-rollout penuh barulah Anda mengirim deploy berikutnya yang membaca dari kolom baru.
// Step 3 deploy: write BOTH shapes. Reads still use full_name.
await db.user.update({
where: { id },
data: {
fullName: `${firstName} ${lastName}`, // old, still authoritative
firstName, // new, now kept in sync
lastName,
},
});
// Step 4 deploy (next release): reads switch to the new columns.
// Writes still populate both until the contract step lands.
const { firstName, lastName } = await db.user.findUniqueOrThrow({
where: { id },
select: { firstName: true, lastName: true },
});Beri jeda nyata antara switch (langkah 4) dan contract (langkah 5) — minimal satu siklus deploy penuh, idealnya satu-dua hari. Jendela itu adalah jaring pengaman Anda: jika membaca kolom baru memunculkan bug backfill, kolom lama masih terisi dan benar, jadi rollback cukup revert satu baris alih-alih insiden.
Contract adalah cermin dari expand. Pertama deploy kode yang berhenti menulis kolom lama, pastikan tidak ada yang mereferensikannya, lalu hapus dalam migrasi terakhir. Di sinilah Anda juga menaikkan kolom baru menjadi NOT NULL bila domain mengharuskannya. Menambah NOT NULL langsung memindai seluruh tabel di bawah lock berat; sebaliknya tambahkan constraint CHECK ... NOT VALID, yang melewati pemindaian, lalu VALIDATE CONSTRAINT terpisah di bawah lock SHARE UPDATE EXCLUSIVE yang lebih ringan yang tidak memblokir baca dan tulis.
-- Enforce NOT NULL without a blocking full-table scan:
ALTER TABLE users
ADD CONSTRAINT users_first_name_not_null
CHECK (first_name IS NOT NULL) NOT VALID; -- instant, no scan
ALTER TABLE users
VALIDATE CONSTRAINT users_first_name_not_null; -- scans, but lighter lock
-- Finally, the contract itself:
ALTER TABLE users DROP COLUMN full_name;Lindungi setiap statement pengubah skema dengan lock_timeout pendek. Bahkan ALTER metadata-only harus sesaat mengambil ACCESS EXCLUSIVE, dan jika query berjalan lama menahan tabel, ALTER Anda mengantre di belakangnya dan memblokir semua lalu lintas selama menunggu. Timeout pendek membuat migrasi gagal cepat dan mencoba ulang alih-alih membekukan produksi.
Lima deploy untuk satu rename kolom adalah overhead nyata, dan saya tidak memakainya di mana-mana. Pada tabel internal berlalu-lintas rendah saya ambil jendela pemeliharaan singkat dan rename sekali jalan. Expand/contract layak biayanya pada tabel yang besar, panas, dan berada di depan layanan yang tidak boleh mati. Daftar di bawah kira-kira cara saya memutuskan.
Model mental yang membuat semua ini masuk akal: deploy bukan satu hal atomik. Database Anda dan beberapa versi aplikasi Anda hidup berdampingan selama beberapa menit sekaligus. Expand/contract sekadar menolak membiarkan versi-versi yang berdampingan itu berselisih tentang skema. Begitu Anda menghayati itu, migrasi menakutkan berhenti menakutkan — ia menjadi checklist membosankan yang Anda jalankan dengan cara sama setiap kali.