---
title: "Veri Yoğunluklu Sistemlerde Ölçek Kırılma Noktaları"
description: "Veri yükü arttıkça boring mimari nerede kırılır? Working set, read replica, partitioning, yazma yükü, ayrı veri katmanı — sırayla ve maliyetleriyle."
url: https://sade.dev/tr/systems/veri-yogunluklu-sistemler-kirilma-noktalari/
lang: tr
author: "Muhammet Şafak"
published: 2026-05-20
updated: 2026-09-06
section: System
tags: ["postgresql","architecture","production","scaling","replication"]
summary: "Veri yükü request yükünden farklı kırılır — kademeli değil, ayrık noktalarda. Boring mimarinin hangi sinyalde, hangi sırayla kırıldığı ve her adımın maliyeti."
---

# Veri Yoğunluklu Sistemlerde Ölçek Kırılma Noktaları

> Veri yükü request yükünden farklı kırılır — kademeli değil, ayrık noktalarda. Boring mimarinin hangi sinyalde, hangi sırayla kırıldığı ve her adımın maliyeti.

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](/tr/journal/neden-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](/tr/systems/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](/tr/notes/pgbouncer-auth-query) ile bağlantı havuzu. Redis cache, session ve lock taşıyor. Bu kurulum, veritabanı birkaç on GB'ta kaldığı ve 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:

```sql
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 artık `shared_buffers`'a sığmıyor demektir — ama bu yalnızca birinci katman: `blks_hit` sadece PostgreSQL'in kendi buffer cache'indeki isabetleri sayar, işletim sisteminin page cache'inden dönen bir sayfa burada `blks_read` olarak görünür. Bu yüzden oranı disk read IOPS ile birlikte okuyun: oran düşerken IOPS da sürekli yükseliyorsa working set gerçekten RAM'i taşırıyordur. `pg_statio_user_tables`'ta belirli tablolarda `heap_blks_read`'in `heap_blks_hit`'e yaklaşması bunu hangi tabloların yaptığını gösterir.

**Ne yapılır?** Sırasıyla:

1. **Ö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`'ta `seq_scan` sayısı yüksek olan büyük tabloları bulun.
2. **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.
3. **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:

```php
// 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:

```sql
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:

```sql
CREATE TABLE events (
    id          bigserial,
    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_at` filtreli sorgular sadece ilgili partition'lara dokunur.
- **Ucuz silme** — eski veriyi atmak `DELETE` değ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 üçü:

1. **Yazma amplifikasyonunu azaltın.** Her index'in her `INSERT`/`UPDATE`'e bir maliyeti vardır. `pg_stat_user_indexes`'ta `idx_scan = 0` olan index'leri — yani hiç kullanılmayanları — silin. Sık güncellenen tablolarda `fillfactor`'ı düşürerek HOT update oranını artırın. Tek tek `INSERT` yerine batch insert yapın.
2. **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.
3. **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ı noktalar 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](/tr/systems/tek-vps-coklu-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](/tr/notes/pgbackrest-postgresql-backup-stratejisi) 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.

## Sık sorulanlar

**Read replica yazma yüküne yardım eder mi?**

Hayır. Aynı yazmalar her replica'da yeniden oynanır; replica yazma kapasitesine hiçbir şey katmaz. Yalnızca okuma yükü kırılma noktasını çözer. İkisini karıştırmak bu listedeki en pahalı yanlış kırılma noktasıdır.

**Partition'lı bir tabloda id tek başına primary key olabilir mi?**

Hayır. Partition'lı bir tabloda kısıtın kolonları partition key'in bütün kolonlarını içermek zorundadır; occurred_at üzerinde RANGE partition'da primary key (id, occurred_at) olur. Bu, başka tablolardan bu tabloya foreign key vermeyi zorlaştırır; append-only log tablolarında bilerek kabul edilen bir ödün.


## Kaynaklar

- [Designing Data-Intensive Applications, Chapter 1: data-intensive vs. compute-intensive](https://dataintensive.net/) — Martin Kleppmann, O'Reilly Media, 2017
- [Monitoring Database Activity: the statistics views](https://www.postgresql.org/docs/current/monitoring-stats.html) — PostgreSQL
- [Table Partitioning: declarative partitioning, pruning and constraint limitations](https://www.postgresql.org/docs/current/ddl-partitioning.html) — PostgreSQL
- [Log-Shipping Standby Servers: streaming replication](https://www.postgresql.org/docs/current/warm-standby.html) — PostgreSQL
