Mert Özen Her Satırda Daha İleri
Veritabanı Veritabanı Performansı ve Scaling — #10

Uçtan Uca: Bookstore'u 10 Kat Büyümeye Hazırlamak — Ölç, Karar Ver, Uygula, Doğrula

Mert Özen 14 Kas 2026 12 dk 13 görüntülenme
Uçtan Uca: Bookstore'u 10 Kat Büyümeye Hazırlamak — Ölç, Karar Ver, Uygula, Doğrula

Bookstore'u 10 kat büyümeye hazırlamak sharding değil; index, connection bütçesi, arşiv, read replica ve partitioning'in ölçülerek sırayla eklenmesi. Kampanya yükünde "Siparişlerim" p99'u 4,0 ms kaldı (hedef 50 ms); yük altında primary öldürülüp replica promote edildiğinde yazmalar 7,1 saniye durdu, ama hiçbir istek hata vermedi ve onaylanmış 3.970 siparişin hepsi korundu. Basamakların kazancı ve bedeli, ADR, failover ve restore tatbikatı, kontrol listesi.

Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (iki container: primary ve async streaming replica), .NET 10, Npgsql 10.0.3 ve Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ile tek makinelik bir lab'da çalıştırılmıştır. Önceki yazıların SQL Server ölçümleri SQL Server 2025 (CU10) üzerindeydi. Yük testi lab ölçeğinde bir doğrulama; gerçek kapasite testinin yerini tutmaz.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için seri boyunca kurduğumuz yapının son hâli şu: bir PostgreSQL primary, bir async streaming replica, okumaları lag'e ve kullanıcının son yazmasına bakarak replica'ya ya da primary'ye gönderen bir router (06), failover'ı bekleyen bir retry strategy ve idempotency key'li sipariş yazma (05). Bir kampanya gününü taklit eden bir yük altında (saniyede yaklaşık 75 sipariş ve 1.300 "Siparişlerim" okuması) 20. saniyede primary'yi docker kill ile öldürüyoruz, 6 saniye sonra replica'yı promote ediyoruz.

Tahmin et: bu 60 saniyede kaç sipariş hata alır, kaç okuma hata verir, müşteriye "siparişiniz alındı" denmiş kaç sipariş kaybolur?

window   | writes ok/err | reads ok/err | replica% | read p99 ms | write p99 ms
  0-  5s |    361/0     |   6214/0     |     100% |         9.9 |        144.5
  5- 10s |    385/0     |   6752/0     |     100% |         3.3 |          5.0
 10- 15s |    383/0     |   6565/0     |     100% |         3.9 |          8.2
 15- 20s |    360/0     |   6630/0     |     100% |         6.6 |          8.0
 20- 25s |      0/0     |   6780/0     |     100% |         3.7 |          0.0
 25- 30s |    248/0     |   6917/0     |      24% |         2.8 |       7050.2
 30- 35s |    383/0     |   6836/0     |       0% |         5.9 |         22.6
 35- 40s |    384/0     |   6868/0     |       0% |         4.6 |          5.3
 40- 45s |    384/0     |   6837/0     |       0% |         6.3 |         22.6
 45- 50s |    377/0     |   6760/0     |       0% |         7.4 |         25.0
 50- 55s |    379/0     |   6520/0     |       0% |         6.6 |         16.0
 55- 60s |    326/0     |   5323/0     |       0% |        18.9 |         34.7
total: writes ok=3970 failed=0, reads ok=79002 failed=0, read p99=6.5 ms
longest gap between committed writes: 7.1 s
acknowledged orders: 3970, found on the current primary: 3970, lost: 0

Sıfır, sıfır, sıfır. Primary öldüğünde yazmalar 7,1 saniye durdu ama hiçbiri hata dönmedi; retry'lar yeni primary'yi bekledi. Okumalar hiç durmadı: primary yokken replica okunmaya devam etti, promote'tan sonra router okumaları yeni primary'ye aldı. 3.970 onaylanmış siparişin hepsi yeni primary'de. Bu sonucu 05'teki ilk denemeyle (98 hatalı sipariş, 50 kayıp) karşılaştırınca serinin ne inşa ettiği görünüyor. Bu yazıda bütün adımları baştan sona bağlıyoruz: başlangıç ölçümleri, hedef, her basamağın kazancı ve maliyeti, kararların kaydı ve aynı metriklerle doğrulama.

Başlangıç Noktası: Ölçümler ve Hedef

01'de üç sayıyla başlamıştık. Son tam çeyrekte günde 1.290 sipariş; ürün ekibinin hedefi bir yılda 10 kat, yani günde yaklaşık 12.900 sipariş. Sipariş başına index'leriyle birlikte yaklaşık 653 byte, yani 10 kat büyümede yılda yaklaşık 3,1 GB yeni veri. Ve ölçülebilir bir hedef: SLO (Service Level Objective; servisin belirli bir pencerede tutturmayı hedeflediği performans değeri) "Siparişlerim sorgusu, 5 dakikalık pencerelerde p99'da 50 ms'nin altında, kampanya günü tepe yükünde de".

O gün "Siparişlerim" sorgusu index'siz çalışıyordu: 64 client ile saniyede 186,9 istek, p99 653 ms, hedefin 13 katı. Veritabanı yavaş değildi; bir sorgu yanlıştı. Merdivenin ilk basamağı her zaman bu: ölçmeden önce ölçeklemeye karar verme.

Basamak Basamak: Kazanç ve Maliyet

Her yazıda bir basamak çıktık ve kazancı aynı lab'da ölçtük:

BasamakYazıLab'daki etkisiBedeli
Sorgu ve index01p99 653 ms'den 5,6 ms'ye; SQL Server'da 6.520 yerine 4 logical readHer yazmada index bakımı
Connection pooling ve PgBouncer02250 doğrudan client: 53.300 hata; PgBouncer ile 33.450 istek/sn, sıfır hataTransaction mode'da session state tuzakları
Batch'li arşiv03Tek transaction: 35 MB log ve tablo lock'u; batch: blocking yok, replica en fazla 1,1 MB gerideJob, checkpoint tablosu, izleme
Replica04Sync commit ile tps %37 düştü; async replica'da kayıp riskiRPO (Recovery Point Objective; kabul edilen en fazla veri kaybı) ile gecikme arasında seçim, replication slot'ları
Failover'a dayanıklı uygulama0598 hatalı sipariş sıfıra indi; 491 siparişin hepsi tekilRetry strategy ve idempotency key
Read/write splitting06Replay durunca 300/300 kaçan sipariş, router ile sıfırİki pool; primary bütün okumayı taşıyabilmeli
Partitioning ve arşiv07, 08Ay başına 40 ms (PostgreSQL) ve 10 ms (SQL Server) altında arşivPartition key'siz sorgular 7–32 kat fazla okuma
Sharding09GerekmediCross-shard sorgu, global ID, operasyon yükü

Sıra önemli. Index tek bir komuttu ve p99'u 100 kattan fazla düşürdü; sharding aylar sürer ve her sorguyu etkiler. Merdivende bir basamak, ancak bir önceki ölçülmüş bir sınıra ulaştığında çıkılır. Bookstore'un 10 kat büyümesi için gereken basamaklar şunlar oldu: index, pool bütçesi ve PgBouncer, arşiv job'u, bir replica ve okuma router'ı, sipariş tablosunun partitioning'i. 09'daki hesaba göre 10 kat büyümede bile yazma yükü tek bir sunucunun çok altında; sharding bu yolun parçası değil.

Kararları Kaydetmek: ADR

Her basamak bir mimari karar: bir takas seçildi, alternatifler reddedildi, bir bedel kabul edildi. Altı ay sonra "replica'yı neden ekledik, neden sync değil?" sorusunun cevabı bir ADR'da (Architecture Decision Record; bir mimari kararı, bağlamını ve sonuçlarını kaydeden kısa doküman) durmalı. Bookstore'un repo'sunda docs/adr/ altında her basamak için bir kayıt var. 06'nın kararı, Nygard şablonuna yakın bir biçimde:

# 4. Serve "My orders" and catalog reads from a read replica with lag-aware routing

Status: Accepted (2026-11)

## Context
Orders are expected to grow 10x in a year (1,290/day -> ~12,900/day). SLO: the "My orders" database
query stays under 50 ms at p99 in 5-minute windows, also at campaign peaks. Reads are ~95% of
database calls. The primary also serves checkout, payments and stock, and must keep headroom for them.

## Decision
- Add one async streaming replica; use it for reads that tolerate a few seconds of staleness.
- BookstoreReadContext (no tracking, no SaveChanges) on Npgsql "prefer-standby"; writes on "primary".
- ReadRouter sends a read to the primary when the replica is unreachable, lags more than 2 s, or has
  not replayed the user's last write LSN.
- Checkout, payment, stock and "decide-then-write" reads always stay on the primary.

## Consequences
- Read-your-writes holds for the writing user; other users may see data up to 2 s old.
- When the replica falls behind, all reads go to the primary: the primary is sized for the full read load.
- Two pools per API instance: the connection budget counts both against the primary.
- Long reports move to a separate reporting database (logical replication), not to this replica.

## Verification
- 5-minute load at campaign peak: read p99 < 50 ms, 0 errors (see post 10 measurements).
- Quarterly failover drill: write gap and lost acknowledged orders are recorded.

Kayıt kod gibi İngilizce, çünkü kodla aynı repo'da yaşıyor ve pull request'lerde kodla birlikte gözden geçiriliyor. En önemli bölüm "Verification": kararın işe yarayıp yaramadığını hangi ölçümle anlayacağımızı baştan yazıyor. ADR yazmanın ayrıntıları ve şablonlar Architecture Decision'lar serisinin "ADR Yazmak" yazısında.

Doğrulama: SLO'yu Yük Altında Ölçmek

Karar kaydının söz verdiği ölçüm, failover'sız 5 dakikalık bir yük: 4 writer her 50 ms'de bir sipariş veriyor, 16 reader her 10 ms'de bir rastgele bir müşterinin "Siparişlerim" listesini okuyor. Her yazma 05'teki gibi idempotency key'li ve retry'lı, her okuma 06'daki router'dan geçiyor:

await using BookstoreReadContext read = readFactory.Create(null, out target);
await read.Orders
    .Where(o => o.CustomerId == customerId)
    .OrderByDescending(o => o.CreatedAt)
    .Take(20)
    .ToListAsync();
window   | writes ok/err | reads ok/err | replica% | read p99 ms | write p99 ms
  0- 60s |   4568/0     |  80728/0     |     100% |         2.9 |          6.7
 60-120s |   4584/0     |  80484/0     |     100% |         3.4 |          6.5
120-180s |   4560/0     |  79754/0     |     100% |         5.3 |         15.8
180-240s |   4593/0     |  80463/0     |     100% |         3.7 |          6.3
240-300s |   4529/0     |  79657/0     |     100% |         4.4 |         12.0
total: writes ok=22834 failed=0, reads ok=401086 failed=0, read p99=4.0 ms
longest gap between committed writes: 0.1 s
acknowledged orders: 22834, found on the current primary: 22834, lost: 0
pg1 max client connections 21
pg2 max client connections 18

5 dakikalık pencerede okuma p99'u 4,0 ms; hedef 50 ms. Yazma tarafı saniyede yaklaşık 76 sipariş, yani günde 6,5 milyon siparişe denk bir hız; 10 kat hedefin (günde 12.900) yaklaşık 500 katı. Okumaların hepsi replica'dan yapıldı, primary yalnız yazmalarla uğraştı. Connection sayıları 02'deki bütçenin içinde: primary'de en fazla 21, replica'da 18 client connection.

Bu bir lab doğrulaması, kapasite testi değil. Gerçek bir load test, üretim benzeri veri boyutuyla, gerçek trafik karışımıyla (katalog, arama, sepet, checkout, raporlar), açık bir arrival rate ile ve sonuçları coordinated omission'a karşı doğru okuyarak yapılır. Bunların hepsi Performance ve Load Testing serisinde: workload modeling, k6 ile arrival-rate senaryoları ve sonuçların yorumlanması. Bu seriden o teste giden şey üç şey: SLO cümlesi, ölçülecek metrikler (p99 okuma ve yazma süresi, hata sayısı, replica lag, connection sayısı) ve test edilecek arıza senaryoları.

Failover Tatbikatı

Yazının başındaki tablo bir game day'in (planlı arıza tatbikatı) çıktısıydı. Tatbikat 05'te söz verdiğimiz gibi, ölçülebilir sorularla tanımlandı ve her biri ölçüldü:

  • Yazmalar kaç saniye durdu, yani ölçülmüş RTO (Recovery Time Objective; servisin yeniden yazabilir hâle gelme süresi) ne? Ardışık iki başarılı commit arasındaki en uzun boşluk 7,1 saniye: primary'nin ölümünden promote'a kadar geçen 6 saniye artı retry aralığı.
  • Kaç istek hata döndü? Sıfır. Yazmalar retry ile bekledi; o 7 saniyede yazma p99'u 7.050 ms'ye çıktı. Bu, HTTP timeout'u ve kullanıcının beklemesi için üst sınırı belirliyor.
  • Kaç onaylanmış sipariş kayboldu, yani ölçülmüş RPO ne? Sıfır, ama bu şanstı: async replica primary öldüğünde yetişmiş durumdaydı. 05'te replica'nın ağı kopukken 50 sipariş kaybolmuştu; RPO'yu sıfıra garanti etmek sync replication ve en az iki replica ister (04).
  • Okumalar ne yaşadı? Hiç hata yok. Primary yokken replica eski ama tutarlı veriyi sunmaya devam etti; promote'tan sonra router replica'yı bulamadı ve okumaları yeni primary'ye verdi.

Tatbikat bir şeyi daha gösterdi: promote'tan sonra replica kalmadı ve bütün okuma primary'ye bindi (son 30 saniyede replica oranı %0, okuma p99'u 18,9 ms'ye kadar çıktı). Failover'dan sonraki ilk iş yeni bir replica kurmak; bu süre boyunca primary bütün yükü taşımalı. Patroni gibi bir araç eski primary'yi replica olarak geri alır, ama bu lab'da Patroni yoktu ve promote'u elle yaptık.

Restore Tatbikatı

Replica bir backup değil (05): primary'de yanlışlıkla çalışan bir DELETE replica'ya da uygulanır. Tatbikatın ikinci yarısı, backup'tan gerçekten geri dönebildiğimizi göstermek. Yeni primary'den pg_basebackup ile alınan bir base backup'ı boş bir container'a açtık ve sayımları karşılaştırdık:

gosu postgres pg_basebackup -h pg2 -U replicator -D "$PGDATA" -X stream -c fast
restored and accepting connections in 3 s
 in_recovery | orders  | last_order | keyed_orders
-------------+---------+------------+--------------
 f           | 1028272 |    1028286 |        28272

Kaynakta aynı sorgu 1028272 | 1028286 | 28272 döndü. 698 MB'lık bir küme aynı makinede 3 saniyede açıldı; üretimde backup başka bir bölgedeki depodan gelir ve boyut yüzlerce GB'tır, süre saatlere çıkar. Ölçülmesi gereken tam olarak bu süre, çünkü DR'ın (Disaster Recovery; büyük bir felaketten sonra veriyi ve servisi geri getirmek) RTO'su odur. Belirli bir ana dönmek (point-in-time recovery) için base backup'a ek olarak sürekli WAL (Write-Ahead Log; PostgreSQL'in değişiklikleri önce yazdığı transaction log'u) arşivi gerekir; lab'da WAL arşivi kurmadık. Bookstore'un hedefi 05'te belirlenmişti: DR'da RPO beş dakika, RTO bir saat. Restore tatbikatı bu hedefin gerçekçi olup olmadığını her çeyrek ölçmeli.

Kontrol Listesi

Bookstore'un 10 kat büyümeye hazır olduğunu söylemeden önce kontrol ettiğimiz maddeler:

  • Connection bütçesi: instance sayısı × pool boyutu × pool sayısı (yazma ve okuma) primary'nin max_connections'ının altında; HPA'nın (Horizontal Pod Autoscaler; Kubernetes'te pod sayısını yüke göre değiştiren bileşen) en fazla pod sayısıyla hesaplandı (02).
  • Replica lag alarmı: lag 2 saniyeyi aştığında router primary'ye dönüyor ve alarm çalıyor; "replica'dan yapılan okuma oranı" da izleniyor (04, 06).
  • Kullanılmayan replication slot alarmı ve max_slot_wal_keep_size (04).
  • Partition bakım job'u: önümüzdeki 3 ay açık; en ileri partition 2 aydan yakınsa alarm; yarım kalan detach kontrolü; parent için ANALYZE ONLY (07, 08).
  • Arşiv retention'ı: 3 yıldan eski aylar arşivde; arşivin saklama süresi hukuk ekibiyle yazılı (03, 07, 08).
  • Retry ve idempotency: bütün yazma endpoint'leri idempotency key alıyor; retry strategy failover sırasındaki hataları tanıyor; HTTP timeout retry süresinden uzun (05).
  • Failover ve restore tatbikatı: her çeyrek, kampanya döneminden önce; ölçülen RTO ve RPO kayda geçiyor.
  • Her basamak için bir ADR ve içinde doğrulama ölçümü.

Üretimde Dikkat

Ölçümü bırakma. Bu yazıdaki SLO ölçümü bir kerelik bir test değil, üretimde 5 dakikalık pencerelerle sürekli hesaplanan bir metric olmalı. 01'deki gibi sorgu bazında süre ve wait dağılımı, connection sayısı ve replica lag aynı dashboard'da durursa bir sonraki basamağın zamanı geldiğinde veriler hazır olur.

Merdivenin bir sonraki basamağını ölçümle belirle. Primary'nin CPU'su, WAL üretimi, disk yazma gecikmesi ve en büyük tabloların büyüme hızı izleniyorsa vertical scaling'in, ikinci bir replica'nın ya da sharding'in zamanını tahminle değil trendle bulursun.

Tatbikatı gerçek bileşenlerle yap. Lab'da failover'ı elle tetikledik; üretimde Patroni, SQL Server'ın AG (availability group; replica'ları tek grup olarak yöneten yüksek erişilebilirlik özelliği) listener'ı, DNS kayıtlarının TTL'i (Time To Live; kaydın cache'te tutulma süresi), load balancer health check'leri ve alarmlar da tatbikatın parçası. Hepsinin birlikte çalıştığı ancak gerçek bir tatbikatta görülür.

Kampanyayı önceden planla: büyük bir kampanyadan önce pool'ların minimum boyutu, cache'in önceden doldurulması ve replica sayısı yük testinin sonucuna göre ayarlanmalı.

Sık Düşülen Tuzaklar

Ölçmeden basamak çıkmak: 01'deki "veritabanı yavaş" şikâyetinin cevabı tek bir index'ti. Sharding ya da daha büyük bir sunucu aynı sorunu daha pahalıya çözerdi.

Ortalamaya bakmak: SLO p99 üzerinden tanımlı; failover'lı tatbikatta okuma ortalaması neredeyse hiç değişmedi, ama yazma p99'u 7 saniyeye çıktı.

Async replica ile sıfır kayıp sanmak: bu tatbikatta kayıp olmadı, 05'te 50 sipariş kayboldu. Fark replica'nın o anki durumuydu, mimari değil.

Kararları kaydetmemek: replica'nın ve router'ın neden böyle kurulduğu yazılı değilse, ilk büyük lag'de biri okumaları "geçici olarak" hep primary'ye alır ve kimse geri çevirmez.

Yalnız failover'ı test edip restore'u test etmemek: replica yanlışlıkla silinen veriyi kurtarmaz; restore süresi bilinmeden DR hedefi bir tahmindir.

Küçük Bir Deneme

★☆☆ Drill'i synchronous_standby_names = 'pg2' ile tekrarla. Normal dönemde yazma p99'u ne kadar artıyor, failover sırasında yazma boşluğu değişiyor mu?

★★☆ Tatbikata ikinci bir replica ekle ve promote sırasında okumaların primary'ye düşmek yerine kalan replica'ya gitmesini sağla. Son 30 saniyedeki okuma p99'unu ve primary'deki connection sayısını karşılaştır.

★★★ Bookstore için bir WAL arşivi kur (archive_mode ve archive_command ile bir klasöre), bir DELETE hatası yap ve point-in-time recovery ile hatadan bir saniye öncesine dön. Ölçtüğün RTO'yu 05'teki DR hedefiyle (bir saat) karşılaştır.

Seri burada bitiyor. Bookstore'un veritabanını ölçerek başladık, merdivenin basamaklarını tek tek çıktık ve her birinin bedelini aynı lab'da ödedik. Bir sonraki adım bu serinin dışında: yük testinin ayrıntıları Performance ve Load Testing serisinde, replication ve consistency'nin teorisi Distributed Systems Temelleri serisinde, sistem seviyesindeki kararlar System Design serisinde.