İLETİŞİME GEÇİN
pg_stat_statements ve EXPLAIN ile ölçümden doğru indekse, N+1 sorgulardan keyset sayfalamaya ve vacuum ayarlarına: Yavaş PostgreSQL sorgularını hızlandırmanın 7 yolu.

Veritabanı performansı çoğu zaman tek bir sihirli ayarla değil, doğru sorunun doğru araçla bulunmasıyla düzelir. Yavaş bir ekranın arkasında bazen eksik bir indeks, bazen bir ORM'in sessizce ürettiği yüzlerce sorgu, bazen de güncelliğini yitirmiş istatistikler vardır. Bu yazıda PostgreSQL'de üretim sorgularını hızlandırmak için yedi tekniği, en sık karşılaşılan hatalarla birlikte ele alıyoruz. Örnekler PostgreSQL 16 ve sonrası düşünülerek yazıldı; çoğu daha eski sürümlerde de geçerlidir.
Hangi sorgunun optimize edileceğine tahminle karar vermek, zamanın en kolay boşa harcandığı yerdir. pg_stat_statements eklentisi, sunucuda çalışan sorguları normalize ederek çağrı sayısı ve toplam süreyle birlikte kaydeder. Eklentinin shared_preload_libraries ayarına eklenmesi ve sunucunun yeniden başlatılması gerekir.
SELECT query, calls,
round(total_exec_time) AS toplam_ms,
round(mean_exec_time, 1) AS ortalama_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Toplam süreye göre sıralamak önemlidir: Tek seferde 20 milisaniye süren ama dakikada binlerce kez çağrılan bir sorgu, günde bir kez çalışan yavaş bir rapordan daha fazla yük oluşturabilir.
Hedef sorgu belirlendikten sonra EXPLAIN (ANALYZE, BUFFERS) ile gerçek çalışma planına bakın. PostgreSQL 18'den itibaren BUFFERS bilgisi ANALYZE ile birlikte varsayılan olarak gelir. Planı okurken öncelikle şunlara dikkat edin:
rows= tahmini ile actual rows arasında büyük fark varsa planlayıcı yanlış bilgiyle karar veriyordur.loops değeri: İç içe döngüde binlerce kez tekrarlanan bir adım, küçük görünen maliyeti katlayabilir.Sort Method: external merge gibi ifadeler, sıralamanın belleğe sığmadığını gösterir.Unutmayın: EXPLAIN ANALYZE sorguyu gerçekten çalıştırır. UPDATE veya DELETE içeren sorguları bir transaction içinde deneyip ROLLBACK ile geri alın.
Varsayılan B-tree indeksi eşitlik, aralık ve sıralama sorgularının çoğunu karşılar. Ancak indeksin nasıl kurulduğu, kullanılıp kullanılmayacağını belirler:
(musteri_id, olusturma_tarihi) indeksi "bir müşterinin son siparişleri" sorgusuna birebir uyar.INCLUDE ile eklenen sütunlar sayesinde sorgu tabloya hiç gitmeden yalnız indeksten cevaplanabilir (index-only scan).lower(email) ile arama yapılıyorsa indeks de aynı ifade üzerine kurulmalıdır.CREATE INDEX CONCURRENTLY siparis_acik_idx
ON siparisler (musteri_id, olusturma_tarihi DESC)
INCLUDE (toplam_tutar)
WHERE durum = 'acik';
Özel durumlar için diğer indeks türleri vardır: jsonb, dizi ve tam metin araması için GIN; zamana göre sıralı eklenen çok büyük tablolar (log, sensör verisi) için çok küçük yer kaplayan BRIN. Üretimde indeks eklerken CONCURRENTLY kullanmak, tabloya yazmayı kilitlemeden indeks oluşturur. Her indeksin yazma işlemlerine ek maliyet getirdiğini de unutmayın; pg_stat_user_indexes görünümünde hiç kullanılmayan indeksleri tespit edip kaldırabilirsiniz.
Doğru indeks var olduğu hâlde planlayıcı onu kullanamıyorsa, sorun genellikle sorgunun yazılışındadır:
WHERE date(olusturma_tarihi) = '2026-01-15' yerine aralık kullanın: WHERE olusturma_tarihi >= '2026-01-15' AND olusturma_tarihi < '2026-01-16'.LIKE '%kumaş%' B-tree indeksini kullanamaz. Bu tür aramalar için pg_trgm eklentisi ve GIN indeksi uygundur.SELECT * hem ağ trafiğini artırır hem de index-only scan imkânını ortadan kaldırır.ORM kullanan uygulamalarda en yaygın performans sorunu, tek tek hızlı ama sayıca çok olan sorgulardır. Yüz siparişi listeleyip her biri için müşteriyi ayrı sorguyla çekmek, veritabanına yüz bir istek gönderir. Her istek ağ gidiş dönüşü ve sorgu hazırlığı maliyeti taşır. Çözüm, ilişkili veriyi tek seferde almaktır: ya bir JOIN ile, ya da kimlikleri bir dizi olarak göndererek.
SELECT id, ad, telefon
FROM musteriler
WHERE id = ANY($1::bigint[]);
Çoğu ORM'in bunun için bir "eager loading" seçeneği vardır. Geliştirme ortamında sorgu loglarını açık tutmak, N+1 desenini ekrana bakarken fark etmenin en kolay yoludur.
OFFSET 50000 LIMIT 50 ifadesi PostgreSQL'in ilk 50.000 satırı okuyup atmasını gerektirir; sayfa numarası büyüdükçe sorgu yavaşlar. Keyset (seek) sayfalamada ise son görülen kaydın değerinden devam edilir ve her sayfa aynı hızda gelir:
SELECT id, olusturma_tarihi, toplam_tutar
FROM siparisler
WHERE (olusturma_tarihi, id) < ($1, $2)
ORDER BY olusturma_tarihi DESC, id DESC
LIMIT 50;
Bu sorgunun hızlı olması için (olusturma_tarihi, id) üzerinde bir indeks gerekir. Bedeli, "doğrudan 400. sayfaya git" özelliğinin kaybolmasıdır; sonsuz kaydırma ve "daha fazla yükle" arayüzlerinde bu genellikle sorun değildir.
Planlayıcı, tablo istatistiklerine bakarak karar verir. Büyük bir veri yüklemesinden sonra istatistikler güncellenmemişse planlayıcı yanlış plan seçebilir; böyle durumlarda ANALYZE tablo_adi; çalıştırmak yeterlidir.
PostgreSQL'de güncellenen ve silinen satırlar hemen diskten kalkmaz; autovacuum bu ölü satırları temizler. Çok sık güncellenen büyük tablolarda varsayılan eşikler geç kalabilir ve tablo şişer. Bu durumda tablo bazında ayar yapılabilir:
ALTER TABLE stok_hareketleri
SET (autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01);
Birbiriyle ilişkili sütunlarda (örneğin il ve ilçe) planlayıcı, koşulları bağımsız sayarak satır sayısını olduğundan düşük tahmin edebilir. CREATE STATISTICS ile tanımlanan genişletilmiş istatistikler bu ilişkiyi planlayıcıya öğretir.
PostgreSQL her bağlantı için ayrı bir süreç açar; yüzlerce boşta bekleyen bağlantı bile bellek ve yönetim yükü getirir. Uygulama katmanında bir bağlantı havuzu kullanmak temel gerekliliktir. Çok sayıda uygulama örneği veya serverless fonksiyon aynı veritabanına bağlanıyorsa PgBouncer gibi harici bir havuzlayıcı eklemek gerekir. Transaction pooling modunda oturum düzeyindeki özelliklerin (ör. SET ile yapılan ayarlar, advisory lock'lar) beklendiği gibi çalışmayabileceğini, prepared statement desteğinin de PgBouncer sürümüne ve yapılandırmasına bağlı olduğunu göz önünde bulundurun.
work_mem, her sıralama ve hash işlemi için ayrılabilecek bellek miktarıdır. Bir sorgu birden fazla böyle işlem içerebildiği ve birçok bağlantı aynı anda çalışabildiği için bu değeri global olarak büyük tutmak bellek tükenmesine yol açabilir. Daha güvenli yaklaşım, global değeri makul tutup ağır raporlar için oturum veya transaction bazında yükseltmektir:
BEGIN;
SET LOCAL work_mem = '256MB';
-- ağır rapor sorgusu
COMMIT;
Bu yedi tekniğin ortak noktası, her birinin bir ölçümle başlamasıdır. pg_stat_statements nereye bakılacağını, EXPLAIN neden yavaş olduğunu, değişiklikten sonraki ikinci ölçüm de gerçekten düzelip düzelmediğini gösterir. TeksERP ve Plasiyo gibi PostgreSQL üzerinde çalışan ürünlerde de aynı döngüyü izliyoruz: Önce ölç, sonra tek bir değişiklik yap, sonra yeniden ölç.
Yorumlar yakında etkinleştirilecek.

LCP, INP ve CLS neyi ölçer, eşikleri nedir ve üretimde nasıl iyileştirilir? Saha ve laboratuvar ölçümünden performans bütçesine pratik bir rehber.

Request ve limit'lerden probe'lara, Pod Security Standards'tan NetworkPolicy ve HPA/VPA'ya: Bir workload'u üretime hazır hâle getiren Kubernetes ayarları.

Server Components, paralel veri yükleme, Suspense ile streaming, önbellek kararları ve Server Actions: App Router ile performans odaklı bir Next.js mimarisi nasıl kurulur?