PostgreSQL Şema Migrasyonlarını Yönetmek

Selamlar, bu yazımda PostgreSQL şema migrasyonlarını biraz toparlayacağız. Konuyu sevdiğim için anlatacağım ama uyarayım: migrasyon, kod kadar kolay konuşulan bir alan değil. Bir tablo silindi, bir sütun tipi değişti derken canlıdaki sorgular bir anda 'relation does not exist' demeye başlıyor. Hadi nasıl bu duvara toslamadan ilerleriz, ona bakalım.

Neden bir araca ihtiyacımız var?

İlk akla gelen soru bu. 'Ben SQL dosyalarımı kendim yönetirim' diyebilirsiniz, ama birkaç geliştiriciyle çalışmaya başlayınca işin rengi değişiyor. Hangi migration ne zaman çalıştı? Staging'de var mı, prod'da var mı? Hangi sıraya göre uygulanmalı? Bu soruların cevabını el ile vermeye çalışmak, bence kısa sürede ekibi tüketir.

Migrasyon araçları aslında basit bir şey yapıyor: veritabanında küçük bir tablo (flyway_schema_history, alembic_version gibi) tutuyor ve hangi script'in uygulandığını oraya yazıyor. Geri kalan her şey bu tablonun üzerine inşa ediliyor.

Üç popüler seçenek

Türk ekiplerinde en sık karşılaştığım üçlü Flyway, Liquibase ve Alembic. Şahsi kanaatim şu: SQL'inizi olduğu gibi yazmak istiyorsanız Flyway, dilden bağımsız bir DSL istiyorsanız Liquibase, Python ekosistemindeyseniz Alembic. Hadi her birine kısaca bakalım.

Flyway

Flyway'in en güzel yanı saf SQL ile çalışabilmesi. Dosya isimlendirmesi V<sürüm>__<açıklama>.sql şeklinde:

-- V1__kullanicilar_tablosu.sql
CREATE TABLE kullanicilar (
    id SERIAL PRIMARY KEY,
    eposta VARCHAR(255) UNIQUE NOT NULL,
    olusturulma TIMESTAMP DEFAULT NOW()
);

-- V2__kullanicilara_isim_ekle.sql
ALTER TABLE kullanicilar ADD COLUMN isim VARCHAR(100);

Çalıştırması da bir o kadar düz:

flyway migrate     # bekleyen migrasyonlari uygula
flyway info        # durum ozeti
flyway repair      # bozulan checksum'lari onar

Alembic

Python ile çalışıyorsanız, özellikle SQLAlchemy modelleriniz varsa Alembic'in --autogenerate özelliği ciddi zaman kazandırır. Modeli değiştiriyorsunuz, Alembic farkı çıkarıp size bir migration dosyası üretiyor:

alembic revision --autogenerate -m "kullanicilar tablosunu olustur"
alembic upgrade head

Üretilen dosya da okunaklı:

# migrations/versions/001_kullanicilar_tablosu.py
from alembic import op
import sqlalchemy as sa


def upgrade():
    # Yeni tabloyu olusturuyoruz
    op.create_table(
        'kullanicilar',
        sa.Column('id', sa.Integer(), primary_key=True),
        sa.Column('eposta', sa.String(255), nullable=False, unique=True),
    )


def downgrade():
    op.drop_table('kullanicilar')

downgrade kısmını ihmal etmeyin; canlıda 'şuna geri dön' demek bazen hayat kurtarıyor.

Sıfır kesintili migrasyon

Bence yazının asıl önemli kısmı burası. Küçük bir tabloda ALTER TABLE neyse de, milyonlarca satırlı bir tabloda yanlış komut size dakikalarca kilit getirir. Birkaç altın kural var.

İlk olarak index'leri her zaman CONCURRENTLY ile oluşturun. Bu, yazma işlemlerini bloklamaz:

CREATE INDEX CONCURRENTLY idx_kullanicilar_eposta
    ON kullanicilar(eposta);

İkincisi, foreign key gibi kısıtları önce NOT VALID ile ekleyip sonra ayrı bir adımda doğrulayın. Böylece tablo taraması kilit tutmadan ilerler:

ALTER TABLE siparisler
    ADD CONSTRAINT fk_kullanici
    FOREIGN KEY (kullanici_id) REFERENCES kullanicilar(id) NOT VALID;

ALTER TABLE siparisler VALIDATE CONSTRAINT fk_kullanici;

Bir sütun tipini değiştirmek istiyorsanız, tek ALTER COLUMN ... TYPE ile gitmek yerine adımları parçalayın: yeni sütunu nullable olarak ekleyin, veriyi parça parça doldurun, sonra NOT NULL koyun, en son eski sütunu düşürün. Uzun gibi görünüyor, ama canlıyı düşürmüyor.

Sık karşılaşılan tuzaklar

  • ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ... PostgreSQL 10 ve öncesinde tabloyu baştan yazar: Yeni sürümlerde bu çoktan optimize edildi, ama hâlâ eski bir cluster'la çalışıyorsanız ekleyip backfill etmek daha güvenli.
  • CREATE INDEX (CONCURRENTLY olmadan) yazma kilidi alır: Trafik varken çalıştırırsanız uygulama saniyelerce bekler. Üretimde bunu yapmak istemezsiniz.
  • Migration dosyasını sonradan düzenlemek: Flyway checksum tutar, dosyayı değiştirirseniz bir sonraki migrate komutu hata verir. Yapacağınız değişikliği yeni bir versiyon olarak ekleyin.
  • Geri alma planı olmadan deploy: downgrade veya down.sql dosyasını şart koşun. 'Geri dönmeyiz nasılsa' demeyin, bir sefer dönmeniz gerektiğinde pişman olursunuz.
  • Uzun migrasyonu tek transaction'a sıkıştırmak: Milyonlarca satırlık UPDATE'i tek seferde çalıştırmak hem WAL'ı şişirir hem replica'ları geride bırakır. Batch'lere bölün.

Doğrulama

Migrasyonu canlıya açmadan önce temiz bir Docker container'ında deneyin. Bence bu adımı atlamak en pahalı kestirme:

docker run -d --name test-db -e POSTGRES_PASSWORD=test -p 5433:5432 postgres:16
sleep 5
flyway -url=jdbc:postgresql://localhost:5433/postgres migrate
psql -h localhost -p 5433 -U postgres -c "\dt"
docker rm -f test-db

Aynı script'i CI'a koyduğunuzda her PR'da otomatik koşar; üç dakika sonra 'bu migration sıfırdan açıyor mu?' sorusunun net cevabı elinizde olur.

Kapanış

PostgreSQL şema migrasyonlarını yönetmek aslında bir araç seçimi değil, bir disiplin meselesi. Aracı seçtikten sonra sıfır kesintili kalıpları öğrenmek, geri dönüş planını her zaman hazır tutmak ve değişikliği önce temiz bir veritabanında denemek kalıyor. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.