İçeriğe geç
academia.sh

Ders 12 / 25

Tablo Bölümleme

Bir tablonun aralığa, listeye ve karmaya göre parçalara ayrılması, sorgunun yalnız ilgili bölüme dokunduğunun ölçümle gösterilmesi, eski verinin silinmek yerine düşürülmesi ve bölüm anahtarının dağılımının hesaplanması.

İçindekiler

Dizin Bakımı dersi bir soruyu askıda bıraktı. Eski ödünç kayıtlarını silmek, dizin sırasıyla örtüşmediği her yerde ölü boşluk bıraktı ve o boşluğu geri almak tabloyu baştan yazmayı gerektirdi. Sorunun kaynağı silmenin kendisi değil, silinecek verinin canlı verinin arasına karışmış olmasıdır.

Bölümleme (partitioning), bu karışmayı baştan önler: mantıksal olarak tek bir tablo, fiziksel olarak birden çok parçada saklanır. Her parçaya bölüm (partition) denir ve bir satırın hangi bölüme düşeceğini bölüm anahtarı (partition key) belirler. Bu ders üç bölümleme biçimini kurar, kazancını ölçer ve kazancın nereden gelmediğini de gösterir.

Aralık Bölümlemesi

En yaygın biçim aralık bölümlemesidir (range partitioning): bölüm anahtarının değer aralıkları bölümlere karşılık gelir. Kütüphane kayıtlarında doğal anahtar alış tarihidir ve doğal aralık yıldır.

Bazı motorlar bölümlemeyi sözdizimiyle sunar; sunmayanlarda aynı yapı ayrı tablolar ve bir birleşim görünümüyle kurulur. Aşağıdaki koşum ikinci yolu izler. Her bölüm tablosu kendi aralığını bir değer denetimiyle bildirir ve bir görünüm bölümleri tek bir ad altında toplar.

rm -f bolumsuz.db bolumlu.db bolumle.sql
cat > kurulum.sql <<'SQL'
CREATE TABLE odunc (
  odunc_id    INTEGER PRIMARY KEY,
  kitap_id    INTEGER NOT NULL,
  uye_id      INTEGER NOT NULL,
  alis_tarihi TEXT NOT NULL,
  iade_tarihi TEXT
);
INSERT INTO odunc (odunc_id, kitap_id, uye_id, alis_tarihi, iade_tarihi)
WITH RECURSIVE sayac(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM sayac WHERE n < 2000000)
SELECT n, 1 + ((n * 7) % 200000), 1 + ((n * 13) % 120000),
       date('2018-01-01', '+' || ((n * 37) % 2437) || ' days'),
       CASE WHEN n % 9 = 0 THEN NULL
            ELSE date('2018-01-01', '+' || (((n * 37) % 2437) + 14) || ' days') END
FROM sayac;
SQL
sqlite3 bolumsuz.db < kurulum.sql
sqlite3 bolumsuz.db 'CREATE INDEX odunc_alis ON odunc(alis_tarihi);'

# Yil basina bir bolum tablosu, deger denetimi ve bolumleri toplayan gorunum.
{
  echo "ATTACH 'bolumsuz.db' AS kaynak;"
  for y in 2018 2019 2020 2021 2022 2023 2024; do
    s=$((y + 1))
    cat <<EOF
CREATE TABLE odunc_$y (
  odunc_id    INTEGER PRIMARY KEY,
  kitap_id    INT NOT NULL,
  uye_id      INT NOT NULL,
  alis_tarihi TEXT NOT NULL CHECK (alis_tarihi >= '$y-01-01' AND alis_tarihi < '$s-01-01'),
  iade_tarihi TEXT);
INSERT INTO odunc_$y SELECT * FROM kaynak.odunc
  WHERE alis_tarihi >= '$y-01-01' AND alis_tarihi < '$s-01-01';
CREATE INDEX odunc_${y}_alis ON odunc_$y(alis_tarihi);
EOF
  done
  echo "CREATE VIEW odunc AS"
  echo "  SELECT * FROM odunc_2018 UNION ALL SELECT * FROM odunc_2019 UNION ALL"
  echo "  SELECT * FROM odunc_2020 UNION ALL SELECT * FROM odunc_2021 UNION ALL"
  echo "  SELECT * FROM odunc_2022 UNION ALL SELECT * FROM odunc_2023 UNION ALL"
  echo "  SELECT * FROM odunc_2024;"
} > bolumle.sql
sqlite3 bolumlu.db < bolumle.sql
sqlite3 bolumlu.db <<'SQL'
.mode column
.headers on
SELECT count(*) AS gorunum_satir FROM odunc;
SELECT name AS bolum, count(*) AS sayfa FROM dbstat
WHERE name LIKE 'odunc\_20__' ESCAPE '\' GROUP BY name ORDER BY name;
SQL
gorunum_satir
-------------
2000000      
bolum       sayfa
----------  -----
odunc_2018  2966 
odunc_2019  2982 
odunc_2020  2970 
odunc_2021  2959 
odunc_2022  2964 
odunc_2023  2982 
odunc_2024  1994 

Görünüm iki milyon satırın hepsini veriyor: uygulama tarafından bakıldığında tablo hâlâ tektir. Altında yedi ayrı tablo, yedi ayrı dizin ve her biri yaklaşık üç bin sayfalık yedi ayrı ağaç vardır.

Yalnız İlgili Bölüme Dokunmak

Bölümlemenin ilk iddiası şudur: bölüm anahtarını süzen bir sorgu diğer bölümleri hiç okumaz. Bu iddia plan çıktısında görünmez — plan yedi bölümü de listeler.

sqlite3 bolumlu.db "EXPLAIN QUERY PLAN
SELECT count(*) FROM odunc WHERE alis_tarihi BETWEEN '2021-03-01' AND '2021-03-31';"

for hedef in "bolumlu.db|odunc|bolumlu gorunum" \
             "bolumlu.db|odunc_2021|dogrudan 2021 bolumu" \
             "bolumsuz.db|odunc|bolumsuz tek tablo"; do
  db=${hedef%%|*}; kalan=${hedef#*|}; tablo=${kalan%%|*}; ad=${kalan#*|}
  printf '%-22s ' "$ad"
  sqlite3 "$db" <<SQL 2>&1 | grep -E 'Page cache misses|Run Time' | tr '\n' ' '
.stats on
.timer on
SELECT count(*) FROM $tablo WHERE alis_tarihi BETWEEN '2021-03-01' AND '2021-03-31';
SQL
  echo
done
QUERY PLAN
|--CO-ROUTINE odunc
|  `--COMPOUND QUERY
|     |--LEFT-MOST SUBQUERY
|     |  `--SEARCH odunc_2018 USING INDEX odunc_2018_alis (alis_tarihi>? AND alis_tarihi<?)
|     |--UNION ALL
|     |  `--SEARCH odunc_2019 USING INDEX odunc_2019_alis (alis_tarihi>? AND alis_tarihi<?)
|     |--UNION ALL
|     |  `--SEARCH odunc_2020 USING INDEX odunc_2020_alis (alis_tarihi>? AND alis_tarihi<?)
|     |--UNION ALL
|     |  `--SEARCH odunc_2021 USING INDEX odunc_2021_alis (alis_tarihi>? AND alis_tarihi<?)
|     |--UNION ALL
|     |  `--SEARCH odunc_2022 USING INDEX odunc_2022_alis (alis_tarihi>? AND alis_tarihi<?)
|     |--UNION ALL
|     |  `--SEARCH odunc_2023 USING INDEX odunc_2023_alis (alis_tarihi>? AND alis_tarihi<?)
|     `--UNION ALL
|        `--SEARCH odunc_2024 USING INDEX odunc_2024_alis (alis_tarihi>? AND alis_tarihi<?)
`--SCAN odunc
bolumlu gorunum        Page cache misses:                   141 Run Time: real 0.002 user 0.002195 sys 0.000186 
dogrudan 2021 bolumu   Page cache misses:                   123 Run Time: real 0.001 user 0.000566 sys 0.000115 
bolumsuz tek tablo     Page cache misses:                   123 Run Time: real 0.000 user 0.000570 sys 0.000158

Sayfa ıskası sayıları iddiayı doğruluyor. Yalnız 2021 bölümüne doğrudan sorulan sorgu 123 sayfa okudu; görünüm üzerinden sorulan sorgu 141 sayfa. Aradaki 18 sayfa, altı bölümün dizinine yapılan altı boş yoklamadır — bölüm başına üç sayfa. Yedi bölümün altısı planda görünüyor ama verisi hiç okunmuyor.

Ayrımın adı bölüm ayıklamasıdır (partition pruning). Yerleşik bölümleme sunan motorlarda ayıklama plan kurulurken yapılır ve ilgisiz bölümler plana hiç girmez; buradaki modelde çalışma zamanında oluyor, çünkü her boş yoklama kendi dizininde hemen sonuçsuz kalıyor. İki yolun sonucu yakındır, maliyeti değil: bölüm sayısı yüzlere çıktığında yüzlerce boş yoklamanın toplamı ihmal edilemez olur.

Üçüncü satır ise beklentiyi düzeltir. Bölümsüz tek tablo aynı sorguyu aynı 123 sayfayla yanıtladı. Dizin varken bölümleme okuma başarımı kazandırmaz; dizin zaten aynı işi yapıyordu. Bölümlemenin kazancı başka yerdedir.

Eski Veriyi Silmek ve Bölümü Düşürmek

Asıl kazanç, verinin bütün olarak kaldırılabilmesidir. Aşağıdaki koşum aynı işi iki kurulumda yapar: 2018 yılının kayıtlarını sistemden çıkarmak.

rm -f s1.db s2.db
cp bolumsuz.db s1.db
cp bolumlu.db  s2.db
olc() { printf '%-8s dosya=%s  toplam_sayfa=%s  serbest=%s\n' "$2" "$(wc -c < "$1")" \
  "$(sqlite3 "$1" 'PRAGMA page_count;')" "$(sqlite3 "$1" 'PRAGMA freelist_count;')"; }

echo "=== bolumsuz: eski yili silme ==="
olc s1.db once
sqlite3 s1.db <<'SQL' | grep 'Run Time'
.timer on
DELETE FROM odunc WHERE alis_tarihi < '2019-01-01';
SQL
olc s1.db silme
sqlite3 s1.db <<'SQL' | grep 'Run Time'
.timer on
VACUUM;
SQL
olc s1.db vacuum

echo
echo "=== bolumlu: eski bolumu dusurme ==="
olc s2.db once
sqlite3 s2.db <<'SQL' | grep 'Run Time'
.timer on
DROP VIEW odunc;
DROP TABLE odunc_2018;
CREATE VIEW odunc AS
  SELECT * FROM odunc_2019 UNION ALL SELECT * FROM odunc_2020 UNION ALL
  SELECT * FROM odunc_2021 UNION ALL SELECT * FROM odunc_2022 UNION ALL
  SELECT * FROM odunc_2023 UNION ALL SELECT * FROM odunc_2024;
SQL
olc s2.db dusurme
sqlite3 s2.db "SELECT count(*) FROM odunc;"
=== bolumsuz: eski yili silme ===
once     dosya= 112066560  toplam_sayfa=27360  serbest=0
Run Time: real 0.674 user 0.165852 sys 0.203029
silme    dosya= 112066560  toplam_sayfa=27360  serbest=1398
Run Time: real 0.460 user 0.157747 sys 0.262727
vacuum   dosya= 95285248  toplam_sayfa=23263  serbest=0

=== bolumlu: eski bolumu dusurme ===
once     dosya= 119492608  toplam_sayfa=29173  serbest=0
Run Time: real 0.000 user 0.000095 sys 0.000291
Run Time: real 0.074 user 0.002629 sys 0.007488
Run Time: real 0.000 user 0.000063 sys 0.000187
dusurme  dosya= 119492608  toplam_sayfa=29173  serbest=4367
1700444

İki yol aynı 299.556 satırı sistemden çıkardı; maliyetleri karşılaştırılamaz. Silme yolu 0,674 saniye sürdü ve yalnızca 1.398 sayfayı serbest bırakabildi — kalan boşluk sayfaların içine dağılmıştı. Boşluğu geri almak için çalıştırılan tam yeniden yazma 0,460 saniye daha aldı ve bu süre boyunca tablo kilitliydi. Toplam bir saniyeyi aştı.

Bölümü düşürme yolu 0,074 saniye sürdü ve 4.367 sayfayı tek işlemde serbest bıraktı. Yapılan iş satır silmek değil, bir nesneyi kaldırmaktır: maliyet satır sayısıyla değil, sabit bir katalog işlemiyle ölçülür. Süreler ortama bağlıdır; kararlı olan, birinin veriyle orantılı, diğerinin veriden bağımsız olmasıdır.

Bu, saklama süresi kuralı olan her sistemde bölümlemenin ana gerekçesidir. Aynı gerekçe ters yönde de çalışır: yeni bir dönem başladığında boş bir bölüm eklemek de sabit maliyetlidir.

Liste ve Karma Bölümlemesi

İki biçim daha vardır. Liste bölümlemesinde (list partitioning) her bölüm, anahtarın belirli değerlerini alır — şehir, şube, ülke. Karma bölümlemesinde (hash partitioning) bölüm, anahtarın bir karma fonksiyonundan geçirilip bölüm sayısına bölünmesiyle bulunur; amaç anlamlı bir gruplama değil, dengeli bir dağıtımdır.

Seçim, bölümlerin ne kadar dengeli dolacağına bağlıdır ve bu hesaplanabilir. Aşağıdaki model üye başına ödünç sayısını güç yasasına yakın dağıtır — az sayıda üye çok ödünç alır, çoğu üye birkaç kayıt bırakır — ve bölüm doluluklarını üç anahtar seçimi için sayar.

cat > dagilim.mjs <<'JS'
// Model: odunc kayitlarinin bolumlere dagilimi. Gercek bir motorun bolumleyicisi degil;
// olculen sey anahtar secimi ile bolum dolulugu arasindaki iliskidir.
// Uye basina odunc sayisi guc yasasina yakin dagitilir: az sayida uye cok odunc alir.
const UYE = 120000, KAYIT = 2000000;
const sehirler = [['Istanbul', 0.42], ['Ankara', 0.24], ['Izmir', 0.16],
                  ['Bursa', 0.11], ['Konya', 0.07]];

function uyeler() {
  const agirlik = [];
  let toplam = 0;
  for (let i = 1; i <= UYE; i++) { const a = 1 / Math.pow(i, 0.8); agirlik.push(a); toplam += a; }
  return agirlik.map((a, i) => {
    let esik = ((i * 7919) % 100) / 100, k = 0, birikim = 0;
    while (k < sehirler.length - 1 && (birikim += sehirler[k][1]) <= esik) k++;
    return { uye_id: i + 1, odunc: Math.max(1, Math.round(a / toplam * KAYIT)), sehir: sehirler[k][0] };
  });
}
const M = 2654435761n;
const karmaDusuk  = (x) => Number((BigInt(x) * M) % 4294967296n);          // dusuk bitler
const karmaYuksek = (x) => Number(((BigInt(x) * M) % 4294967296n) >> 20n); // yuksek bitler

function rapor(ad, kova) {
  const enCok = Math.max(...kova), enAz = Math.min(...kova);
  const bos = kova.filter((s) => s === 0).length;
  const oran = enAz === 0 ? 'bölüm boş: ' + bos : (enCok / enAz).toFixed(2) + '×';
  console.log(ad.padEnd(24), 'bölüm', String(kova.length).padStart(2),
              '| en dolu', String(enCok).padStart(8), '| en boş', String(enAz).padStart(8),
              '| oran', oran);
}
function dagit(uye, P, anahtar) {
  const kova = new Array(P).fill(0);
  for (const u of uye) kova[anahtar(u.uye_id) % P] += u.odunc;
  return kova;
}
const uye = uyeler();
console.log('modeldeki toplam ödünç:', uye.reduce((t, u) => t + u.odunc, 0));
console.log('— karma bölümleme, güç yasasına yakın anahtar —');
for (const P of [8, 16, 64]) {
  rapor('uye_id % P', dagit(uye, P, (x) => x));
  rapor('karma_yuksek % P', dagit(uye, P, karmaYuksek));
}
console.log('— yapılı anahtar: üyelik numarası dörtün katı —');
const dortluk = uye.map((u) => ({ ...u, uye_id: u.uye_id * 4 }));
rapor('uye_id % 8',       dagit(dortluk, 8, (x) => x));
rapor('karma_dusuk % 8',  dagit(dortluk, 8, karmaDusuk));
rapor('karma_yuksek % 8', dagit(dortluk, 8, karmaYuksek));
console.log('— liste bölümleme: şehir —');
const listeKova = sehirler.map(([ad]) =>
  uye.filter((u) => u.sehir === ad).reduce((t, u) => t + u.odunc, 0));
rapor('şehir', listeKova);
console.log(sehirler.map(([ad], i) => `${ad}=${listeKova[i]}`).join('  '));
JS
node dagilim.mjs
modeldeki toplam ödünç: 2001046
— karma bölümleme, güç yasasına yakın anahtar —
uye_id % P               bölüm  8 | en dolu   278850 | en boş   238066 | oran 1.17×
karma_yuksek % P         bölüm  8 | en dolu   273855 | en boş   233420 | oran 1.17×
uye_id % P               bölüm 16 | en dolu   158156 | en boş   116396 | oran 1.36×
karma_yuksek % P         bölüm 16 | en dolu   154631 | en boş   114336 | oran 1.35×
uye_id % P               bölüm 64 | en dolu    69604 | en boş    27466 | oran 2.53×
karma_yuksek % P         bölüm 64 | en dolu    69318 | en boş    26434 | oran 2.62×
— yapılı anahtar: üyelik numarası dörtün katı —
uye_id % 8               bölüm  8 | en dolu  1014448 | en boş        0 | oran bölüm boş: 6
karma_dusuk % 8          bölüm  8 | en dolu  1014448 | en boş        0 | oran bölüm boş: 6
karma_yuksek % 8         bölüm  8 | en dolu   285482 | en boş   236957 | oran 1.20×
— liste bölümleme: şehir —
şehir                    bölüm  5 | en dolu   876922 | en boş   139966 | oran 6.27×
Istanbul=876922  Ankara=464332  Izmir=312596  Bursa=207230  Konya=139966

Üç bulgu çıkıyor.

Birincisi: karma bölümlemesi anahtarları dengeler, satırları değil. Sekiz bölümde dengesizlik oranı 1,17; altmış dört bölümde 2,53. Bölüm küçüldükçe tek bir çok etkin üyenin ağırlığı bölümün tamamına göre büyür ve karma fonksiyonunun kalitesi bunu değiştirmez — üstteki iki satırın hemen hemen aynı çıkması bunu gösteriyor.

İkincisi: karma fonksiyonunun kalitesi anahtarın yapılı olduğu durumda belirleyicidir. Üyelik numaraları dördün katı olduğunda doğrudan bölme sekiz bölümün altısını boş bıraktı. Çarpımsal karmanın düşük bitleri de aynı sonucu verdi; nedeni aritmetiktir — tek bir çarpanla çarpıp ikinin kuvvetine bölmek düşük bitleri korur. Yüksek bitler alındığında dağılım 1,20 oranına düzeldi. Kural: karma değerinin düşük bitleri bölüm seçmek için kullanılmaz.

Üçüncüsü: liste bölümlemesi dengeyi hiç hedeflemez. Şehre göre bölünen kayıtlarda oran 6,27 çıktı ve bu bir kusur değil, gerçekliğin kendisidir. Liste bölümlemesi bölümlerin ayrı yönetilmesi gerektiğinde seçilir — bir şehrin verisinin ayrı saklanması, ayrı yedeklenmesi ya da ayrı silinmesi gerekiyorsa dengesizlik kabul edilir.

Özet

  • Bölümleme, mantıksal olarak tek bir tabloyu bölüm anahtarına göre birden çok fiziksel parçada saklar; aralık, liste ve karma biçimleri anahtarın bölüme nasıl çevrildiğiyle ayrılır.
  • Bölüm anahtarını süzen sorgu ilgisiz bölümlerin verisini okumaz: görünüm üzerinden 141, doğrudan tek bölümden 123 sayfa okundu; aradaki fark altı boş dizin yoklamasıdır.
  • Dizin varken bölümleme okuma başarımı kazandırmadı; bölümsüz tablo aynı sorguyu aynı 123 sayfayla yanıtladı.
  • Asıl kazanç veriyi bütün olarak kaldırmaktır: bir yılın silinip boşluğun geri alınması 1,13 saniye sürdü ve 1.398 sayfa serbest bıraktı; bölümün düşürülmesi 0,07 saniyede 4.367 sayfa serbest bıraktı.
  • Karma bölümlemesi anahtarları dengeler, satırları değil; bölüm sayısı arttıkça dengesizlik büyüdü (1,17 → 2,53). Yapılı anahtarlarda karma değerinin düşük bitleri bölüm seçmek için kullanılamaz.

Sonraki Adım

Bu dersteki bütün bölümler aynı veritabanının içindeydi. Bölümleri ayrı makinelere dağıtmak, aynı fikri bir adım ileri götürür ve nitelik değiştirir: bölümler arası bir sorgu artık ağ üzerinden yürür, bir işlem birden çok makineyi kapsar ve bölüm sayısı değiştiğinde veri taşınmak zorunda kalır. Sonraki ders bu kalıbı ele alır — parça anahtarının seçiminin uygulamaya ne dayattığı, çapraz parça sorgusunun maliyeti ve yeniden dengelemede taşınan veri miktarı sayılarak.

İ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