İçeriğe geç
academia.sh

Ders 02 / 19

Belge Veritabanları

Değerin içini gören deponun kazandırdıkları ve ödettikleri: iç içe bir kitap kaydının tek belgede okunmasıyla dört ilişkisel tablodan birleştirilmesinin çarpım patlaması, alan yerinde güncellemenin yazma çarpanına etkisi, ve şema esnekliğinin okuma tarafına yıktığı yük — aynı derlemde üç şema sürümü bulunduğunda dizinli sorgunun sessizce kaçırdığı kayıt sayısı ile savunmalı okumanın taradığı belge sayısı.

İçindekiler

Önceki ders tek bir soruyla kapandı: depo değerin içini görebilse ne değişirdi. Belge veritabanı (document database) tam olarak bu değişikliktir. Kayıt yine tek bir anahtarla adlanır, ama depo artık değerin alanlarını tanır: alan üzerinde koşul yazılabilir, alan üzerinde dizin kurulabilir ve alanın kendisi yerinde güncellenebilir.

Bu ders iki şeyi ölçer. Birincisi kazanç tarafıdır: iç içe bir kitap kaydının tek okumada gelmesi ile aynı kaydın dört ilişkisel tablodan birleştirilmesi arasındaki fark. İkincisi bedel tarafıdır: alanların şema tarafından zorunlu kılınmaması, denetimi yazma anından okuma anına taşır ve bu taşımanın bir sayısı vardır.

Belge ve Derlem

Belge modelinde kayıt bir belgedir (document): adlandırılmış alanlardan oluşan, alanları kendi başına yine belge ya da dizi olabilen bir ağaç. Belgeler bir derlem (collection) içinde toplanır. Derlem bir tablo değildir, çünkü içindeki belgelerin aynı alanları taşıması zorunlu değildir; belge de bir satır değildir, çünkü alanı skaler olmak zorunda değildir.

Bu iki gevşetmenin doğrudan bir sonucu vardır: ilişkisel modelde ayrı tablolara dağılmak zorunda olan çoklu değerler — bir kitabın yazarları, konuları, nüshaları — belgenin içinde kalır. Birinci normal formun yasakladığı şey, belge modelinin tanımıdır.

NS1: katalog 50.000 kitap taşır; kitap i’nin 1 ile 3 arası yazarı, 2 ile 4 arası konusu ve 1 ile 5 arası nüshası vardır. NS2: ilişkisel taraf node:sqlite ile kurulur ve birleştirme sütunlarının hepsinde dizin vardır; baytlar iki tarafta da aynı JSON gösteriminden sayılır.

// belge/ic-ice.mjs — ayni kitap kaydinin dort iliskisel tabloda ve tek belgede okunmasi.
// Iliskisel taraf node:sqlite ile kurulur; sayilar kosumdan bagimsizdir.
import { DatabaseSync } from "node:sqlite";

const KITAP = 50_000, YAZARLAR = ["Atay", "Saramago", "Tanpinar", "Woolf", "Borges"];
const KONULAR = ["roman", "deneme", "siir", "tarih", "felsefe", "atlas"];
function* kitaplar() {                     // kitap i: 1+(i%3) yazar, 2+(i%3) konu, 1+(i%5) nusha
  for (let i = 1; i <= KITAP; i += 1) yield {
    kitap_id: i, baslik: `Kitap ${i}`,
    basim: { yil: 1960 + (i % 65), dil: i % 4 === 0 ? "en" : "tr" },
    yazarlar: Array.from({ length: 1 + (i % 3) }, (_, j) => YAZARLAR[(i + j) % 5]),
    konular: Array.from({ length: 2 + (i % 3) }, (_, j) => KONULAR[(i * 2 + j) % 6]),
    nushalar: Array.from({ length: 1 + (i % 5) }, (_, j) => ({
      nusha_id: i * 10 + j, sube_id: 1 + ((i + j) % 9), durum: j % 3 === 0 ? "rafta" : "oduncte" })),
  };
}

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE kitap (kitap_id INTEGER PRIMARY KEY, baslik TEXT NOT NULL,
           basim_yili INTEGER NOT NULL, dil TEXT NOT NULL);
         CREATE TABLE kitap_yazar (kitap_id INTEGER NOT NULL, sira INTEGER NOT NULL, ad TEXT NOT NULL);
         CREATE TABLE kitap_konu (kitap_id INTEGER NOT NULL, konu TEXT NOT NULL);
         CREATE TABLE nusha (nusha_id INTEGER PRIMARY KEY, kitap_id INTEGER NOT NULL,
           sube_id INTEGER NOT NULL, durum TEXT NOT NULL);`);
const eK = db.prepare("INSERT INTO kitap VALUES (?,?,?,?)");
const eY = db.prepare("INSERT INTO kitap_yazar VALUES (?,?,?)");
const eT = db.prepare("INSERT INTO kitap_konu VALUES (?,?)");
const eN = db.prepare("INSERT INTO nusha VALUES (?,?,?,?)");
const derlem = new Map();                  // belge derlemi: kitap_id -> belge
db.exec("BEGIN");
for (const k of kitaplar()) {
  eK.run(k.kitap_id, k.baslik, k.basim.yil, k.basim.dil);
  k.yazarlar.forEach((a, j) => eY.run(k.kitap_id, j, a));
  for (const t of k.konular) eT.run(k.kitap_id, t);
  for (const n of k.nushalar) eN.run(n.nusha_id, k.kitap_id, n.sube_id, n.durum);
  derlem.set(k.kitap_id, JSON.stringify(k));
}
db.exec("COMMIT");
db.exec(`CREATE INDEX ky ON kitap_yazar(kitap_id); CREATE INDEX kt ON kitap_konu(kitap_id);
         CREATE INDEX nk ON nusha(kitap_id);`);

const bayt = (x) => Buffer.byteLength(JSON.stringify(x));
const H = 4244;
const b = JSON.parse(derlem.get(H));
console.log(`hedef kitap ${H}: ${b.yazarlar.length} yazar, ${b.konular.length} konu, ` +
  `${b.nushalar.length} nusha; carpim ${b.yazarlar.length * b.konular.length * b.nushalar.length}`);

const satir = [];
{ let n = 0, by = 0;
  for (const s of ["SELECT * FROM kitap WHERE kitap_id = ?", "SELECT ad FROM kitap_yazar WHERE kitap_id = ?",
                   "SELECT konu FROM kitap_konu WHERE kitap_id = ?", "SELECT * FROM nusha WHERE kitap_id = ?"]) {
    const r = db.prepare(s).all(H); n += r.length; by += bayt(r); }
  satir.push(["iliskisel, dort sorgu", 4, n, by]); }
{ const r = db.prepare(`SELECT k.baslik, k.basim_yili, k.dil, y.ad, t.konu, n.sube_id, n.durum
    FROM kitap k JOIN kitap_yazar y ON y.kitap_id = k.kitap_id
                 JOIN kitap_konu  t ON t.kitap_id = k.kitap_id
                 JOIN nusha       n ON n.kitap_id = k.kitap_id WHERE k.kitap_id = ?`).all(H);
  satir.push(["iliskisel, tek birlestirme", 1, r.length, bayt(r)]); }
{ satir.push(["belge, tek okuma", 1, 1, Buffer.byteLength(derlem.get(H))]); }

console.log(`\n${"yol".padEnd(28)}${"gidis".padStart(7)}${"satir/belge".padStart(13)}${"bayt".padStart(7)}`);
for (const [a, g, k, y] of satir)
  console.log(a.padEnd(28) + String(g).padStart(7) + String(k).padStart(13) + String(y).padStart(7));

// Ayni kaydin tek alanini degistirmek: nusha durumu "rafta" -> "oduncte"
const oncesi = Buffer.byteLength(derlem.get(H));
const alan = Buffer.byteLength(JSON.stringify({ durum: "oduncte" }));
console.log(`\ntek alan guncellemesi (nusha durumu)`);
console.log(`${"model".padEnd(28)}${"yazilan bayt".padStart(14)}${"carpan".padStart(8)}`);
console.log("iliskisel, tek satir".padEnd(28) + String(alan).padStart(14) + "1.0".padStart(8));
console.log("belge, alan yerinde".padEnd(28) + String(alan).padStart(14) + "1.0".padStart(8));
console.log("anahtar-deger, butun deger".padEnd(28) + String(oncesi).padStart(14) +
  (oncesi / alan).toFixed(1).padStart(8));
hedef kitap 4244: 3 yazar, 4 konu, 5 nusha; carpim 60

yol                           gidis  satir/belge   bayt
iliskisel, dort sorgu             4           13    513
iliskisel, tek birlestirme        1           60   6718
belge, tek okuma                  1            1    412

tek alan guncellemesi (nusha durumu)
model                         yazilan bayt  carpan
iliskisel, tek satir                    19     1.0
belge, alan yerinde                     19     1.0
anahtar-deger, butun deger             412    21.7

Sayılar ölçüm sınıfındadır ve belirlenimlidir.

Üst tablo ilişkisel tarafın gerçek ikilemini gösteriyor. Dört ayrı sorgu 13 satır ve 513 bayt getiriyor, ama dört gidiş ediyor. Gidişi bire indirmek için üç birleştirme yazıldığında sonuç 60 satır oluyor: üç yazar, dört konu ve beş nüshanın çarpımı. Kitabın başlığı, basım yılı ve dili altmış kez tekrarlanıyor ve taşınan bayt 513’ten 6.718’e, on üç katına çıkıyor. Bu, birleştirmenin bir kusuru değil tanımıdır — ilişkisel sonuç kümesi düz bir tablodur ve birbirinden bağımsız üç çoklu değer aynı düzlemde yan yana getirildiğinde çarpım kaçınılmazdır.

Belge tarafında aynı kayıt tek gidiş, tek belge, 412 bayttır. Tekrar yoktur, çünkü sonuç düzleştirilmemiştir: yazarlar bir dizi, konular bir dizi, nüshalar belgelerden oluşan bir dizi olarak kalır. Uygulamanın istediği ağacın kendisi diskte de ağaç olarak durur.

Alt tablo, önceki dersteki yazma çarpanı ölçümünün belge tarafındaki karşılığıdır. Bir nüshanın durumunu değiştirmek 19 bayt bilgidir. İlişkisel motor tek satırı günceller. Belge veritabanı alanı belgenin içinde yerinde günceller — depo alanı tanıdığı için bütün belgeyi geri yazmak zorunda değildir. Anahtar–değer deposunda aynı değişiklik 412 baytın tamamının yeniden yazılmasıdır: çarpan 21,7. Belge modeli, önceki dersin ödünleşiminin bir ucunu — geniş değer, tek okuma — koruyup öteki ucunu, yazma çarpanını ödemeden alır.

Şemanın Okuma Tarafına Geçmesi

Bu kazancın bir bedeli vardır ve bedel şemanın kendisidir. İlişkisel şemada NOT NULL bir yazma zamanı kuralıdır: alan yoksa kayıt reddedilir ve okuyan taraf alanın varlığını denetlemek zorunda değildir. Belge derleminde böyle bir kural yoktur; alanın var olup olmadığı her okumada sorulur.

Bu bir tasarım hatası değil, uygulamanın zamanla değişmesinin doğal sonucudur. Aynı derlemde farklı zamanlarda yazılmış üç şema sürümü yan yana durur: ilk sürümde basım yılı üst düzeyde yil alanıdır, ikinci sürümde basim alt belgesine taşınmıştır, üçüncü sürümde bir de dijital alanı eklenmiştir. Soru değişmez — 2000 sonrası basılmış kitaplar hangileridir.

// belge/sema.mjs — sema esnekliginin okuma tarafina yikilan yuku. Ayni derlemde uc sema
// surumu bulunur; ayni soru dort yolla sorulur. Iliskisel taraf node:sqlite ile kurulur.
import { DatabaseSync } from "node:sqlite";

const KITAP = 50_000, ESIK = 2000;
function surum(i) { return i <= 20_000 ? 1 : i <= 40_000 ? 2 : 3; }
function belge(i) {                        // v1: yil ust duzeyde; v2: basim.yil; v3: + dijital
  const yil = 1960 + (i % 65), ortak = { kitap_id: i, baslik: `Kitap ${i}` };
  if (surum(i) === 1) return { ...ortak, yil, dil: i % 4 === 0 ? "en" : "tr" };
  const b = { ...ortak, basim: { yil, dil: i % 4 === 0 ? "en" : "tr" } };
  return surum(i) === 3 ? { ...b, dijital: i % 2 === 0 } : b;
}

class BelgeDeposu {
  #belgeler = []; #dizin = new Map();
  gidis = 0; taranan = 0;
  ekle(b) { this.#belgeler.push(b); }
  al(b, yol) { return yol.split(".").reduce((o, k) => (o == null ? undefined : o[k]), b); }
  dizinKur(yol) { const g = [];
    for (const b of this.#belgeler) { const v = this.al(b, yol);
      if (v === undefined) continue;       // alani olmayan belge dizine hic girmez
      for (const x of Array.isArray(v) ? v : [v]) g.push([x, b.kitap_id]); }
    this.#dizin.set(yol, g.sort((p, q) => p[0] - q[0])); return g.length; }
  dizinliBuyuk(yol, esik) {                // sirali dizinde esigin ustundeki son ek okunur
    this.gidis += 1; const g = this.#dizin.get(yol);
    let a = 0, u = g.length;
    while (a < u) { const o = (a + u) >> 1; if (g[o][0] <= esik) a = o + 1; else u = o; }
    this.taranan += g.length - a; return g.slice(a).map(([, id]) => id); }
  taramaliBuyuk(sart) {                    // dizin yok: her belge tek tek bakilir
    this.gidis += 1; const c = [];
    for (const b of this.#belgeler) { this.taranan += 1; if (sart(b)) c.push(b.kitap_id); }
    return c; }
  sifirla() { this.gidis = 0; this.taranan = 0; }
}

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE kitap (kitap_id INTEGER PRIMARY KEY, baslik TEXT NOT NULL,
           basim_yili INTEGER NOT NULL, dil TEXT NOT NULL);`);
const ekle = db.prepare("INSERT INTO kitap VALUES (?,?,?,?)");
const depo = new BelgeDeposu();
db.exec("BEGIN");
for (let i = 1; i <= KITAP; i += 1) {
  const b = belge(i);
  ekle.run(i, b.baslik, b.yil ?? b.basim.yil, b.dil ?? b.basim.dil);
  depo.ekle(b);
}
db.exec("COMMIT");
db.exec("CREATE INDEX kitap_yil ON kitap(basim_yili);");
const d2 = depo.dizinKur("basim.yil"), d1 = depo.dizinKur("yil");
console.log(`${KITAP} kitap, esik ${ESIK}; surum dagilimi 1:20000 2:20000 3:10000`);
console.log(`dizin girdi sayisi — basim.yil: ${d2}, yil: ${d1}`);

const dogru = db.prepare("SELECT kitap_id FROM kitap WHERE basim_yili > ?").all(ESIK).map((r) => r.kitap_id);
const yol = [];
yol.push(["iliskisel, basim_yili dizini", 1, dogru.length, dogru.length]);
depo.sifirla(); let r = depo.dizinliBuyuk("basim.yil", ESIK);
yol.push(["belge, basim.yil dizini", depo.gidis, depo.taranan, r.length]);
depo.sifirla(); r = [...depo.dizinliBuyuk("basim.yil", ESIK), ...depo.dizinliBuyuk("yil", ESIK)];
yol.push(["belge, iki dizin birlesimi", depo.gidis, depo.taranan, r.length]);
depo.sifirla(); r = depo.taramaliBuyuk((b) => (b.basim?.yil ?? b.yil) > ESIK);
yol.push(["belge, savunmali tarama", depo.gidis, depo.taranan, r.length]);

console.log(`\n${"yol".padEnd(30)}${"gidis".padStart(7)}${"taranan giris".padStart(15)}` +
  `${"sonuc".padStart(7)}${"eksik".padStart(7)}`);
for (const [a, g, t, n] of yol)
  console.log(a.padEnd(30) + String(g).padStart(7) + String(t).padStart(15) +
    String(n).padStart(7) + String(dogru.length - n).padStart(7));

let redU = 0, kabulB = 0;                  // yil alani olmayan 500 kayit
for (let i = KITAP + 1; i <= KITAP + 500; i += 1) {
  try { ekle.run(i, `Kitap ${i}`, null, "tr"); } catch { redU += 1; }
  depo.ekle({ kitap_id: i, baslik: `Kitap ${i}` }); kabulB += 1;
}
console.log(`\nyil alani olmayan 500 kayit — iliskisel reddetti: ${redU}, belge derlemi kabul etti: ${kabulB}`);
depo.sifirla(); const sonra = depo.taramaliBuyuk((b) => (b.basim?.yil ?? b.yil) > ESIK);
console.log(`savunmali tarama artik ${depo.taranan} belge geziyor, sonuc ${sonra.length}`);
50000 kitap, esik 2000; surum dagilimi 1:20000 2:20000 3:10000
dizin girdi sayisi — basim.yil: 30000, yil: 20000

yol                             gidis  taranan giris  sonuc  eksik
iliskisel, basim_yili dizini        1          18456  18456      0
belge, basim.yil dizini             1          11083  11083   7373
belge, iki dizin birlesimi          2          18456  18456      0
belge, savunmali tarama             1          50000  18456      0

yil alani olmayan 500 kayit — iliskisel reddetti: 500, belge derlemi kabul etti: 500
savunmali tarama artik 50500 belge geziyor, sonuc 18456

İkinci satır bu dersin en pahalı sayısıdır. Sorgu hata vermiyor, boş dönmüyor, yavaş çalışmıyor: 1 gidişte 11.083 sonuç dönüyor ve 7.373 kitap sessizce kayboluyor. Neden dizin tanımının kendisindedir — basim.yil alanı olmayan belge dizine hiç girmez, dolayısıyla dizinden yanıtlanan bir sorgu için o belgeler yoktur. İlişkisel şemada bu durum oluşamaz, çünkü alanın yokluğu yazma anında reddedilmiştir; son satır bunu doğruluyor: aynı 500 eksik kayıt ilişkisel tarafta reddedildi, belge derleminde kabul edildi.

Doğru sonuca iki yol var ve ikisinin de bir bedeli var. İki dizin birleşimi doğru 18.456 kaydı veriyor ama gidiş sayısı ikiye çıkıyor ve her şema sürümü için ayrı bir dizin tutulmasını, sorguyu yazan tarafın da bütün sürümleri bilmesini gerektiriyor. Savunmalı tarama tek gidişte doğru yanıtı veriyor, ama hiçbir dizin kullanamıyor: koşul iki ayrı yol arasında seçim yaptığı için dizinlenebilir bir ifade değil. Taranan giriş 18.456’dan 50.000’e çıkıyor — sonucun 2,7 katı. Eksik alanlı 500 kayıt eklendikten sonra tarama 50.500 belgeye uzuyor; sonuç değişmiyor.

Ödünleşim tek cümlede toplanır: belge modeli şema denetimini kaldırmaz, yazma anından okuma anına taşır. Yazma tarafında kazanılan esneklik, okuma tarafında ya bir doğruluk açığı ya bir dizin çoğalması ya da bir tarama olarak geri gelir. Bu yükün tamamen ortadan kalkması için şemanın derleme geri konması gerekir; bunun nasıl yapıldığı ve neye mal olduğu bu kursun ilerleyen konusudur.

Özet

  • Belge modelinde kayıt, alanları yine belge ya da dizi olabilen bir ağaçtır; derlem, belgelerin aynı alanları taşımasını zorunlu kılmaz.
  • İç içe kitap kaydı belge tarafında 1 gidiş ve 412 bayt; ilişkisel tarafta ya 4 gidiş ve 513 bayt, ya da tek birleştirmeyle 60 satır ve 6.718 bayttır — üç çoklu değerin çarpımı.
  • Alanın yerinde güncellenmesi 19 bayt yazar; aynı değişiklik anahtar–değer modelinde bütün değerin yeniden yazılmasıdır, çarpan 21,7.
  • Şema esnekliği denetimi okuma anına taşır: üç sürümlü bir derlemde basim.yil dizininden yanıtlanan sorgu 7.373 kaydı sessizce kaçırıyor, ilişkisel şema aynı 500 eksik kaydı yazma anında reddediyor.
  • Doğru yanıtın iki yolu da ödetiyor: iki dizin birleşimi gidişi 2’ye çıkarıyor, savunmalı tarama dizini kullanamadığı için taranan girişi 18.456’dan 50.000’e çıkarıyor.

Sonraki Adım

Buraya kadar iki aile de kaydı bir bütün olarak ele aldı: anahtar–değer deposu değeri opak bir bütün saydı, belge veritabanı ağacı tanıdı ama yine bütün belgeyi tek yerde tuttu. İkisinde de bir kaydın bütün alanları diskte yan yana durur, dolayısıyla tek bir alan için yapılan okuma da o kaydın tamamının bulunduğu yeri okur. Katalog kaydının kırk alanı varsa ve sorgu yalnız ikisini istiyorsa, geri kalan otuz sekizi de diskten geçer. Sonraki ders yerleşimi alanlara göre ayıran aileyi ele alıyor ve üç şey ölçüyor: aynı sorgunun sütun ailesine göre yerleşimde okuduğu bayt, tek anahtarın altına binlerce sütun toplanmasının anlamı, ve yazma yolunun yerinde değiştirme yerine eklemeli olmasının silme işlemine ödettiği bedel.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat