PostgreSQL'de doğru index stratejisi, sorgu performansını saniyelerden milisaniyelere indirebilir. Ama yanlış veya gereksiz index'ler write performansını düşürür ve disk alanı tüketir. Bu derste EXPLAIN ANALYZE'dan partial index'e kadar PostgreSQL optimizasyonunun tüm araçlarını öğreneceksin.
EXPLAIN ANALYZE: Sorgu Planını Oku
// SQL //
EXPLAIN ANALYZESELECT u.name, COUNT(p.id) AS post_countFROM users uLEFT JOIN posts p ON p.user_id = u.idWHERE u.created_at > '2025-01-01'GROUP BY u.id, u.nameORDER BY post_count DESCLIMIT 10;
// PLAINTEXT //
Limit (cost=1543.21..1543.24 rows=10) (actual time=23.451..23.453 ms) -> Sort (cost=1543.21..1556.87) (actual time=23.449..23.450 ms) -> HashAggregate (cost=1283.45..1366.87) (...) -> Hash Left Join (cost=125.00..1200.12) (actual time=5.2..18.3 ms) Hash Cond: (p.user_id = u.id) -> Seq Scan on posts p (cost=0..823.45 rows=30000) ← PROBLEM! -> Hash (cost=100.00..100.00) -> Index Scan on users u (cost=0.29..100.00) Index Cond: (created_at > '2025-01-01')Planning Time: 1.234 msExecution Time: 23.892 ms
Seq Scan = tüm tablo tarandı → index gerekli. Index Scan = index kullanıldı → iyi.
Index Türleri
B-Tree (Varsayılan)
// SQL //
-- Tekil indexCREATE INDEX idx_posts_user_id ON posts(user_id);-- Composite index — sıra önemli-- WHERE user_id = x AND status = 'PUBLISHED' sorgusuna uygunCREATE INDEX idx_posts_user_status ON posts(user_id, status);-- user_id tek başına da bu index'i kullanır (öncü sütun)-- status tek başına KULLANAMAZ-- Descending — ORDER BY published_at DESC içinCREATE INDEX idx_posts_published_desc ON posts(published_at DESC NULLS LAST);
Partial Index
// SQL //
-- Sadece PUBLISHED statuslü postları index'le-- Tablonun %20'si published ise → %80 daha küçük indexCREATE INDEX idx_posts_published ON posts(published_at)WHERE status = 'PUBLISHED';-- Soft delete pattern — deleted_at NULL olmayanları atlaCREATE INDEX idx_users_active ON users(email)WHERE deleted_at IS NULL;-- Bu sorgu partial index kullanır:SELECT * FROM postsWHERE status = 'PUBLISHED' AND published_at > NOW() - INTERVAL '7 days';
GIN Index (Full-Text Search ve JSONB)
// SQL //
-- Full-text searchALTER TABLE posts ADD COLUMN search_vector tsvector;UPDATE posts SET search_vector = to_tsvector('turkish', title || ' ' || body);CREATE INDEX idx_posts_fts ON posts USING GIN(search_vector);-- Arama sorgusuSELECT title, ts_rank(search_vector, query) AS rankFROM posts, to_tsquery('turkish', 'python & veri:*') queryWHERE search_vector @@ queryORDER BY rank DESC;-- JSONB için GINCREATE INDEX idx_posts_meta ON posts USING GIN(meta);-- meta->>'author' = 'Adem' gibi sorgular bu index'i kullanırSELECT * FROM posts WHERE meta @> '{"author": "Adem"}';
Covering Index (INCLUDE)
// SQL //
-- Index + gerekli sütunları birlikte sakla — heap fetch yokCREATE INDEX idx_posts_cover ON posts(user_id, status)INCLUDE (title, published_at, slug);-- Bu sorgu sadece index'i okur, heap'e gitmez (Index Only Scan)SELECT title, published_at, slugFROM postsWHERE user_id = 42 AND status = 'PUBLISHED';
N+1 Problemi: SQL Katmanında Çözüm
// SQL //
-- KÖTÜ: N+1 — her kullanıcı için ayrı sorguSELECT id FROM users WHERE active = true;-- Sonra her user_id için: SELECT * FROM posts WHERE user_id = ?-- İYİ: Tek sorgu, JOIN ileSELECT u.id, u.name, json_agg( json_build_object('id', p.id, 'title', p.title) ORDER BY p.published_at DESC ) FILTER (WHERE p.id IS NOT NULL) AS postsFROM users uLEFT JOIN posts p ON p.user_id = u.id AND p.status = 'PUBLISHED'WHERE u.active = trueGROUP BY u.id, u.name;
Vacuum ve Bloat Yönetimi
// SQL //
-- Tablo istatistiklerini güncelle (planner için)ANALYZE posts;-- Ölü tuple'ları temizleVACUUM posts;-- Index bloat kontrolüSELECT relname AS index_name, pg_size_pretty(pg_relation_size(indexrelid)) AS index_sizeFROM pg_stat_user_indexesWHERE relname LIKE 'idx_posts%'ORDER BY pg_relation_size(indexrelid) DESC;-- Kullanılmayan index'leri bulSELECT schemaname, tablename, indexname, idx_scan AS scan_count, pg_size_pretty(pg_relation_size(indexrelid)) AS sizeFROM pg_stat_user_indexesWHERE idx_scan < 10 -- nadiren kullanılanORDER BY pg_relation_size(indexrelid) DESC;
pg_stat_statements: Yavaş Sorguları Bul
// SQL //
-- Eklentiyi etkinleştir (postgresql.conf)-- shared_preload_libraries = 'pg_stat_statements'CREATE EXTENSION IF NOT EXISTS pg_stat_statements;-- En yavaş 10 sorguSELECT substring(query, 1, 80) AS query_preview, calls, round(total_exec_time::numeric / calls, 2) AS avg_ms, round(total_exec_time::numeric, 2) AS total_ms, rows / calls AS avg_rowsFROM pg_stat_statementsWHERE calls > 50ORDER BY avg_ms DESCLIMIT 10;
Olmadan: Her request → yeni PG bağlantısı → ~25ms overheadPgBouncer ile: Bağlantı havuzundan al → <1ms overhead
Materialized View: Pahalı Sorguları Önbelleğe Al
// SQL //
-- Pahalı raporlama sorgusunu materialized view yapCREATE MATERIALIZED VIEW mv_kullanici_istatistikleri ASSELECT u.id, u.name, COUNT(p.id) AS post_count, SUM(p.view_count) AS total_views, MAX(p.published_at) AS last_post_atFROM users uLEFT JOIN posts p ON p.user_id = u.id AND p.status = 'PUBLISHED'GROUP BY u.id, u.nameWITH DATA;CREATE UNIQUE INDEX ON mv_kullanici_istatistikleri(id);-- Periyodik yenile (cron veya trigger)REFRESH MATERIALIZED VIEW CONCURRENTLY mv_kullanici_istatistikleri;-- Hızlı okumaSELECT * FROM mv_kullanici_istatistikleri ORDER BY total_views DESC LIMIT 20;
Özet
PostgreSQL optimizasyonu üç aşamadan geçer: (1) EXPLAIN ANALYZE ile Seq Scan'leri tespit et, (2) doğru index tipini seç — tekil sorgu için B-Tree, metin arama için GIN, düşük kardinalite için partial, join sonucu okunan sütunlar için INCLUDE, (3) pg_stat_statements ile periyodik yavaş sorgu denetimi. Kullanılmayan index'leri sil — her index write'ı yavaşlatır.