Skip to main content
BlogBackend

Pola Expand/Contract untuk Migrasi Skema yang Aman

Cara mengganti nama atau merestrukturisasi kolom database yang sedang berjalan tanpa downtime dengan menjalankan kolom lama dan baru berdampingan melalui rollout lima langkah.

Matthews Wong

Software Engineer

Juli 20269 menit baca
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)

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.

Lima langkah, dan mengapa urutan 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.

LangkahAksiAplikasi tetap jalan karena
1. ExpandTambah kolom baru yang nullable; jangan sentuh yang lamaKode lama mengabaikan kolom yang tidak dikenalnya
2. BackfillSalin data yang ada ke kolom baru secara batchBaca masih pakai kolom lama; yang baru sedang diisi
3. Tulis gandaDeploy kode yang menulis ke kolom lama dan baruKedua kolom tetap sinkron untuk setiap baris baru
4. AlihkanDeploy kode yang membaca dari kolom baruKolom baru kini terisi penuh dan tepercaya
5. KontraksiBerhenti menulis kolom lama, lalu hapusTidak ada lagi yang membaca atau menulisnya

Langkah 1: Expand tanpa mengunci tabel

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.

Langkah 2: Backfill secara batch, bukan satu UPDATE besar

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 $$;

Langkah 3 dan 4: Tulis ganda, lalu balik baca

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.

Langkah 5: Kontraksi, dan tambah constraint dengan cara aman

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.

Kapan repotnya sepadan

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.

  • Pakai saat: tabel cukup besar sehingga rewrite mengunci secara terasa, atau layanan punya target uptime ketat dan rolling deploy berarti dua versi aplikasi berjalan sekaligus.
  • Lewati saat: tabel kecil atau internal, lalu lintas rendah, dan downtime pemeliharaan beberapa detik memang bisa diterima.
  • Selalu: jaga setiap langkah dalam migrasinya sendiri dan deploy-nya sendiri, dan jangan pernah mencampur expand dan contract dalam satu rilis.
  • Bonus: bentuk yang sama berlaku di luar kolom — memecah tabel, mengubah tipe, atau memindah foreign key semuanya mengikuti expand, migrate, switch, contract.

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu pola expand/contract dalam migrasi database?

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.

Mengapa saya tidak bisa langsung mengganti nama kolom dalam satu migrasi?

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.

Apakah menambah kolom di PostgreSQL mengunci seluruh tabel?

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.

Bagaimana cara backfill kolom baru tanpa mengunci tabel besar?

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.

Bagaimana menambah constraint NOT NULL tanpa downtime di Postgres?

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.