Web backend’lerinin çoğu compute-intensive değil, data-intensive’dir. Darboğaz nadiren CPU’nun bir şeyi hesaplaması olur; neredeyse her zaman veriyi bir yerden bir yere taşımak, saklamak ve tutarlı tutmaktır. Boring architecture — tek VPS, paylaşılan PostgreSQL, paylaşılan Redis — bu yükün şaşırtıcı bir kısmını taşır.
Ama veri yükü altında bu mimari kademeli olarak yavaşlamaz. Ayrık noktalarda, hem de tahmin edilebilir bir sırayla kırılır. Bu yazı o noktaları haritalıyor: her birini hangi sinyal haber verir, sonraki adım nedir ve o adımın maliyeti nedir?
Tek bir kuralı baştan söyleyeyim: bu adımları sırasıyla yapın. Her adım size zaman kazandırır ve karşılığında operasyonel ağırlık ekler. 4. adımı 1. adımdan önce yapmak, hiçbir şey çözmeden karmaşıklık vergisi ödemektir.
Veri yoğunluğu hangi eksende kırılır
“Ölçeklenme” tek bir sayı değildir. Request sayısı bir eksen, veri hacmi bambaşka bir eksen. Bir sistem dakikada 50 request alıp yine de veri tarafından boğulabilir; bir başkası saniyede 5.000 request’i rahat karşılarken veri katmanı el değmemiş durur.
Veri yoğunluklu sistemleri kıran asıl eksenler şunlar:
- Working set / RAM oranı — sık erişilen verinin RAM’e sığıp sığmadığı.
- Okuma / yazma oranı — yükün hangi tarafa baskın olduğu.
- Tek tablo boyutu — tek bir ilişkinin index ve vacuum davranışını bozacak kadar büyümesi.
- Yazma throughput’u — tek primary’nin saniyede absorbe edebileceği commit sayısı.
- Tutarlılık gereksinimi — verinin ne kadar bayat okunabileceği.
Bu eksenlerin hepsi aynı anda kırılmaz. İyi haber bu: çünkü hepsini aynı anda çözmeniz gerekmiyor. Mimari kararı, “hangi eksen önce kırılacak” sorusuna dürüst cevap vermekle başlar — varsayımla değil, ölçümle.
Başlangıç durumu
Kırılma noktalarını konuşmadan önce kırılacak şeyi tarif edeyim. Laravel production stack yazısındaki kurulumun veri katmanı şuna benzer:
PHP-FPM ──┬──► pgBouncer ──► PostgreSQL 16
│ (tek primary)
│
└──► Redis 7
(cache · session · lock)
Tek primary, önünde pgBouncer ile bağlantı havuzu. Redis cache, session ve lock taşıyor. Bu kurulum bir veritabanı birkaç on GB’a, working set RAM’e sığdığı sürece, dakikada binlerce request’e kadar hiç şikâyet etmeden çalışır. Asıl mesele: çalışmayı ne zaman bırakacağı.
Kırılma noktası 1 — Working set RAM’e sığmıyor
Veri yoğunluğunun ilk ve en sık kırılma noktası budur. PostgreSQL sık erişilen sayfaları shared_buffers’ta, ötesini işletim sisteminin page cache’inde tutar. Sıcak veri — yani working set — bu iki katmana sığdığı sürece okumalar RAM hızındadır. Sığmadığı an her sorgu rastgele disk I/O’suna düşer ve p95 gecikme aniden, kademesiz biçimde tırmanır.
Sinyal. Cache hit ratio’nun düşmesi. Bunu tahmin etmeyin, ölçün:
SELECT
sum(blks_hit) * 100
/ nullif(sum(blks_hit) + sum(blks_read), 0) AS cache_hit_ratio
FROM pg_stat_database;
Sağlıklı bir OLTP sisteminde bu oran %99’un üzerindedir. %95’e doğru düşmeye başladıysa working set RAM’i taşırıyor demektir. Disk read IOPS’unun sürekli yükselmesi ve pg_statio_user_tables’ta belirli tablolarda heap_blks_read’in heap_blks_hit’e yaklaşması aynı hikâyenin başka açılarıdır.
Ne yapılır. Sırasıyla:
- Önce index disiplini. Çoğu “RAM yetmiyor” vakası aslında “gereksiz veri okunuyor” vakasıdır. Eksik index yüzünden sequential scan yapan bir sorgu, working set’i olduğundan kat kat büyük gösterir.
pg_stat_user_tables’taseq_scansayısı yüksek olan büyük tabloları bulun. - Sonra ölü veriyi ayırın. Sıcak ve soğuk veri aynı tabloda yaşıyorsa working set yapay olarak şişer. Üç yıllık kapalı kayıtla bu haftanın aktif kayıtları aynı heap’te durmak zorunda değil.
- En son vertical scale. RAM eklemek bir kez yapılır, ucuzdur ve karmaşıklık eklemez. Working set’in birkaç katı RAM’iniz olsun. Bu, alınabilecek en sade ölçek hamlesidir — utanılacak bir şey değil, ilk tercihtir.
Maliyet. Index bakımının her yazıya eklediği maliyet ve daha büyük bir VPS’in aylık farkı. İkisi de küçük. Bu yüzden 1. kırılma noktası en ucuz çözülenidir — ve bu yüzden ilk sıradadır.
Kırılma noktası 2 — Okuma yükü tek primary’yi doyuruyor
Working set RAM’e sığıyor ama primary yine de doluyor. CPU sürekli yüksek, sorgular kuyrukta bekliyor ve yük ezici biçimde okuma tarafında. Rapor sayfaları, listeleme endpoint’leri, arama — hepsi aynı primary’den okuyor.
Sinyal! Primary’de CPU sürekli yüksekken yazma oranı düşük kalıyor. pg_stat_statements’ta toplam süreyi SELECT’ler domine ediyor. pgBouncer’da cl_waiting sayacı sıfırdan kalkmış — yani client’lar bağlantı için sıraya giriyor.
Ne yapılır? Bir read replica. PostgreSQL’in streaming replication’ı ile primary’nin yanına hot standby bir kopya kurun, okuma trafiğini oraya yönlendirin. Laravel bunu connection ayrımıyla doğal destekler:
// config/database.php
'pgsql' => [
'driver' => 'pgsql',
'read' => ['host' => ['10.0.0.2']], // replica
'write' => ['host' => ['10.0.0.1']], // primary
'sticky' => true,
// ...ortak ayarlar
],
sticky => true kritik. Bir request içinde yazma yaptıysanız, aynı request’teki sonraki okumalar primary’ye gider — kullanıcı kendi yazdığı veriyi bayat görmez. Replica’nın kendi pgBouncer havuzu olur; primary’ninkinden ayrı.
Maliyet. Replication lag. Replica primary’nin birkaç milisaniye — yük altında birkaç saniye — gerisindedir. Yani read-after-write consistency’yi kaybedersiniz, sticky’nin kurtardığı request sınırının dışında. Lag’i izlemek artık bir operasyon kalemidir:
SELECT client_addr, state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;
İkinci maliyet: her sorgunun “bu bayat veri okuyabilir mi” sorusuna cevap vermesi gerekir. Çoğu listeleme/rapor için cevap “evet”. Bakiye, stok, yetki kontrolü için cevap “hayır” — onlar primary’de kalır. Bu sınıflandırma artık mimarinin parçasıdır ve dokümante edilmesi gerekir.
Not: Read replica yazma kapasitesine hiçbir şey katmaz. Aynı yazmalar replica’da da tekrar oynanır. Replica, okuma yükü problemini çözer — yazma yükü problemini değil. İkisini karıştırmak en pahalı yanlış kırılma noktasıdır.
Kırılma noktası 3 — Tek tablo fazla büyük
Veritabanı toplamda makul, ama tek bir tablo onlarca-yüz milyon satıra ulaşmış. Genelde append ağırlıklı olanlar: events, audit_log, notifications, zaman serisi ölçümler. Bu tablo artık kendi başına bir problem.
Sinyal! O tabloda autovacuum yetişemiyor — pg_stat_user_tables’ta n_dead_tup sürekli yüksek ve last_autovacuum eskimiş. Index’ler şişiyor, tek satır güncellemesi bile yavaşlıyor. Eski veriyi DELETE etmek saatler sürüyor ve arkasında devasa bir vacuum borcu bırakıyor.
Ne yapılır? O tabloyu partition’lara bölün. Append ağırlıklı veride doğal anahtar zamandır:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL
) PARTITION BY RANGE (occurred_at);
CREATE TABLE events_2026_05 PARTITION OF events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
Bunun kazandırdığı:
- Partition pruning —
occurred_atfiltreli sorgular sadece ilgili partition’lara dokunur. - Ucuz silme — eski veriyi atmak
DELETEdeğil,DROP TABLE events_2026_01. Saniyeler, vacuum borcu yok. - Sınırlı vacuum — autovacuum her partition’la ayrı ayrı, küçük parçalar hâlinde uğraşır.
Aylık partition oluşturmayı pg_partman ile otomatikleştirin; elle yönetilen bir partition takvimi unutulmaya mahkûmdur.
Maliyet. Bir gerçek tuzak: partition’lı tabloda primary key, partition anahtarını içermek zorundadır. Yani id tek başına PK olamaz; (id, occurred_at) olur. Bu, başka tablolardan bu tabloya foreign key vermeyi zorlaştırır — append-only log tablolarında zaten genelde istenmez, ama bilerek kabul edilen bir tavizdir. Saf append-only ve “sadece zaman aralığı sorgulanan” tablolarda B-tree yerine BRIN index çok daha küçük ve yeterlidir.
Kırılma noktası 4 — Yazma yükü tek primary’yi doyuruyor
Bu zor olanı. Replica okumayı kurtardı, partitioning tek tabloyu evcilleştirdi — ama primary yazma tarafında doyuyor. Saniyedeki commit sayısı, disk fsync ve WAL throughput’u tavana dayandı. Replica eklemek burada işe yaramaz: aynı yazmalar her replica’da yeniden oynanır.
Sinyal! Primary’de I/O wait yüksek, pg_stat_bgwriter’da checkpoint’ler sıklaşmış, WAL üretim hızı disk’in fsync kapasitesini zorluyor. Yazma gecikmesi okuma yükünden bağımsız tırmanıyor.
Ne yapılır? Burada sihirli tek hamle yok; sırayla şu üçü:
- Yazma amplifikasyonunu azaltın. Her index’in her
INSERT/UPDATE’e bir maliyeti vardır.pg_stat_user_indexes’taidx_scan = 0olan index’leri — yani hiç kullanılmayanları — silin. Sık güncellenen tablolardafillfactor’ı düşürerek HOT update oranını artırın. Tek tekINSERTyerine batch insert yapın. - Append-ağırlıklı veriyi primary’den çıkarın. Yazma yükünün büyük kısmı genelde tek bir kaynaktan gelir: olay logu, metrik, analitik event. Bu veri çoğu zaman ilişkisel bütünlük de istemez. Onu ayrı bir store’a taşımak primary’yi asıl işine — transactional veriye — geri verir.
- En son: veri alanını bölün. Hâlâ yetmiyorsa, gerçek bir veri alanı ayrımı (functional sharding) gerekir — ki bu bizi bir sonraki kırılma noktasına götürür.
Maliyet. Bu nokta, boring mimarinin gerçekten zorlandığı yerdir. 1–3 arası adımlar primary’yi tek tutmaya devam ediyordu; buradan sonrası tekillikten ödün vermek demek. Acele etmeyin: çoğu sistem 4. noktaya hiç ulaşmaz, ulaşanların çoğu da aslında 1. noktayı (eksik index, şişmiş working set) atlamış olduğu için erken gelir.
Kırılma noktası 5 — Veri katmanı uygulamadan ayrılmalı
Tek bir uygulama, paylaşılan veritabanında baskın hâle geldi. Yazma yükünü o üretiyor, en büyük tablolar onun, incident’ların kaynağı o. Tek VPS üzerinde çoklu proje mimarisi yazısındaki “büyüme sinyalleri” tam olarak bunu haber verir.
Sinyal! Paylaşılan PostgreSQL’de yükün tek bir database’den geldiği net. O uygulamanın bakım pencereleri diğer projeleri de etkiliyor. Kapasite planı artık “VPS” değil “o uygulama” ekseninde yapılıyor.
Ne yapılır? O uygulamayı kendi veri katmanına taşıyın — ayrı bir PostgreSQL instance’ı, ya kendi sunucusunda ya managed bir serviste. Paylaşılan veritabanından bir uygulamayı çekmek, pgBackRest ile alınan bir yedekten restore + logical replication ile fark kapatma + kısa bir cutover penceresiyle yapılır.
Bu, “her şeyi mikroservise böl” demek değil. Tek bir veri-baskın uygulamayı kendi katmanına almak — diğerleri paylaşılan kurulumda mutlu mesut kalmaya devam ederken. Ayırma kararı veri profiliyle verilir, organizasyon şemasıyla değil.
Maliyet. İkinci bir veritabanı: ikinci bir backup hattı, ikinci bir monitoring hedefi, ikinci bir upgrade takvimi. İki veritabanı arasında artık JOIN yapamazsınız — o sınırı geçen sorgular uygulama katmanında birleşir. Bunlar gerçek maliyetler; tek bir uygulama paylaşılan kurulumu domine ettiği için karşılığında ödenirler, peşin değil.
Yanlış kırılma noktaları
Veri-yükü problemine benzeyen ama olmayan şeyler, en pahalı yanlış dönüşlere yol açar. Sık görülenler:
| Görünen | Gerçek | Doğru hamle |
|---|---|---|
| ”Veritabanı yavaş” | N+1 sorgu — uygulama 200 sorgu atıyor | Eager loading; sorgu sayısını ölç |
| ”Replica lazım” | Tek bir sorguda eksik index | EXPLAIN ANALYZE, index ekle |
| ”Sharding lazım” | Replica ve partitioning hiç denenmedi | Önce 2. ve 3. noktayı tüket |
| ”PostgreSQL yetmez, NoSQL’e geçelim” | jsonb, partitioning, BRIN hiç kullanılmadı | PostgreSQL’i sonuna kadar kullan |
| ”Yazma yükü var, cache ekleyelim” | Cache yazmayı azaltmaz, okumayı azaltır | Yazma için 4. noktaya bak |
Ortak hata kalıbı şu: bir aracın çözdüğü problemle, eldeki problemi karıştırmak. Read replica okumayı ölçekler. Cache okumayı azaltır. Partitioning tek tabloyu evcilleştirir. Bunların hiçbiri yazma throughput’unu artırmaz. Hangi eksenin kırıldığını ölçmeden araç seçmek, yanlış kırılma noktasına yatırım yapmaktır.
Bir de “yeni veritabanı” cazibesi var. Cassandra, MongoDB, ayrı bir Kafka — bunları getirmeden önce PostgreSQL’in jsonb’sini, LISTEN/NOTIFY’ını, declarative partitioning’ini, BRIN index’lerini ve logical replication’ını tükettiğinizden emin olun. Çoğu ekip PostgreSQL’in %20’sini kullanıp “PostgreSQL yetmiyor” sonucuna varır.
Sıra neden önemli
Bu beş nokta bir kontrol listesi değil, bir sıra. Her adım bir öncekinin satın aldığı zamanla ertelenir:
1. Working set / RAM → index + RAM (en ucuz)
2. Okuma yükü → read replica
3. Tek tablo boyutu → partitioning
4. Yazma yükü → amplifikasyon + ayrıştırma
5. Veri-baskın uygulama → ayrı veri katmanı (en pahalı)
Sırayı atlamak iki yönde de pahalıdır. 1’i atlayıp 2’ye geçerseniz — yani eksik index’i görmezden gelip replica eklerseniz — bayat-veri okuma riskini, replication lag izlemeyi ve fazladan bir sunucuyu, aslında bir CREATE INDEX’in çözeceği problem için satın almış olursunuz. Tersi de geçerli: 4. noktada gerçekten yazma duvarına çarpmış bir sistemi cache ekleyerek “çözmeye” çalışmak, sadece teşhisi geciktirir.
Sade mimarinin gücü, tam da bu kırılma noktalarının görünür ve sıralı olmasıdır. Her adımı bir metrik haber verir, her adımın maliyeti önceden bilinir ve hiçbir adım bir sonrakini zorunlu kılmaz. “Bütün veri katmanı çöktü” senaryosu yerine, “cache hit ratio %96’ya düştü, RAM eklemek lazım” senaryosunu yaşarsınız.
Bu yaklaşımın gerçekten bittiği yer de bellidir: tek bir büyük makinenin fsync kapasitesini aşan sürdürülebilir yazma throughput’u, ya da coğrafi olarak dağıtık low-latency gereksinimi. Oraya ulaşan bir sistem zaten bunu finanse edecek bir iş modeline sahiptir — ve o noktada karmaşıklık artık bir borç değil, ödenmiş bir bedeldir. Ondan önce değil.