Postgres 100M+ satır performansı: 7 prensip
100M+ satırda 'index ekle' tavsiyesi yetmiyor. Üç ürünümüzden damıtılmış 7 saha prensibi: indeks, partitioning, vacuum, pooling, MV, statistics, monitoring.
Postgres tablolarınızdan biri 100 milyona yaklaştıysa ve klasik tavsiyeler tükendiyse bu yazı, üç ürünümüzden damıtılmış 7 prensibi paylaşıyor. Postgres’in “öylece ölçeklendiği” söylemi, ürünlerimizin üçünün de tek bir tabloda 100 milyonu aşan satıra ulaştığı andan itibaren çatlamaya başladı. Tek başına Postgres ölçekleniyor — ama doğru indeks, doğru partitioning, doğru vacuum tuning ve doğru bağlantı yönetimi yoksa ölçek noktası 50-80 milyon civarında bir duvara çarpıyor. O duvarın ardındaki dünya, klasik “EXPLAIN ANALYZE çıktısını oku, eksik indeksi ekle” tavsiyesinin yetmediği yer.
Bu yazıda, müşterilerimizden ve kendi ürünlerimizden çıkardığımız yedi prensibi paylaşıyoruz. Her biri tek başına ele alındığında bilinen şeyler — ama bir arada uygulanmadıkça 100M+ satırda istediğiniz performansı vermiyor. Yazı boyunca somut Postgres ayarları, gerçek üretim hikâyeleri ve hangi prensibi hangi ölçekte devreye almanız gerektiğine dair pratik eşikler veriyoruz. Her prensip kendi başına bir yazı olabilirdi; burada sahada en sık dönüş aldığımız 20-30 cümlelik özetlerini bırakıyoruz.
Postgres üzerinde tenant izolasyonunu üç farklı desende çözdüğümüz multi-tenant SaaS yazımızda bu prensiplerin bazılarına dokunmuştuk; bu yazı tek tablo ölçeğinde derinleşiyor.
Prensip 1: Index seçimi — B-tree dışına çıkmak
100M satıra varan tablolarda B-tree her zaman doğru indeks olmaktan çıkıyor. Üç alternatifi disiplinle değerlendirmek gerekiyor:
B-tree. Eşitlik ve aralık sorgularında varsayılan. PK, FK ve sık WHERE kullanılan kolonlar için doğru. Ama B-tree disk maliyeti satır sayısına orantılı; 100M satırın B-tree indeksi 5-15 GB seviyesine çıkar. Bu da bellekte tutamayacağınız anlamına gelir; lookup’lar disk I/O’ya düşer.
BRIN (Block Range INdex). Sıralı veriler için mükemmel — özellikle zaman serisi tabloları. Tablonun fiziksel sırası ile sorguladığınız kolonun sırası uyumluysa, BRIN indeksi tipik olarak B-tree’nin %1-2’si büyüklüğünde olur. 100M satırlık bir log tablosunda B-tree 12 GB iken BRIN 80-150 MB; üçüncü tarafların bahsetmediği büyük kazanım. Tek koşul: tablo fiziksel olarak kolona göre sıralı (zaman serisi otomatik öyledir).
GIN. Full-text search, jsonb ve array sütunları için. Postgres’in tsvector ve jsonb_path_ops operator class’ı GIN indeksiyle birlikte 100M+ satırda saniyenin altında lookup veriyor. B-tree bu kolon tipleri için kullanılamıyor; GIN dışı bir alternatif yok.
Partial index. Toplam tablonun %5-15’inde sorgu yapılıyorsa partial indeks (WHERE status = 'active' gibi) tüm tabloya bakan B-tree’den 5-10 kat hızlı. Disk maliyeti de orantılı düşüyor. Müşterilerimizin yarısında “neden tüm satırları indeksleyelim ki” sorusu sorulduğunda partial indeksle %70-80 disk tasarrufu çıktı.
Saha kuralı: 100M satırı geçtiğinizde mevcut B-tree indekslerinizin yarısı yanlış seçim oluyor. Audit’inizin ilk haftasında pg_stat_user_indexes üzerinden idx_scan sayısı düşük olan indeksleri çıkarın — kullanılmayan indeksler INSERT/UPDATE’i yavaşlatıyor.
Prensip 2: Partitioning stratejisi
100M satır eşiğinin üstünde tek tablo yönetimi pratik olmaktan çıkıyor. VACUUM süreleri uzar, indeks bakımı pahalılaşır, query planner kararsız kalır. Çözüm partitioning — ama hangi stratejinin doğru olduğu tabloya göre değişir.
Range partitioning. Zaman serisi tabloları için kanonik seçim. Aylık (ya da haftalık) partition’lar, hot/cold ayrımını fiziksel hale getiriyor. Eski ay partition’larına BRIN indeksi + read-only flag verirken, sıcak partition’a B-tree indeks + yüksek autovacuum tuning uygulayabiliyorsunuz. pg_partman extension’ı partition oluşturma ve düşürmeyi otomatize ediyor; manuel yapmaktansa kurulu hale getirin.
List partitioning. Tenant ID, region ya da kategori bazlı ayrım için. 50-200 tenant’lı bir SaaS’ta tenant bazlı list partitioning, tek tenant’ın query’lerinin diğerlerini etkilememesini sağlıyor. 200+ tenant’ta fiziksel partition sayısı yönetilemez hale gelir; o noktada hash partitioning’e dönmek gerekir.
Hash partitioning. Eşit dağılımlı yazma yükü için. Postgres’in hash partition’ları 8, 16, 32 gibi sayılarda en iyi performansı veriyor. Yüksek concurrency’li INSERT yükünde tek tablonun lock contention’ı dağılır. Karşılığında: bir partition’ı drop etmek (eski veri silmek için) range gibi temiz değil; her partition’ı gözden geçirmek gerekiyor.
Pratik karar: Zaman damgası kolunuz varsa range. Tenant/region izolasyonu ihtiyacınız varsa list (200 altı). Eşit yazma yükü ihtiyacınız varsa hash. İkisi gerekiyorsa range + hash sub-partitioning yapılabiliyor — Postgres 11’den itibaren sub-partitioning destekleniyor.
Prensip 3: VACUUM ve autovacuum tuning
100M satırlı tablolarda autovacuum’un default ayarları yetmiyor. autovacuum_vacuum_scale_factor (default 0.2) demek “tablonun %20’si ölü tuple olmadan VACUUM çalışmasın” anlamına geliyor — 100M satır = 20M ölü tuple sınırı. O sınıra gelmeden tablo zaten yavaşlamış oluyor.
Saha tuning’i:
autovacuum_vacuum_scale_factor = 0.05(büyük tablolar için tablo bazında override)autovacuum_vacuum_threshold = 10000(default 50)autovacuum_vacuum_cost_limit = 2000(default 200, daha agresif vacuum)autovacuum_vacuum_cost_delay = 10ms(default 20ms — Postgres 12+ için)autovacuum_max_workers = 6(default 3, paralel partition’larda)
Tablo bazında override:
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_cost_limit = 2000,
autovacuum_analyze_scale_factor = 0.02
);
Ek olarak: yüksek UPDATE yükü olan tablolarda HOT (Heap-Only Tuple) update ratio’yu izleyin (pg_stat_user_tables). HOT ratio %80’in altındaysa fillfactor’ü düşürün (ALTER TABLE x SET (fillfactor = 80)); update’ler aynı sayfada kalır, indeks bloat azalır.
VACUUM FULL kaçınılması gereken bir araç ama ara sıra gerekiyor: tablo %30’un üstüne bloat’a girmişse pg_repack extension’ı ile online repack yapın. VACUUM FULL exclusive lock alır; üretimde 10-20 dakika downtime demek.
Prensip 4: Connection pooling — PgBouncer transaction mode
Postgres her bağlantı için bir process açar; default max_connections = 100. Application tier’ı 200-500 connection açmaya başlayınca process patlaması başlıyor; her process 5-10 MB RAM tüketir, context switch maliyetleri artar.
Çözüm: PgBouncer (ya da PgCat) önünde transaction-mode pool. PgBouncer client’ı hızlı kabul eder, sadece transaction süresince Postgres bağlantısı tutar. Tipik kazanım: 500 application connection, 25-40 Postgres connection ile karşılanıyor.
Transaction mode’un kuralları:
- Session-level state kullanılamaz (
SET LOCALevet,SEThayır). - Prepared statement support’a dikkat (PgBouncer 1.21+ destek veriyor; eskisi sorunlu).
LISTEN/NOTIFYçalışmaz; replikaya geçmek gerekiyorsa application tier’da farklı kanal.
Pratik konfigürasyon:
pool_mode = transaction
default_pool_size = 25
reserve_pool_size = 5
max_client_conn = 1000
server_reset_query = DISCARD ALL
Üç müşterimizde PgBouncer’a geçiş, p95 latency’yi %30-45 düşürdü; sebep Postgres CPU’sunun connection açma kapama yerine query çalıştırmaya gitmesi. Bu kazanım, discovery call’da getirilmesi gereken bilgilerin içinde “mevcut connection sayısı” sorusunun neden kritik olduğunu açıklıyor.
Prensip 5: Materialised views vs CDC + downstream
Ağır analytical query’leri OLTP tablosundan çekmek 100M+ satırda iki seçeneğe dayanıyor: materialised view ya da CDC ile downstream’e ayrı bir analytical store.
Materialised view. Aynı Postgres içinde, REFRESH MATERIALIZED VIEW CONCURRENTLY ile saatte/günde yenilenir. Avantaj: tek sistem, ayrı altyapı yok. Dezavantaj: refresh maliyetli (100M satırdan çekilen view 5-15 dakika sürer); refresh sırasında query plan’ı pratik. CONCURRENTLY ile read’lere block atmaz, ama daha yavaş.
Ne zaman seçilir: Refresh aralığı 1+ saatse, downstream query frekansı düşükse, ekibinizde ayrı bir analytical store besleme kapasitesi yoksa.
CDC + downstream. Debezium ile Postgres logical replication slot’undan değişiklikleri yakalar; Kafka/Pulsar üzerinden Snowflake/BigQuery/ClickHouse’a yazılır. OLTP tablosu tamamen rahat kalır. Avantaj: real-time analytical, infinite scale, OLTP’yi etkilemez. Dezavantaj: ayrı altyapı (Debezium + Kafka + warehouse), ekip kapasitesi gerekiyor.
Ne zaman seçilir: Real-time dashboard ihtiyacı varsa, downstream query frekansı yüksekse, mühendislik ekibi 2+ kişiyse.
Saha gözlemi: 100-500M satır arasındaki tabloların yarısında materialised view yetiyor; 500M üzeri tablolarda CDC + downstream zorunlu hale geliyor. İkisini birden kullanan ekipler de var: hot path’te materialised view, deep analytical’da warehouse.
Prensip 6: Statistics — query planner’ı doğru yemleyin
Postgres’in query planner’ı tablo istatistiklerine bakarak plan üretir. Default istatistik granülaritesi (default_statistics_target = 100) 100M satırda yetersiz. Skewed distribution (örn. tenant ID kolonu çoğunlukla aynı 5-10 tenant’a düşüyor) durumunda yanlış index seçimi sık görülür.
Tuning:
-- Sık sorgulanan kolon için statistics target artır
ALTER TABLE events ALTER COLUMN tenant_id SET STATISTICS 1000;
ALTER TABLE events ALTER COLUMN created_at SET STATISTICS 500;
-- Statistics yenile
ANALYZE events;
Default 100 yerine 500-1000 değerler:
- Histogram bucket sayısı artar; planner skew’u daha iyi anlar.
- Multi-column statistics (
CREATE STATISTICS) iki kolon arasındaki korelasyonu yakalar. - ANALYZE süresi uzar ama 100M satırda saniye mertebesinde, sorun değil.
Multi-column statistics örneği:
CREATE STATISTICS events_tenant_date
ON tenant_id, created_at
FROM events;
Bu, “tenant X her zaman son 30 gün içinde sorgulanıyor” gibi korelasyonları planner’a açar. Üç müşterimizde bu tek satırlık ekleme query planner’ı yanlış indeksten doğru partition’a çevirdi; p99 latency 4-8 saniyeden 200-400 ms’ye düştü.
ANALYZE cadence: Statistics target arttığında manuel ANALYZE’i de düşünmek gerekiyor. Autovacuum’un autoanalyze threshold’u (default %10) 100M satırda 10M değişiklik demek — bu çok geç. Autoanalyze threshold’u tablo bazında %2’ye düşürmek ya da cron ile günlük ANALYZE çalıştırmak iki yol.
Prensip 7: Monitoring — neyi izlediğinizi bilmek
Tuning’i ölçmeden yapamazsınız. Postgres’in built-in extension’ları üç boyutta görünürlük veriyor:
pg_stat_statements. Hangi query’ler ne kadar zaman alıyor. p95/p99 outlier’ları hangi query pattern’i, ortalama row sayısı, cache hit ratio. 100M satırlı tabloda en yavaş 20 query’nin %80 oranı izlenmeli. pg_stat_statements_reset() ile haftalık baseline alınmalı.
pg_stat_user_indexes ve pg_stat_user_tables. Hangi indeks kullanılıyor, hangi tabloda ne kadar seq scan oluyor, dead tuple oranı, last_vacuum/last_analyze zamanları. Aylık audit’te idx_scan = 0 olan indeksler dropping kandidat’ı.
pg_stat_io (Postgres 16+). Hangi I/O nereye gidiyor — relation read’leri mi, WAL write’leri mi, autovacuum mı? Sadece tablo seviyesinde değil, I/O pattern seviyesinde görünürlük. Disk I/O bottleneck’i araştırırken altın madeni.
Ek olarak: auto_explain extension’ı yavaş query’leri otomatik EXPLAIN ile loglar. auto_explain.log_min_duration = 500ms ayarı 500 ms’in üstündeki query’lerin planını günlüğe alır; sonradan analiz için altın değerinde.
Eksternal monitoring (Datadog, Grafana, pganalyze) bu metrikleri zaman serisi haline getirir; alerting kurmak için zorunlu. p95 latency, replication lag, dead tuple ratio, cache hit ratio dört temel alarm. Bunu nasıl bir post-launch CWV monitoring disiplinine bağladığımızı ayrıca yazmıştık.
Hepsini bir araya getirmek
Yedi prensip tek başına ele alındığında klişe; bir arada uygulandığında 100M+ satırın altında yaşayan ekipler için fark yaratıyor. Pratik sırasını verelim:
- Önce monitoring kur (Prensip 7). Neyin yavaş olduğunu bilmeden tuning yapamazsın.
- Index audit yap (Prensip 1). Kullanılmayan indeksleri düşür, partial ve BRIN fırsatlarını çıkar.
- Statistics tuning (Prensip 6). Statistics target ve multi-column statistics. Query planner doğru yemlenmeden index optimization da yarım.
- Connection pooling (Prensip 4). PgBouncer transaction mode. Process patlamasını çöz.
- Vacuum tuning (Prensip 3). Tablo bazında scale factor ve cost limit override.
- Partitioning (Prensip 2). 200M satır eşiğine yaklaşırken kaçınılmaz.
- CDC veya materialised view (Prensip 5). Analytical workload OLTP’yi rahatsız ettiğinde.
İlk üç prensip 1-2 hafta, sonraki dördü 4-12 hafta sürer. 100M+ satırın altındaki ekiplerle çalışırken bu sırayı playbook olarak kullanıyoruz; mühendislik kapasitesi düşük müşterilerde enterprise sistemler pillar sayfamızda anlattığımız operasyonel patron’lara yaslanıyoruz.
Kapanış
Postgres “ölçeklenir” demek doğru — ama ölçeklenmesi için yedi tuning kararının verilmesi gerekiyor. Bu kararların tümü well-known, hiçbiri rocket science değil. Zorluk, hangisini ne zaman ve hangi sırayla devreye alacağınızı ayırt etmek; bu da deneyim sorusu. 100M+ satıra ulaştığınızda klasik tavsiyelerin işlemediği yerde sıkışıyorsanız, keşif görüşmesi için bize ulaşın — [email protected]. Kendi audit checklist’imizi paylaşıp ilk haftada hangi üç-dört kararı alacağınızı birlikte belirliyoruz.