Ders 01 / 25
Önbellek Katmanları
Aynı yanıtı defalarca üreten bir okuma yolunun ölçülmesi ve istemci, kenar, sunucu ile veritabanı önbelleklerinin zincir olarak kurulması: her katmanın kaç isteği durdurduğu, paylaşılan ile özel önbellek ayrımı ve kaynağa ulaşan istek oranının düşürülmesi.
İçindekiler
Veri Erişim Katmanı ve İş Mantığı kursu, bir isteğin veriye inen yolunu baştan sona kurdu: depo kalıbı, işlem sınırı, sorgu bütçesi, alan kuralları. Kursun sonunda bir varsayım kalmıştı: her istek verinin kaynağına gidiyor. Bu varsayım her zaman tutmaz. Kütüphane sisteminde kitap ayrıntı sayfası günde binlerce kez açılır ve kitabın başlığı ile yazarı aylardır değişmemiştir. Aynı sorgu aynı yanıtı üretiyorsa, yanıtın saklanması sorgunun tekrarından ucuzdur.
Bu ders önce tekrarın ölçüsünü koyar, sonra yanıtın saklanabileceği yerleri sıralar. Saklama yerlerinin her biri bir önbellek (cache) katmanıdır ve katmanlar bir zincir oluşturur: istek zincirin ne kadar erken bir halkasında durursa o kadar ucuza karşılanır.
Ölçülen Tekrar
Ölçümler kurs boyunca kullanılan kütüphane şeması üzerinde yapılır: şube, kitap, üye ve ödünç bağıntıları. Aşağıdaki blok veritabanını sıfırdan kurar.
# kur.sh — kutuphane veritabanini kurar (M16/K04 semasinin ayni sutunlariyla) rm -f kutuphane.db sqlite3 kutuphane.db <<'SQL' CREATE TABLE sube (sube_id INTEGER PRIMARY KEY, ad TEXT NOT NULL, sehir TEXT NOT NULL); CREATE TABLE kitap (kitap_id INTEGER PRIMARY KEY, baslik TEXT NOT NULL, yazar TEXT NOT NULL, basim_yili INTEGER, sube_id INTEGER REFERENCES sube(sube_id)); 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 REFERENCES kitap(kitap_id), uye_id INTEGER NOT NULL REFERENCES uye(uye_id), alis_tarihi TEXT NOT NULL, iade_tarihi TEXT); INSERT INTO sube VALUES (1,'Merkez','Ankara'),(2,'Bahçelievler','Ankara'), (3,'Kadıköy','İstanbul'),(4,'Konak','İzmir'); INSERT INTO kitap SELECT n, 'Kitap ' || n, 'Yazar ' || (n % 40 + 1), 1950 + n % 70, n % 4 + 1 FROM (WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 300) SELECT n FROM s); INSERT INTO uye SELECT n, 'Uye' || n, 'Soyad' || n, 'uye' || n || '@ornek.test', '2024-01-01' FROM (WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 200) SELECT n FROM s); INSERT INTO odunc SELECT n, n % 300 + 1, n % 200 + 1, '2025-' || substr('0' || (n % 12 + 1), -2) || '-' || substr('0' || (n % 28 + 1), -2), CASE WHEN n % 5 = 0 THEN NULL ELSE '2025-12-31' END FROM (WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 4000) SELECT n FROM s); CREATE INDEX odunc_uye ON odunc(uye_id); CREATE INDEX kitap_sube ON kitap(sube_id); SQL sqlite3 kutuphane.db "SELECT 'sube=' || (SELECT count(*) FROM sube) || ' kitap=' || (SELECT count(*) FROM kitap) || ' uye=' || (SELECT count(*) FROM uye) || ' odunc=' || (SELECT count(*) FROM odunc);"
sube=4 kitap=300 uye=200 odunc=4000
Kaynak, üç okumayı sunar: kitap ayrıntısı, şube raf sayısı ve üyenin ödünç geçmişi. Her okuma sayılır — Başarım Sorunları konusundaki sorgu sayacının aynı yaklaşımı.
// kaynak.mjs — kutuphane verisinin kaynagi; her okuma sayilir import { DatabaseSync } from "node:sqlite"; export function kaynakAc(dosya = "kutuphane.db") { const db = new DatabaseSync(dosya); const sayac = { sorgu: 0 }; const tek = (sql, ...d) => { sayac.sorgu += 1; return db.prepare(sql).get(...d); }; return { sayac, kitapAyrinti: (id) => tek( "SELECT kitap_id, baslik, yazar, basim_yili, sube_id FROM kitap WHERE kitap_id = ?", id), subeStok: (subeId) => tek( `SELECT ? AS sube_id, count(*) AS raftaki FROM kitap k WHERE k.sube_id = ? AND NOT EXISTS (SELECT 1 FROM odunc o WHERE o.kitap_id = k.kitap_id AND o.iade_tarihi IS NULL)`, subeId, subeId), uyeGecmisi: (uyeId) => tek( "SELECT ? AS uye_id, count(*) AS toplam FROM odunc WHERE uye_id = ?", uyeId, uyeId), kapat: () => db.close(), }; } // Istek akisi: kitap ayrinti istekleri birkac baslikta yogunlasir (belirlenimci uretec). export function istekAkisi(adet) { let tohum = 20250729; const rastgele = () => ((tohum = (tohum * 1103515245 + 12345) % 2147483648) / 2147483648); const istekler = []; for (let i = 0; i < adet; i++) { const r = rastgele(); if (r < 0.7) istekler.push(["kitap", Math.floor(rastgele() * 20) + 1]); else if (r < 0.9) istekler.push(["sube", Math.floor(rastgele() * 4) + 1]); else istekler.push(["uye", Math.floor(rastgele() * 50) + 1]); } return istekler; }
Altı yüz isteklik akış doğrudan kaynağa uygulandığında ortaya çıkan sayı, tekrarın ölçüsüdür.
// kaynaksiz.mjs — her istek kaynaga gidiyor: sorgu sayisi istek sayisina esit import { kaynakAc, istekAkisi } from "./kaynak.mjs"; const kaynak = kaynakAc(); const istekler = istekAkisi(600); const okuyucu = { kitap: kaynak.kitapAyrinti, sube: kaynak.subeStok, uye: kaynak.uyeGecmisi }; const t = performance.now(); for (const [tur, id] of istekler) okuyucu[tur](id); const sure = performance.now() - t; const tekil = new Set(istekler.map(([tur, id]) => `${tur}:${id}`)).size; console.log(`istek=${istekler.length} tekil_anahtar=${tekil} kaynak_sorgusu=${kaynak.sayac.sorgu}`); console.log(`ayni yaniti ureten tekrar sorgu = ${kaynak.sayac.sorgu - tekil}`); console.log(`sure=${sure.toFixed(1)} ms (ortama bagli)`); kaynak.kapat();
istek=600 tekil_anahtar=53 kaynak_sorgusu=600 ayni yaniti ureten tekrar sorgu = 547 sure=545.3 ms (ortama bagli)
Altı yüz istek yalnızca 53 farklı yanıt üretti. Kalan 547 sorgu, veritabanının daha önce ürettiği bir sonucu yeniden üretmek için çalıştı. Süre alanı makineye bağlıdır ve her koşuda değişir; 600 ile 53 sayıları değişmez, çünkü bunlar iş yükünün özellikleridir.
Buradaki fırsat sorgunun hızlandırılması değildir. Sorgu zaten dizinli ve planı düzgün. Fırsat, sorgunun çalıştırılmamasıdır.
Zincirdeki Dört Katman
Yanıt, kaynak ile kullanıcı arasındaki dört noktada saklanabilir.
İstemci önbelleği kullanıcının kendi aygıtındadır: tarayıcının HTTP önbelleği ya da uygulamanın yerel deposu. Ağa hiç çıkmadığı için en ucuz katmandır. Karşılığında yalnız tek kullanıcıya hizmet eder ve içeriği sunucunun denetiminden çıkmıştır — konan kopya, konarken verilen süre dolana kadar orada kalır.
Kenar önbelleği (edge cache) kullanıcıya coğrafi olarak yakın, paylaşılan bir ara noktadır. Frontend Kalitesi kursundaki Statik Barındırma dersinde statik dosyalar için tanıtılmıştı; aynı katman API yanıtları için de çalışır. Paylaşılan olduğu için bir kullanıcının ısıttığı kopyayı diğerleri kullanır. Aynı nedenle kişiye özel veriyi taşıyamaz: oraya konan bir üye geçmişi başka bir üyeye sunulabilir.
Sunucu önbelleği uygulamanın kendi denetimindedir. İki biçimi vardır: süreç içi bellek ve bütün örneklerin paylaştığı ayrı bir depo. Süreç içi olan en hızlısıdır ama her örnek kendi kopyasını tutar; paylaşılan olan bir ağ turu ister ama tek bir doğruluk noktası verir. Bu katman uygulamanın kodundadır, dolayısıyla geçersizleştirme kararını anında uygulayabilir.
Veritabanı önbelleği kaynağın içindedir: sayfa arabelleği ve sorgu planı önbelleği. Yukarıdaki ölçümde bu katman zaten çalışıyordu — 600 sorgunun tamamı sıcak sayfalar üzerinde koştu. Sonuç yine 600 sorgudur. Veritabanı önbelleği bir sorgunun maliyetini düşürür, sorgu sayısını düşürmez. Bağlantı, ayrıştırma, plan seçimi ve gidiş-dönüş her çağrıda yeniden ödenir.
Zincirin Ölçülmesi
Dört katmanı bir zincir olarak kurup her katmanın kaç isteği durdurduğunu saymak, katman tartışmasını sayıya çevirir. Aşağıdaki kurulum üç önbellek katmanını gerçekler; dördüncüsü kaynağın içindedir. Her katmanın bir kapasitesi vardır: dolduğunda en eski girdi atılır.
// katmanlar.mjs — dort katmanli zincir: her istek nerede duruyor? import { kaynakAc } from "./kaynak.mjs"; // Kapasitesi dolunca en eski girdiyi atan kucuk bir onbellek. function kucukOnbellek(kapasite) { const kutu = new Map(); return { al: (a) => kutu.get(a), var: (a) => kutu.has(a), koy(a, d) { kutu.delete(a); kutu.set(a, d); if (kutu.size > kapasite) kutu.delete(kutu.keys().next().value); }, }; } const kaynak = kaynakAc(); const okuyucu = { kitap: kaynak.kitapAyrinti, sube: kaynak.subeStok, uye: kaynak.uyeGecmisi }; const GENEL = new Set(["kitap", "sube"]); // her uyeye ayni donen kaynaklar const istemciler = new Map(); // kullanici basina ayri onbellek, 4 girdi const kenar = kucukOnbellek(10); // paylasilan, yalniz genel kaynaklar const sunucu = kucukOnbellek(200); // paylasilan, her sey const sayac = { istemci: 0, kenar: 0, sunucu: 0, kaynak: 0 }; function oku(kullanici, tur, id) { const anahtar = `${tur}:${id}`; if (!istemciler.has(kullanici)) istemciler.set(kullanici, kucukOnbellek(4)); const istemci = istemciler.get(kullanici); const yerlestir = (d) => { if (GENEL.has(tur)) kenar.koy(anahtar, d); istemci.koy(anahtar, d); return d; }; if (istemci.var(anahtar)) { sayac.istemci += 1; return istemci.al(anahtar); } if (GENEL.has(tur) && kenar.var(anahtar)) { sayac.kenar += 1; return yerlestir(kenar.al(anahtar)); } const sunucuAnahtari = GENEL.has(tur) ? anahtar : `${anahtar}@${kullanici}`; if (sunucu.var(sunucuAnahtari)) { sayac.sunucu += 1; return yerlestir(sunucu.al(sunucuAnahtari)); } sayac.kaynak += 1; const deger = okuyucu[tur](id); sunucu.koy(sunucuAnahtari, deger); return yerlestir(deger); } let tohum = 20250729; const rastgele = () => ((tohum = (tohum * 1103515245 + 12345) % 2147483648) / 2147483648); const ISTEK = 600; for (let i = 0; i < ISTEK; i++) { const kullanici = Math.floor(rastgele() * 40) + 1; const r = rastgele(); const tur = r < 0.7 ? "kitap" : r < 0.9 ? "sube" : "uye"; const id = tur === "kitap" ? Math.floor(rastgele() * 20) + 1 : tur === "sube" ? Math.floor(rastgele() * 4) + 1 : kullanici; oku(kullanici, tur, id); } console.log(`istek=${ISTEK}`); for (const [ad, n] of Object.entries(sayac)) { console.log(`${ad.padEnd(8)} ${String(n).padStart(4)} (%${((n / ISTEK) * 100).toFixed(1)})`); } console.log(`kaynak sorgusu = ${kaynak.sayac.sorgu}`); kaynak.kapat();
istek=600 istemci 98 (%16.3) kenar 189 (%31.5) sunucu 260 (%43.3) kaynak 53 (%8.8) kaynak sorgusu = 53
Kaynağa ulaşan istek sayısı 600’den 53’e düştü; iş yükündeki her farklı anahtar kaynağı bir kez gördü. Zincirin geri kalanı ilginç olan kısmıdır. En yakın katman isteklerin yalnız %16’sını durdurdu, çünkü kapasitesi dört girdiyle sınırlı ve her kullanıcının kopyası ayrıdır. Paylaşılan kenar katmanı %31’ini durdurdu. En çok işi %43 ile sunucu katmanı yaptı: kapasitesi en büyük, kapsamı en geniş katman odur.
Sayıların kapasiteye bağlı olması bu ölçümün asıl dersidir. Kenar katmanının kapasitesi büyütülürse payı artar, sunucununki azalır. Katmanlar birbirinin yerine geçmez; birbirinin artığını toplar.
Bir Veri Hangi Katmana Konur
Katman seçimi iki ölçütle yapılır.
Birincisi paylaşımtır. Bir yanıt bütün kullanıcılara aynı dönüyorsa paylaşılan katmanlara
konabilir. Kitap ayrıntısı ve şube raf sayısı böyledir. Üyenin ödünç geçmişi değildir; onu
kenar önbelleğine koymak, Kimlik Doğrulama ve Yetkilendirme kursunda üzerinde durulan
yetkilendirme sınırını delmek demektir. Ölçüm kodunda bu ayrım GENEL kümesiyle yapılmış ve
kişiye özel kayıtlar sunucu katmanında kullanıcı kimliğiyle anahtarlanmıştır.
İkincisi bayatlık toleransıdır (staleness). Önbellekteki kopya, kaynak değiştiğinde bir süre eski kalır. Kitabın yazarı için bu süre saatler olabilir; şubedeki raf sayısı için saniyeler. Ödünç verme işleminin kendisi için sıfırdır — bir yazma işleminin sonucu önbellekten okunamaz.
Katmanın kullanıcıya yakınlığı ile denetlenebilirliği ters orantılıdır. Sunucu katmanındaki bir girdi kod tarafından anında silinebilir. Kenar katmanındakini silmek ayrı bir işlem ister. İstemcideki kopya için hiçbir yol yoktur: konarken verilen süre dolana kadar kullanılır. Bu yüzden en yakın katmana en uzun ömür verilen veriler konur ve bu kararın geri alınamayacağı bilinerek verilir.
Özet
- Altı yüz isteklik iş yükü yalnız 53 farklı yanıt üretti; 547 sorgu daha önce üretilmiş bir sonucu yeniden üretti. Önbelleklemenin fırsatı sorguyu hızlandırmak değil, çalıştırmamaktır.
- Yanıt dört noktada saklanabilir: istemci, kenar, sunucu ve veritabanı önbelleği. İlk üçü sorgu sayısını düşürür; veritabanı önbelleği yalnız her sorgunun maliyetini düşürür.
- Zincir ölçüldüğünde kaynağa ulaşan istek 600’den 53’e indi; katmanların payı kapasitelerine göre dağıldı. Katmanlar birbirinin yerine geçmez, artığını toplar.
- Paylaşılan katmanlara yalnız bütün kullanıcılara aynı dönen veri konur; kişiye özel veri paylaşılan bir önbellekte yetkilendirme sınırını deler.
- Katman kullanıcıya yaklaştıkça ucuzlar ve denetlenemez hâle gelir; istemcideki kopya, konarken verilen süre dolana kadar geri alınamaz.
Sonraki Adım
Bu derste önbellek, okuma yolunun içine elle yerleştirildi: bak, yoksa kaynaktan getir, koy. Bu yazımın adı vardır ve tek seçenek değildir. Okuma yolunun yanında duran bir önbellek ile bütün okumaların üzerinden geçtiği bir önbellek farklı sorumluluk dağılımları kurar; yazma yolunda ise önbelleğin ne zaman güncelleneceği sorusu ortaya çıkar — yazma anında mı, sonra mı. Sonraki ders bu üç stratejiyi aynı iş yükü üzerinde koşturur ve kaynağa giden okuma ve yazma sayılarıyla tutarsızlık penceresini karşılaştırır.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.