Sıfır Kesintili Şema Göçü: Genişlet ve Daralt, Kilitler ve PostgreSQL Tuzakları
Uygulamayı kesintisiz dağıtmak yetmez; şema değişikliği eski ve yeni sürümün aynı anda çalıştığı anda kırılır. Genişlet ve daralt kalıbı, kilit zaman aşımı, eşzamanlı indeks ve partili veri taşıma ile PostgreSQL'de kesintisiz göç nasıl yapılır, nerede tökezler.
Uygulamayı kesintisiz dağıtmayı öğrendik: birden fazla kopya, sağlık kontrolü, kademeli geçiş. Sonra ilk şema değişikliği gelir ve bütün bu emek tek bir ALTER TABLE ile boşa gider. Çünkü dağıtım sırasında eski ve yeni sürüm aynı veritabanına aynı anda bakar. Şema yalnız yeni koda uyuyorsa eski kopyalar hata basar; yalnız eski koda uyuyorsa yeni kopyalar açılmaz. Sıfır kesintili şema göçünün tek kuralı budur: veritabanı her an hem eski hem yeni sürümü taşıyabilmelidir. Bu yazı bu kuralı PostgreSQL üzerinden nasıl uygulayacağınızı ve nerede tökezleyeceğinizi anlatır.
Üç yaklaşım, biri işe yarar
Bakım penceresi. Trafiği kes, göçü çalıştır, yeni sürümü aç. Dürüst ve basit. Tek kiracılı bir iç araçta hâlâ savunulabilir. Ancak birden fazla saat dilimine hizmet veren bir üründe pencere yoktur ve her "gece üçte" dağıtım ekibi yoruyor, hataları çoğaltıyor.
Kilit tut, hızlı ol. Göçü dağıtımla aynı adımda çalıştır, kısa sürsün diye dua et. Küçük tablolarda çalışır, sonra tablo büyür ve bir gün ALTER TABLE saniyeler değil dakikalar sürer. O dakikalar boyunca tabloya gelen her sorgu kilit kuyruğunda bekler. Bağlantı havuzu dolar, arka uç zaman aşımına düşer, kullanıcı hata görür.
Genişlet ve daralt (expand/contract). Her değişikliği geri uyumlu iki parçaya böl: önce şemayı genişlet, kod iki biçimi de tanısın, sonra eski biçimi kaldır. Daha çok adım, daha çok disiplin ister. Buna karşılık büyüklükten bağımsız çalışır ve geri alma her aşamada mümkündür. Üretimde çalışan bir ürün için doğru tercih budur; kalan yazı bunun ayrıntısıdır.
Sütun yeniden adlandırma örneği
RENAME COLUMN atomiktir ama eski kod bir saniye sonra bile o sütunu göremez. Bunun yerine değişikliği en az üç dağıtıma yayarsınız.
Birinci dağıtım şemayı genişletir ve kodu çift yazmaya geçirir:
ALTER TABLE orders ADD COLUMN customer_ref text;
Uygulama artık hem customer_id hem customer_ref sütununa yazar, okurken eskisini kullanır. Eski kopyalar yeni sütunu bilmez ama bu onları rahatsız etmez, çünkü sütun boş değer kabul eder.
İkinci adım geçmiş veriyi taşımaktır. Tek bir UPDATE orders SET customer_ref = customer_id milyon satırda uzun bir kilit ve büyük bir WAL patlaması demektir. Küçük partiler hâlinde, birincil anahtar aralığına göre ilerleyin:
UPDATE orders
SET customer_ref = customer_id::text
WHERE id BETWEEN 1 AND 10000
AND customer_ref IS NULL;
Her parti kendi işlemi içinde tamamlanır; partiler arasına küçük bekleme koyun ki kopyalama gecikmesi (replication lag) şişmesin.
İkinci dağıtım okumayı yeni sütuna çevirir, yazmayı çift bırakır. Bu noktada bir hafta bekleyip her şeyin yolunda olduğunu görmek ayıp değildir.
Üçüncü dağıtım daraltır: çift yazma kalkar, eski sütun düşer. Sütunu düşürmek de ayrı bir tuzaktır; ona aşağıda geliyoruz.
Kilitlerin gerçek maliyeti
PostgreSQL'de çoğu ALTER TABLE biçimi ACCESS EXCLUSIVE kilit ister. Kilit alınması hızlıdır ama alınabilmesi için tablodaki bütün açık işlemlerin bitmesi gerekir. Uzun süren tek bir rapor sorgusu ya da açık bırakılmış boşta bir işlem, sizin göçünüzü bekletir. Asıl felaket şudur: kilidi bekleyen göç, arkasından gelen bütün sıradan SELECT sorgularını da bekletir. Tek bir yavaş sorgu bu şekilde bütün uygulamayı durdurur.
Savunma iki katlıdır. Göçü çalıştıran bağlantıda kısa bir kilit zaman aşımı koyun:
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN customer_ref text;
Kilit üç saniyede alınamazsa komut hata verir; göç aracınız tekrar dener, uygulama ise hiç fark etmez. İkinci kat, göç aracının bu yeniden denemeyi kendisinin yapmasıdır. Çoğu araç bunu kutudan çıkmadan yapmaz, sarmalamanız gerekir.
Hangi komutun hangi kilidi aldığını ve tabloyu yeniden yazıp yazmadığını ezberlemek yerine ölçün. Sürüm 11'den itibaren sabit varsayılan değerli sütun eklemek tabloyu yeniden yazmaz. NOT NULL kısıtı eklemek ise tam tarama ister; bunun çözümü önce CHECK (col IS NOT NULL) NOT VALID eklemek, sonra ayrı bir işlemde VALIDATE CONSTRAINT çalıştırmaktır. Doğrulama yalnız SHARE UPDATE EXCLUSIVE kilidi alır ve okuma yazmayı engellemez.
İndeksler
CREATE INDEX tabloya yazmayı bloke eder. Üretimde daima eşzamanlı biçimi kullanın:
CREATE INDEX CONCURRENTLY idx_orders_customer_ref ON orders (customer_ref);
Bu komutun iki huyu vardır. Bir işlem bloğu içinde çalışmaz; göç aracınız her göçü otomatik olarak BEGIN ile sarıyorsa bu göç için sarmayı kapatmanız gerekir. İkincisi, yarıda kesilirse arkasında INVALID işaretli bir indeks bırakır. Bu indeks bakım maliyeti üretir ama sorgulara hizmet vermez. Göçü yeniden çalıştırmadan önce pg_index üzerinden indisvalid = false olanları bulup düşürün. Bunu yapmayan bir ekip haftalar sonra "indeks var ama sorgu yavaş" bulmacasıyla vakit kaybeder.
Sütun düşürmek neden geri tepiyor
DROP COLUMN kilidi kısadır ve veri yeniden yazılmaz. Sorun veritabanında değil uygulamadadır. Birçok ORM tabloyu SELECT * ile ya da model tanımındaki sütun listesiyle okur. Sütun düşer düşmez hâlâ çalışan eski kopyalar "sütun yok" hatası alır. Sıra bu yüzden şöyledir: önce kodun o sütunu hiç anmadığı bir sürümü dağıtın, kopyaların tamamının döndüğünden emin olun, sütunu ancak sonra düşürün. Çoğu ORM'de bir sütunu "yoksay" diye işaretlemek mümkündür; bu ara sürümün işi tam olarak budur.
Uygulama tarafı: iki biçimi birden konuşmak
Şema göçünün zor yarısı SQL değil, uygulamanın iki şemayla da doğru çalışmasıdır. Pratikte işe yarayan kalıplar:
- Yeni sütuna yazan kod yolu yapılandırmayla açılıp kapanabilsin. Bir hata görürseniz dağıtımı geri almak yerine anahtarı kapatırsınız.
- Çift yazma dönemi boyunca bir tutarlılık denetimi çalıştırın: eski ve yeni sütun arasında fark olan satır sayısını ölçen bir sorgu, alarm eşiğiyle. Ölçmediğiniz çift yazma güvenilir değildir.
- Göç dosyaları ile uygulama kodu aynı depoda, aynı gözden geçirmeden geçsin. Göçü "veritabanı ekibi" ayrı yürütüyorsa iki taraf arasındaki uyum kimin sorumluluğunda belirsizleşir.
Çok kiracılı şemalarda ölçek sorunu
Kiracı başına ayrı şema ya da ayrı veritabanı kullanıyorsanız her göç yüzlerce kez çalışır. Burada üç ek kural devreye girer. Göç idempotent olmalıdır: bir kiracıda yarıda kalıp tekrar çalıştırıldığında ikinci kez hata vermesin. Göçün hangi kiracıda hangi sürümde olduğunu tek bir yerde takip edin; "hepsi bitti" yalnız ölçülmüşse doğrudur. Ve yeni kaydolan kiracı, göç turu sürerken de doğru sürümü almalıdır; aksi hâlde tur biter, yeni kiracı eski şemayla kalır ve ilk isteğinde patlar.
Ne zaman bu yolu seçmeyin
Genişlet ve daralt döngüsü her değişiklik için üç dağıtım ve haftalık bekleme demektir. Henüz üretimde kullanıcısı olmayan bir sistemde bu külfet anlamsızdır; tabloyu düşürüp yeniden kurun. Aynı şey tek kiracılı, kabul edilmiş bakım penceresi olan iç araçlar için de geçerlidir. Disiplinin karşılığını aldığı yer, yatıp kalkan kopyaların olduğu ve kesintinin doğrudan kullanıcıya dokunduğu üründür.
Son bir uyarı: "bu tablo küçük, sorun olmaz" cümlesi en pahalı cümlelerden biridir. Küçük tablo büyür, ama göç alışkanlığı aynı kalır. Kilit zaman aşımını, eşzamanlı indeksi ve partili veri taşımayı ilk günden alışkanlık yapın; büyüdüğünüzde değiştirmeniz gereken bir şey olmasın.