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ı 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.