İçeriğe geç
academia.sh

Ders 12 / 21

N+1 Sorgu Sorunu

Sorgu sayısının kayıt sayısıyla büyümesi: sayan bir sarmalayıcıyla ölçüm, tek tek yükleme ile toplu getirme ve birleştirmenin karşılaştırılması, ölçeklenme tablosu, gidiş-dönüş maliyetinin etkisi ve sorgu bütçesinin bir sınama olarak yazılması.

İçindekiler

İşlemler konusu yazmanın doğruluğunu kurdu: sınır nereye çizilir, okuma neyi görür, eşzamanlı güncelleme nasıl çözülür, yeniden deneme neden güvenlidir. Hiçbiri hızla ilgili değildi.

Doğru çalışan bir veri erişim katmanı yavaş olabilir ve yavaşlığın kaynağı çoğunlukla sorgunun kendisi değil, sorgu sayısıdır. Bu kursun ilk dersinde küçük bir eşleyici üç ödünç kaydı için on sorgu çalıştırmıştı. Bu ders o sayıyı ölçer, kayıt sayısıyla nasıl büyüdüğünü gösterir ve sabitler.

Ölçüm Verisi

Ölçüm için gerçek büyüklükte bir veri kümesi gerekir.

// uret.mjs — olcum icin kutuphane verisini uretir: 200 kitap, 60 uye, 2000 odunc kaydi
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";

for (const d of ["kutuphane.db", "kutuphane.db-wal", "kutuphane.db-shm"]) {
  rmSync(d, { force: true });
}
const db = new DatabaseSync("kutuphane.db");
db.exec(`
CREATE TABLE uye (uye_id INTEGER PRIMARY KEY, ad TEXT NOT NULL, soyad TEXT NOT NULL);
CREATE TABLE kitap (kitap_id INTEGER PRIMARY KEY, baslik TEXT NOT NULL, yazar TEXT NOT NULL);
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);`);

db.exec("BEGIN");
const uyeEkle = db.prepare("INSERT INTO uye VALUES (?,?,?)");
for (let i = 1; i <= 60; i++) uyeEkle.run(i, `Uye${i}`, `Soyad${i}`);
const kitapEkle = db.prepare("INSERT INTO kitap VALUES (?,?,?)");
for (let i = 1; i <= 200; i++) kitapEkle.run(i, `Kitap ${i}`, `Yazar ${(i % 40) + 1}`);
const oduncEkle = db.prepare("INSERT INTO odunc VALUES (?,?,?,?,?)");
for (let i = 1; i <= 2000; i++) {
  const gun = String((i % 28) + 1).padStart(2, "0");
  const ay = String((i % 12) + 1).padStart(2, "0");
  oduncEkle.run(i, (i % 200) + 1, (i % 60) + 1, `2025-${ay}-${gun}`, i % 4 === 0 ? null : "2025-12-31");
}
db.exec("COMMIT");
console.log("uye:", db.prepare("SELECT count(*) AS n FROM uye").get().n,
            " kitap:", db.prepare("SELECT count(*) AS n FROM kitap").get().n,
            " odunc:", db.prepare("SELECT count(*) AS n FROM odunc").get().n,
            " acik:", db.prepare("SELECT count(*) AS n FROM odunc WHERE iade_tarihi IS NULL").get().n);
node uret.mjs
uye: 60  kitap: 200  odunc: 2000  acik: 500

Sorguyu Görünür Kılmak

Ölçülmeyen sayı düşmez. Bağlantının önüne konan ince bir sarmalayıcı, çalışan sorguları ve dönen satırları sayar.

// sayan.mjs — calisan sorgulari sayan ince bir sarmalayici
import { DatabaseSync } from "node:sqlite";

export function sayanBaglanti(dosya) {
  const db = new DatabaseSync(dosya);
  const sayac = { sorgu: 0, satir: 0 };
  return {
    sayac,
    tek(sql, ...d) { sayac.sorgu += 1; const r = db.prepare(sql).get(...d);
                     if (r !== undefined) sayac.satir += 1; return r; },
    cok(sql, ...d) { sayac.sorgu += 1; const r = db.prepare(sql).all(...d);
                     sayac.satir += r.length; return r; },
    sifirla() { sayac.sorgu = 0; sayac.satir = 0; },
  };
}

Üç Yaklaşım

Görev aynı: iade edilmemiş ödünç kayıtlarını kitap başlığı ve üye adıyla listele. Üç yol denenir. Tek tek yükleme her kayıt için ilişkileri ayrı ayrı okur; ilk derste kurulan tembel yüklemenin doğrudan yazılmış hâlidir. Toplu getirme kimlikleri toplayıp tek sorguda okur. Birleştirme işi veritabanına bırakır.

// n-arti-1.mjs — uc yaklasim, ayni liste; sorgu ve satir sayilari olculur
import { sayanBaglanti } from "./sayan.mjs";
const db = sayanBaglanti("kutuphane.db");
const SINIR = Number(process.argv[2] ?? 100);

function tekTek() {
  db.sifirla();
  const t = performance.now();
  const kayitlar = db.cok(
    "SELECT odunc_id, kitap_id, uye_id FROM odunc WHERE iade_tarihi IS NULL ORDER BY odunc_id LIMIT ?", SINIR);
  const liste = kayitlar.map((o) => ({
    id: o.odunc_id,
    baslik: db.tek("SELECT baslik FROM kitap WHERE kitap_id = ?", o.kitap_id).baslik,
    uye: db.tek("SELECT ad FROM uye WHERE uye_id = ?", o.uye_id).ad,
  }));
  return { ad: "tek tek", liste, ...db.sayac, sure: performance.now() - t };
}

function topluGetir() {
  db.sifirla();
  const t = performance.now();
  const kayitlar = db.cok(
    "SELECT odunc_id, kitap_id, uye_id FROM odunc WHERE iade_tarihi IS NULL ORDER BY odunc_id LIMIT ?", SINIR);
  const kitapIds = [...new Set(kayitlar.map((o) => o.kitap_id))];
  const uyeIds = [...new Set(kayitlar.map((o) => o.uye_id))];
  const kitapYer = new Map(db.cok(
    `SELECT kitap_id, baslik FROM kitap WHERE kitap_id IN (${kitapIds.map(() => "?").join(",")})`,
    ...kitapIds).map((k) => [k.kitap_id, k.baslik]));
  const uyeYer = new Map(db.cok(
    `SELECT uye_id, ad FROM uye WHERE uye_id IN (${uyeIds.map(() => "?").join(",")})`,
    ...uyeIds).map((u) => [u.uye_id, u.ad]));
  const liste = kayitlar.map((o) => ({
    id: o.odunc_id, baslik: kitapYer.get(o.kitap_id), uye: uyeYer.get(o.uye_id),
  }));
  return { ad: "toplu getirme", liste, ...db.sayac, sure: performance.now() - t };
}

function birlestirme() {
  db.sifirla();
  const t = performance.now();
  const liste = db.cok(
    `SELECT o.odunc_id AS id, k.baslik, u.ad AS uye
     FROM odunc o JOIN kitap k ON k.kitap_id = o.kitap_id JOIN uye u ON u.uye_id = o.uye_id
     WHERE o.iade_tarihi IS NULL ORDER BY o.odunc_id LIMIT ?`, SINIR);
  return { ad: "birlestirme", liste, ...db.sayac, sure: performance.now() - t };
}

const sonuclar = [tekTek(), topluGetir(), birlestirme()];
const ilk = JSON.stringify(sonuclar[0].liste);
for (const s of sonuclar) {
  console.log(`${s.ad.padEnd(14)} sorgu=${String(s.sorgu).padStart(4)}  ` +
    `donen_satir=${String(s.satir).padStart(4)}  sure=${s.sure.toFixed(1).padStart(6)} ms  ` +
    `sonuc_ayni=${JSON.stringify(s.liste) === ilk}`);
}
node n-arti-1.mjs 100
tek tek        sorgu= 201  donen_satir= 300  sure=   1.2 ms  sonuc_ayni=true
toplu getirme  sorgu=   3  donen_satir= 165  sure=   0.2 ms  sonuc_ayni=true
birlestirme    sorgu=   1  donen_satir= 100  sure=   0.1 ms  sonuc_ayni=true

Üç yol da aynı listeyi üretti; son sütun bunu doğruluyor. Sorgu sayıları 201, 3 ve 1.

İlk sayının yapısı adı veriyor: bir sorgu listeyi getirir, sonra her kayıt için ek sorgular çalışır. Bu örnekte kayıt başına iki ilişki okunduğu için sayı 1+2N1 + 2N oluyor. Genel biçimiyle N+1 sorgu sorunu budur: liste için bir, her öğe için bir sorgu.

Sayının Büyümesi

Sorunun ciddiyeti tek bir ölçümde görünmez; kayıt sayısı arttıkça görünür.

for n in 10 50 100 250 500; do echo "--- kayit sayisi $n ---"; node n-arti-1.mjs $n; done
--- kayit sayisi 10 ---
tek tek        sorgu=  21  donen_satir=  30  sure=   0.3 ms  sonuc_ayni=true
toplu getirme  sorgu=   3  donen_satir=  30  sure=   0.1 ms  sonuc_ayni=true
birlestirme    sorgu=   1  donen_satir=  10  sure=   0.0 ms  sonuc_ayni=true
--- kayit sayisi 50 ---
tek tek        sorgu= 101  donen_satir= 150  sure=   0.8 ms  sonuc_ayni=true
toplu getirme  sorgu=   3  donen_satir= 115  sure=   0.2 ms  sonuc_ayni=true
birlestirme    sorgu=   1  donen_satir=  50  sure=   0.1 ms  sonuc_ayni=true
--- kayit sayisi 100 ---
tek tek        sorgu= 201  donen_satir= 300  sure=   1.2 ms  sonuc_ayni=true
toplu getirme  sorgu=   3  donen_satir= 165  sure=   0.2 ms  sonuc_ayni=true
birlestirme    sorgu=   1  donen_satir= 100  sure=   0.1 ms  sonuc_ayni=true
--- kayit sayisi 250 ---
tek tek        sorgu= 501  donen_satir= 750  sure=   2.6 ms  sonuc_ayni=true
toplu getirme  sorgu=   3  donen_satir= 315  sure=   0.2 ms  sonuc_ayni=true
birlestirme    sorgu=   1  donen_satir= 250  sure=   0.1 ms  sonuc_ayni=true
--- kayit sayisi 500 ---
tek tek        sorgu=1001  donen_satir=1500  sure=   5.1 ms  sonuc_ayni=true
toplu getirme  sorgu=   3  donen_satir= 565  sure=   0.4 ms  sonuc_ayni=true
birlestirme    sorgu=   1  donen_satir= 500  sure=   0.2 ms  sonuc_ayni=true

Süreler makineye bağlıdır; sorgu sayıları değildir. Tek tek yüklemede sayı kayıt sayısıyla doğrusal artıyor. Toplu getirmede üçte, birleştirmede birde sabit kalıyor. Sabitlik aranan özelliktir; listedeki kayıt sayısı iki katına çıktığında sorgu sayısının değişmemesi gerekir.

Dönen satır sayıları ikinci bir ayrımı gösteriyor. Toplu getirme beş yüz kayıt için 565 satır döndürdü, birleştirme 500. İlkinde ödünç kayıtları bir kez, kitaplar ve üyeler tekilleştirilmiş hâlde okundu; ikincisinde her satır kendi kitap ve üye sütunlarını taşıdığı için aynı kitabın başlığı defalarca aktarıldı. Bu fark, bir sonraki dersin konusudur.

Yerel Ölçümün Gizlediği

Yukarıdaki sürelerde tek tek yükleme beş yüz kayıt için beş milisaniye aldı; kabul edilebilir görünüyor. Bu, ölçümün yerel bir dosya üzerinde yapılmasından kaynaklanıyor.

Bağlantı havuzu dersinde konu edilen gidiş-dönüş burada belirleyici olur. Veritabanı ayrı bir sunucudaysa her sorgu bir ağ turu ister. Tur başına yalnız yarım milisaniye varsayalım: 1001 sorgu 500 milisaniyeden çok, tek sorgu yarım milisaniye eder. Bu bir ölçüm değil, ölçülen sorgu sayısıyla yapılan bir hesaptır; hesabın söylediği, sorgu sayısının yerel ortamda gizlenip gerçek ortamda ortaya çıkan bir maliyet olduğudur.

Bu yüzden başarım ölçütü süre değil sorgu sayısı olarak konur. Süre ortamdan ortama değişir; sorgu sayısı kodun bir özelliğidir.

Toplu Getirme mi, Birleştirme mi

İki çözüm arasındaki seçim veri şekline bağlıdır.

Birleştirme tek sorgudur ve en az satırı döndürür; ilişkiler bire bir ya da bire az sayıda olduğunda doğru seçimdir. Bire çok ilişkilerde ise sonuç kümesi çarpar: her ödünç kaydı için üç etiket varsa satır sayısı üçe katlanır ve ödünç sütunları her satırda tekrarlanır.

Toplu getirme sabit sayıda ek sorgu ekler, ama çarpma üretmez. Her bağıntı bir kez okunur ve birleştirme uygulamada yapılır. Çok sayıda bire çok ilişki varsa bu yol daha az veri aktarır.

Toplu getirmenin bir uygulama ayrıntısı vardır: kimlik listesi sorgu metnine soru işareti olarak girer. Liste büyüdükçe metin büyür ve motorların bağlı değişken sayısı sınırına yaklaşılır. Bu yüzden kimlikler parçalara bölünerek sorulur; parça büyüklüğü sabit tutulduğu sürece sorgu sayısı kayıt sayısıyla değil, sabit bir bölme oranıyla artar.

Bütçeyi Sınamaya Çevirmek

Sorgu sayısı ölçülebiliyorsa sınanabilir. Aşağıdaki sınama, listenin belirli bir bütçenin üstüne çıkmasını hata sayar.

// butce.test.mjs — sorgu sayisi butcesi bir sinama olarak yazilir
import { test } from "node:test";
import assert from "node:assert/strict";
import { sayanBaglanti } from "./sayan.mjs";

const BUTCE = 5;

function acikOduncListesi(db, sinir) {
  const kayitlar = db.cok(
    "SELECT odunc_id, kitap_id, uye_id FROM odunc WHERE iade_tarihi IS NULL ORDER BY odunc_id LIMIT ?", sinir);
  const kitapIds = [...new Set(kayitlar.map((o) => o.kitap_id))];
  const uyeIds = [...new Set(kayitlar.map((o) => o.uye_id))];
  const kitapYer = new Map(db.cok(
    `SELECT kitap_id, baslik FROM kitap WHERE kitap_id IN (${kitapIds.map(() => "?").join(",")})`,
    ...kitapIds).map((k) => [k.kitap_id, k.baslik]));
  const uyeYer = new Map(db.cok(
    `SELECT uye_id, ad FROM uye WHERE uye_id IN (${uyeIds.map(() => "?").join(",")})`,
    ...uyeIds).map((u) => [u.uye_id, u.ad]));
  return kayitlar.map((o) => ({ id: o.odunc_id, baslik: kitapYer.get(o.kitap_id), uye: uyeYer.get(o.uye_id) }));
}

for (const sinir of [10, 100, 500]) {
  test(`acik odunc listesi ${sinir} kayit icin butceyi asmiyor`, () => {
    const db = sayanBaglanti("kutuphane.db");
    const liste = acikOduncListesi(db, sinir);
    assert.equal(liste.length, sinir);
    assert.ok(db.sayac.sorgu <= BUTCE, `sorgu sayisi ${db.sayac.sorgu} > ${BUTCE}`);
  });
}
node --test butce.test.mjs
✔ acik odunc listesi 10 kayit icin butceyi asmiyor (0.722292ms)
✔ acik odunc listesi 100 kayit icin butceyi asmiyor (0.57875ms)
✔ acik odunc listesi 500 kayit icin butceyi asmiyor (0.475583ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 36.655375

Sınamanın değeri, üç farklı kayıt sayısında aynı bütçeyi istemesidir. Biri tembel yüklemeye dönen bir değişiklik yaparsa, beş yüz kayıtlı sınama bütçeyi aşar ve hata sorgu sayısıyla birlikte bildirilir. Bu, N+1 sorununun kod incelemesinde gözden kaçmasını engelleyen tek otomatik yoldur.

Özet

  • Sorgu sayısı, sarmalayıcıyla sayıldığında görünür oldu: aynı liste için tek tek yükleme 201, toplu getirme 3, birleştirme 1 sorgu çalıştırdı.
  • Tek tek yüklemede sorgu sayısı kayıt sayısıyla doğrusal artıyor; beş yüz kayıtta 1001’e çıktı. Öteki iki yolda sabit kaldı.
  • Yerel ölçümde fark milisaniyelerle kalır; ağ üzerinden her sorgu bir gidiş-dönüş istediğinden aynı fark saniyelere çıkar. Bu yüzden ölçüt süre değil sorgu sayısıdır.
  • Birleştirme en az satırı döndürür ama bire çok ilişkilerde sonucu çarpar; toplu getirme çarpma üretmez, karşılığında sabit sayıda ek sorgu ister.
  • Sorgu bütçesi bir sınamaya çevrildiğinde, tembel yüklemeye dönüş otomatik olarak yakalanır.

Sonraki Adım

Bu derste bir sayı düştü, başka bir sayı gözden kaçtı. Toplu getirme beş yüz kayıt için 565 satır, birleştirme 500 satır döndürdü; ama satır sayısı aktarılan verinin ölçüsü değil. Sorgular yalnız gereken sütunları istemişti. Uygulamalarda çoğu sorgu bütün sütunları ister, çoğu liste gereğinden çok satır getirir ve fark bayt cinsinden ölçülene kadar görünmez. Sonraki ders aynı listeyi farklı sütun kümeleriyle çekerek aktarılan veriyi bayt olarak ölçer ve alan ile satır düzeyinde sınırlamanın karşılığını gösterir.

İ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