Ders 06 / 21
Şema Göçleri
Şema değişikliğinin sürümlenmiş adımlara bölünmesi: ileri ve geri göç betikleri, sürüm tablosu, başarısız adımın atomik geri alınması ve bir sütunun genişlet–yaz–daralt düzeniyle hizmet kesilmeden değiştirilmesi.
İçindekiler
Bağlantı havuzu, bütün bağlantıların aynı şemayı gördüğü varsayımıyla çalışır. Varsayım şema değişene kadar doğrudur. Sütun eklendiğinde, kaldırıldığında ya da adı değiştiğinde uygulamanın çalışan kopyalarıyla veritabanının durumu ayrışır: hangi kopyanın hangi şemayı gördüğü, kopyaların ne zaman yenilendiğine bağlı kalır.
SQL Temelleri kursunda şema değiştirme, tek başına çalıştırılan bir komut olarak tanıtıldı. Uygulama katmanında aynı komut bir göç (migration) adımına dönüşür: sürümlenmiş, sırayla uygulanan, geri alınabilir ve hangi ortamda hangisinin uygulandığı kayıtlı bir birim. Bu ders göç yürütücüsünü kurar, iki yönde çalıştırır ve bir sütunu hizmet kesilmeden değiştirir.
Adımlar ve Sürüm Tablosu
Göç adımları dosyadır. Her adımın ileri ve geri yönü ayrı dosyada durur; ad, sıra numarası ile başlar.
mkdir -p goc cat > goc/001-odunc-tablosu.up.sql <<'SQL' CREATE TABLE uye (uye_id INTEGER PRIMARY KEY, ad TEXT NOT NULL, soyad TEXT NOT NULL, eposta TEXT, kayit_tarihi TEXT NOT NULL); CREATE TABLE odunc (odunc_id INTEGER PRIMARY KEY, kitap_id INTEGER NOT NULL, uye_id INTEGER NOT NULL REFERENCES uye(uye_id), alis_tarihi TEXT NOT NULL, iade_tarihi TEXT); SQL cat > goc/001-odunc-tablosu.down.sql <<'SQL' DROP TABLE odunc; DROP TABLE uye; SQL cat > goc/002-gecikme-ucreti.up.sql <<'SQL' ALTER TABLE odunc ADD COLUMN gecikme_ucreti REAL NOT NULL DEFAULT 0; SQL cat > goc/002-gecikme-ucreti.down.sql <<'SQL' ALTER TABLE odunc DROP COLUMN gecikme_ucreti; SQL cat > goc/003-acik-odunc-dizini.up.sql <<'SQL' CREATE INDEX odunc_acik_dizini ON odunc (uye_id) WHERE iade_tarihi IS NULL; SQL cat > goc/003-acik-odunc-dizini.down.sql <<'SQL' DROP INDEX odunc_acik_dizini; SQL
Hangi adımın uygulandığı bilgisi veritabanının kendisinde durur. Dosya sisteminde ya da yapılandırma dosyasında tutulsaydı, veritabanı kopyalandığında bilgi kopyayla birlikte gitmezdi.
// goc.mjs — surum tablosuna bakarak goc adimlarini ileri ve geri calistirir import { DatabaseSync } from "node:sqlite"; import { readdirSync, readFileSync } from "node:fs"; const db = new DatabaseSync(process.env.VT ?? "kutuphane.db"); db.exec(`CREATE TABLE IF NOT EXISTS sema_surumu ( surum INTEGER PRIMARY KEY, ad TEXT NOT NULL, uygulanma TEXT NOT NULL)`); const adimlar = [...new Set(readdirSync("goc").map((d) => d.replace(/\.(up|down)\.sql$/, "")))] .sort() .map((ad) => ({ surum: Number(ad.slice(0, 3)), ad })); const uygulanan = () => db.prepare("SELECT surum FROM sema_surumu ORDER BY surum") .all().map((r) => r.surum); function calistir(adim, yon) { const sql = readFileSync(`goc/${adim.ad}.${yon}.sql`, "utf8"); db.exec("BEGIN"); try { db.exec(sql); if (yon === "up") { db.prepare("INSERT INTO sema_surumu VALUES (?,?,datetime('now'))").run(adim.surum, adim.ad); } else { db.prepare("DELETE FROM sema_surumu WHERE surum = ?").run(adim.surum); } db.exec("COMMIT"); console.log(`${yon === "up" ? "ileri" : "geri "} ${adim.ad}`); } catch (h) { db.exec("ROLLBACK"); console.log(`HATA ${adim.ad}: ${h.message}`); process.exit(1); } } const [komut, hedefArg] = process.argv.slice(2); const hedef = hedefArg === undefined ? null : Number(hedefArg); if (komut === "durum") { const u = new Set(uygulanan()); for (const a of adimlar) console.log(`${u.has(a.surum) ? "[x]" : "[ ]"} ${a.ad}`); console.log("gecerli surum:", Math.max(0, ...u)); } else if (komut === "yukselt") { const u = new Set(uygulanan()); for (const a of adimlar) { if (!u.has(a.surum) && (hedef === null || a.surum <= hedef)) calistir(a, "up"); } } else if (komut === "alcalt") { const u = uygulanan().sort((x, y) => y - x); for (const s of u) { if (s > hedef) calistir(adimlar.find((a) => a.surum === s), "down"); } } else { console.log("kullanim: node goc.mjs durum | yukselt [surum] | alcalt <surum>"); }
Yürütücünün üç kararı vardır. Adım ile sürüm kaydı aynı işlem içinde yazılır; adımlar dosya adına göre sıralanır, dolayısıyla numaralandırma sıralamayı belirler; geri yönde sıra tersine döner.
İleri Yürütme
node goc.mjs durum
node goc.mjs yukselt 3
node goc.mjs durum
[ ] 001-odunc-tablosu [ ] 002-gecikme-ucreti [ ] 003-acik-odunc-dizini gecerli surum: 0 ileri 001-odunc-tablosu ileri 002-gecikme-ucreti ileri 003-acik-odunc-dizini [x] 001-odunc-tablosu [x] 002-gecikme-ucreti [x] 003-acik-odunc-dizini gecerli surum: 3
Üç adım sırayla uygulandı ve sürüm tablosuna yazıldı. İkinci durum çağrısı bilgiyi
dosyalardan değil veritabanından okuyor; aynı göç dizini başka bir veritabanına karşı
çalıştırılsaydı orada bütün kutular boş görünürdü.
Başarısız Adımın Atomikliği
Bir adımın ortasında hata çıkarsa, o adımın yarısı uygulanmış olarak kalmamalıdır. Aşağıdaki adım ikinci satırında kasıtlı olarak hata üretir.
cat > goc/004-bozuk-ornek.up.sql <<'SQL' ALTER TABLE odunc ADD COLUMN sube_id INTEGER; ALTER TABLE odunc ADD COLUMN sube_id INTEGER; SQL cat > goc/004-bozuk-ornek.down.sql <<'SQL' ALTER TABLE odunc DROP COLUMN sube_id; SQL node goc.mjs yukselt 4 echo "cikis kodu: $?" node goc.mjs durum sqlite3 kutuphane.db "PRAGMA table_info(odunc);" | grep -c sube_id
HATA 004-bozuk-ornek: duplicate column name: sube_id cikis kodu: 1 [x] 001-odunc-tablosu [x] 002-gecikme-ucreti [x] 003-acik-odunc-dizini [ ] 004-bozuk-ornek gecerli surum: 3 0
Üç sonuç birden görünüyor: yürütücü sıfırdan farklı bir çıkış kodu verdi, sürüm tablosunda
adım işaretlenmedi ve son satırdaki sayı sube_id sütununun tabloda bulunmadığını
gösteriyor. İlk ALTER TABLE komutu başarılı olmuştu; işlem geri alındığında o da geri
alındı.
Bu davranış motora bağlıdır. Şema değiştirme komutlarının işlem içinde geri alınabilmesi her ilişkisel veritabanında yoktur; geri alınamayan motorlarda her adım tek bir komut içerecek şekilde küçültülür, çünkü adımın yarısında kalmak elle onarım gerektirir.
Geri Yürütme
Geri yön, uygulanmış adımları tersten çalıştırır.
node goc.mjs alcalt 1
node goc.mjs durum
geri 003-acik-odunc-dizini geri 002-gecikme-ucreti [x] 001-odunc-tablosu [ ] 002-gecikme-ucreti [ ] 003-acik-odunc-dizini [ ] 004-bozuk-ornek gecerli surum: 1
Önce dizin düştü, sonra sütun kaldırıldı. Sıra ters olsaydı sütunu kullanan bir dizin ortada kalırdı. Geri yönün yazılması ihmal edilirse göç tek yönlü olur ve hatalı bir sürümden dönüş yolu kalmaz.
Sıfır Kesintili Sütun Değişimi
Asıl zorluk, uygulamanın çalışmaya devam ettiği bir değişikliktir. uye bağıntısındaki
eposta sütununun adı e_posta olacak. Tek adımda yeniden adlandırma kırıcı
değişikliktir: adı değiştiren komut çalıştığı anda, henüz yenilenmemiş uygulama kopyaları
hata almaya başlar.
Çözüm değişikliği üç adıma bölmektir. İlişkisel Veritabanı Yönetimi kursunda bu düzen genişlet–daralt göçü adıyla geçti; arada bir yazma evresi bulunur.
- Genişlet: yeni sütun eklenir ve var olan değerler kopyalanır. Eski sütun durur.
- Yaz: uygulamanın yeni sürümü iki sütuna birden yazar, yeni sütundan okur. Bu evredeki yazma davranışına çift yazma denir. Eski sürüm de çalışmaya devam eder.
- Daralt: bütün kopyalar yenilendikten sonra eski sütun kaldırılır.
Adımlar üç göç dosyasıdır.
cat > goc/005-eposta-genislet.up.sql <<'SQL' ALTER TABLE uye ADD COLUMN e_posta TEXT; UPDATE uye SET e_posta = eposta WHERE e_posta IS NULL; SQL cat > goc/005-eposta-genislet.down.sql <<'SQL' ALTER TABLE uye DROP COLUMN e_posta; SQL cat > goc/006-eposta-dolgu.up.sql <<'SQL' UPDATE uye SET e_posta = eposta WHERE e_posta IS NULL AND eposta IS NOT NULL; SQL cat > goc/006-eposta-dolgu.down.sql <<'SQL' SELECT 1; SQL cat > goc/007-eposta-daralt.up.sql <<'SQL' ALTER TABLE uye DROP COLUMN eposta; SQL cat > goc/007-eposta-daralt.down.sql <<'SQL' ALTER TABLE uye ADD COLUMN eposta TEXT; UPDATE uye SET eposta = e_posta; SQL
İki kod sürümü, uygulamanın eski ve yeni kopyalarını temsil eder.
// eski-kod.mjs — calisan eski surum: yalniz eposta sutununu bilir import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync("kutuphane.db"); try { db.prepare("INSERT INTO uye (ad, soyad, eposta, kayit_tarihi) VALUES (?,?,?,?)") .run("Eski", "Kayit", "[email protected]", "2025-07-20"); const n = db.prepare("SELECT count(*) AS n FROM uye WHERE eposta IS NOT NULL").get().n; console.log(`eski kod: calisti, eposta dolu satir = ${n}`); } catch (h) { console.log(`eski kod: HATA -> ${h.message}`); }
// yeni-kod.mjs — yeni surum: e_posta sutununu okur; CIFT_YAZMA=1 iken eskiye de yazar import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync("kutuphane.db"); const cift = process.env.CIFT_YAZMA === "1"; try { if (cift) { db.prepare("INSERT INTO uye (ad, soyad, e_posta, eposta, kayit_tarihi) VALUES (?,?,?,?,?)") .run("Yeni", "Kayit", "[email protected]", "[email protected]", "2025-07-20"); } else { db.prepare("INSERT INTO uye (ad, soyad, e_posta, kayit_tarihi) VALUES (?,?,?,?)") .run("Yeni", "Kayit", "[email protected]", "2025-07-20"); } const n = db.prepare("SELECT count(*) AS n FROM uye WHERE e_posta IS NOT NULL").get().n; console.log(`yeni kod: calisti (cift yazma=${cift ? "acik" : "kapali"}), e_posta dolu satir = ${n}`); } catch (h) { console.log(`yeni kod: HATA -> ${h.message}`); }
Aşağıdaki betik veritabanını sıfırdan kurar, her adımdan sonra iki kod sürümünü de çalıştırır. Bozuk örnek adımı, sonraki adımların önünü tıkamaması için siliniyor.
# sifir-kesinti.sh — uc adimli sutun degisiminde iki kod surumunun durumu rm -f kutuphane.db goc/004-bozuk-ornek.up.sql goc/004-bozuk-ornek.down.sql node goc.mjs yukselt 3 >/dev/null sqlite3 kutuphane.db "INSERT INTO uye (ad,soyad,eposta,kayit_tarihi) VALUES ('Ayşe','Demir','[email protected]','2023-02-14'), ('Zeynep','Arslan',NULL,'2024-01-09');" echo "== surum 3: yalniz eski sutun var ==" node eski-kod.mjs node yeni-kod.mjs echo "== surum 5 (genislet): yeni sutun eklendi ve dolduruldu ==" node goc.mjs yukselt 5 node eski-kod.mjs CIFT_YAZMA=1 node yeni-kod.mjs echo "== surum 6 (dolgu): eski kodun yazdiklari yeni sutuna tasindi ==" node goc.mjs yukselt 6 node eski-kod.mjs CIFT_YAZMA=1 node yeni-kod.mjs echo "== surum 7 (daralt): eski sutun kaldirildi ==" node goc.mjs yukselt 7 node eski-kod.mjs node yeni-kod.mjs echo "-- adresi bos kalan satirlar --" sqlite3 -header -column kutuphane.db "SELECT uye_id, ad, soyad FROM uye WHERE e_posta IS NULL;"
sh sifir-kesinti.sh
== surum 3: yalniz eski sutun var == eski kod: calisti, eposta dolu satir = 2 yeni kod: HATA -> table uye has no column named e_posta == surum 5 (genislet): yeni sutun eklendi ve dolduruldu == ileri 005-eposta-genislet eski kod: calisti, eposta dolu satir = 3 yeni kod: calisti (cift yazma=acik), e_posta dolu satir = 3 == surum 6 (dolgu): eski kodun yazdiklari yeni sutuna tasindi == ileri 006-eposta-dolgu eski kod: calisti, eposta dolu satir = 5 yeni kod: calisti (cift yazma=acik), e_posta dolu satir = 5 == surum 7 (daralt): eski sutun kaldirildi == ileri 007-eposta-daralt eski kod: HATA -> table uye has no column named eposta yeni kod: calisti (cift yazma=kapali), e_posta dolu satir = 6 -- adresi bos kalan satirlar -- uye_id ad soyad ------ ------ ------ 2 Zeynep Arslan 6 Eski Kayit
Ortadaki iki evre kesintisizliğin kanıtıdır: sürüm 5 ve 6’da hem eski hem yeni kod hatasız çalıştı. Değişikliğin kırıcı olduğu tek an sürüm 7’dir ve o an, bütün kopyalar yenilendikten sonraya bırakılabilir.
Daralt Adımının Koşulu
Çıktının son bölümü bir uyarı taşıyor. e_posta sütunu boş kalan iki satır var. Bunlardan
Zeynep başlangıçtan beri adressizdi; Eski Kayit ise sorunludur. O satırı eski kod, dolgu
adımından sonra yazdı: eski sütuna yazdı, yeni sütun boş kaldı ve daralt adımı eski
sütunu kaldırdığında değer kayboldu.
Daralt adımının koşulu buradan çıkar: eski sütuna yazan hiçbir kopya kalmamalıdır. Koşul, dolgu adımını tekrarlayarak değil, eski kopyaların tamamen çekildiğini doğrulayarak sağlanır. Uygulamada bu doğrulama iki yolla yapılır: yenileme sürecinin bittiğinin onaylanması ve daralt adımından hemen önce boş kalan satır sayısının sıfır olduğunun sınanması.
Geri Göçün Sınırı
Daralt adımından dönmek mümkündür, ama dönüş şemayı geri getirir; veriyi getirmez.
node goc.mjs alcalt 6 sqlite3 -header -column kutuphane.db "SELECT uye_id, ad, eposta, e_posta FROM uye WHERE uye_id IN (1,2,6);"
geri 007-eposta-daralt uye_id ad eposta e_posta ------ ------ --------------- --------------- 1 Ayşe [email protected] [email protected] 2 Zeynep 6 Eski
eposta sütunu geri geldi ve e_posta değerlerinden dolduruldu. Altıncı satırın adresi
yine boş: daralt adımı onu sildiğinde bilgi tamamen kayboldu. Geri göç bir geri dönüş
planıdır, bir yedek değildir. Veri kaybettiren adımların geri yönü ancak silinen veriyi
başka bir yerde saklamışsa anlamlıdır; aksi hâlde geri dönüş yalnız şemayı onarır.
Özet
- Göç adımı sürümlenmiş, iki yönlü ve sırayla uygulanan bir birimdir; hangi adımın uygulandığı bilgisi veritabanının kendi içindeki sürüm tablosunda durur.
- Adımın kendisi ile sürüm kaydı aynı işlemde yazılır; hata çıkan adımda ikisi birlikte geri alındı ve sürüm tablosu değişmedi.
- Bir sütunun kesintisiz değiştirilmesi üç evre ister: genişlet, çift yazmayla yaz, daralt. Ortadaki iki evrede eski ve yeni kod sürümleri birlikte çalıştı.
- Daralt adımı, eski sütuna yazan kopya kalmadan uygulanamaz; kurala uyulmadığında dolgudan sonra yazılan satırın değeri kayboldu.
- Geri göç şemayı geri alır, silinen veriyi geri getirmez; geri dönüş planı yedeğin yerini tutmaz.
Sonraki Adım
Göç yürütücüsü her adımı BEGIN ile açıp COMMIT ile kapattı ve hata durumunda
ROLLBACK çağırdı; adım ile sürüm kaydının birlikte yazılmasını sağlayan da buydu. Aynı
soru uygulamanın normal çalışması için de geçerlidir: bir ödünç verme isteği hangi
yazmaları kapsar, bu yazmaların hepsi birlikte mi kesinleşmelidir, işlemi açan ve kapatan
kod hangi katmanda durur? Sonraki konu bu soruyla açılır ve ilk ders işlem sınırını iş
biriminin tanımı üzerinden kurar: sınır fazla dar çizilirse yarım kalan durumlar, fazla
geniş çizilirse uzun süre tutulan kilitler üretir.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.