Mert Özen Her Satırda Daha İleri
Veritabanı PostgreSQL — #07

PostgreSQL'de EXPLAIN Okumak: ANALYZE, BUFFERS, Scan ve Join Node'ları, Statistics ve pg_stat_statements

Mert Özen 14 Kas 2026 17 dk 2 görüntülenme
PostgreSQL'de EXPLAIN Okumak: ANALYZE, BUFFERS, Scan ve Join Node'ları, Statistics ve pg_stat_statements

PostgreSQL'de bir planı okurken önce her node'un tahmini satır sayısını EXPLAIN ANALYZE'ın gerçek değeriyle karşılaştırırsın; planner'ın kararları o tahmine dayanır. Cost'un elle hesabı, 18'de varsayılan olan BUFFERS, loops ve ondalıklı rows, nested loop, hash ve merge join, 13 kat yanılan tahmini düzelten CREATE STATISTICS, pg_stat_statements, GENERIC_PLAN ve EF Core sorgusunun planını parametreleriyle yakalayan auto_explain.

Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (resmî postgres:18 Docker image'ı), .NET 10, Npgsql 10.0.3 ve Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ile, 02'de kurduğumuz Bookstore lab'ında, şema yeniden yüklendikten hemen sonra çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için ürün ekibi kategori sayfasına "son 30 günün çok satanları" kutusu istiyor. Sorgu basit: siparişler, sipariş kalemleri ve kitaplar join'leniyor, iptal edilenler dışarıda kalıyor. 06'da olduğu gibi planları sade tutmak için oturumda paralel sorguyu kapattık ve önce yalnızca EXPLAIN çalıştırdık:

SET max_parallel_workers_per_gather = 0;
EXPLAIN
SELECT b.book_id, b.title, sum(oi.quantity) AS sold
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN books b ON b.book_id = oi.book_id
WHERE o.created_at >= timestamptz '2026-09-20' AND o.status <> 'Cancelled' AND b.category = 'Fantasy'
GROUP BY b.book_id, b.title
ORDER BY sold DESC
LIMIT 10;
 Limit  (cost=74686.63..74686.65 rows=10 width=30)
   ->  Sort  (cost=74686.63..74696.59 rows=3983 width=30)
         Sort Key: (sum(oi.quantity)) DESC
         ->  GroupAggregate  (cost=74521.58..74600.56 rows=3983 width=30)
               Group Key: b.book_id
               ->  Sort  (cost=74521.58..74534.63 rows=5220 width=24)
                     Sort Key: b.book_id
                     ->  Hash Join  (cost=26565.84..74199.25 rows=5220 width=24)
                           Hash Cond: (oi.book_id = b.book_id)
                           ->  Hash Join  (cost=25572.05..73067.85 rows=52420 width=6)
                                 Hash Cond: (oi.order_id = o.order_id)
                                 ->  Seq Scan on order_items oi  (cost=0.00..40931.96 rows=2500496 width=10)
                                 ->  Hash  (cost=25310.00..25310.00 rows=20964 width=4)
                                       ->  Seq Scan on orders o  (cost=0.00..25310.00 rows=20964 width=4)
                                             Filter: ((created_at >= '2026-09-20 00:00:00+00'::timestamp with time zone) AND (status <> 'Cancelled'::text))
                           ->  Hash  (cost=944.00..944.00 rows=3983 width=22)
                                 ->  Seq Scan on books b  (cost=0.00..944.00 rows=3983 width=22)
                                       Filter: (category = 'Fantasy'::text)

Şemada orders.created_at üzerinde index yok; bir milyon siparişin yalnızca 21 bini son 30 günde. İki soru: Bu plan en çok maliyeti nerede görüyor? orders (created_at) üzerine bir index eklersen toplam maliyet ve okunan page sayısı ne kadar düşer? Cevaba index'i ekledikten sonra bakacağız. Önce planın dilini çözelim: cost, tahmin ile gerçek, scan ve join node'ları, istatistikler ve production'da yavaş sorguyu bulmak.

EXPLAIN'in Dili: cost, rows, width

Plan bir ağaç. Okumaya en içteki, en sağa girintili node'dan başlarsın; her node altındakilerden satır alır, işler ve üstüne verir. SQL Server'ın grafik planında sağdan sola okuduğun akışın metin hâli bu. Her node'da üç tahmin var: cost=startup..total (başlangıç ve toplam maliyet), rows (bu node'un döndüreceği tahmini satır sayısı) ve width (satır başına tahmini byte).

Cost bir süre değil, planner'ın kendi birimi. Ayarlardan geliyor ve elle hesaplanabiliyor:

SELECT relpages, reltuples FROM pg_class WHERE relname = 'books';
SELECT name, setting FROM pg_settings
WHERE name IN ('seq_page_cost', 'random_page_cost', 'cpu_tuple_cost', 'cpu_operator_cost') ORDER BY 1;
EXPLAIN SELECT count(*) FROM books WHERE category = 'Fantasy';
 relpages | reltuples
----------+-----------
      444 |     40000

       name        | setting
-------------------+---------
 cpu_operator_cost | 0.0025
 cpu_tuple_cost    | 0.01
 random_page_cost  | 4
 seq_page_cost     | 1

 Aggregate  (cost=953.96..953.97 rows=1 width=8)
   ->  Seq Scan on books  (cost=0.00..944.00 rows=3983 width=0)
         Filter: (category = 'Fantasy'::text)

444 page × 1 (seq_page_cost) + 40.000 satır × 0,01 (cpu_tuple_cost) + 40.000 filtre karşılaştırması × 0,0025 (cpu_operator_cost) = 444 + 400 + 100 = 944. Planner'ın bütün kararı bu tür toplamların karşılaştırması: sıralı okunan page 1, rastgele okunan page 4 birim. Başlangıç cost'u ise ilk satırı verebilmek için harcanan kısım. Sort ve Hash gibi node'lar girdinin tamamını okumadan tek satır veremez, bu yüzden başlangıç ve toplam cost'ları birbirine yakındır. LIMIT olan sorgularda planner başlangıç cost'u düşük planları sever; aşağıdaki merge join örneğinde bunu göreceğiz.

Tahmin sorusunun ilk cevabı da burada: planın toplam cost'u 74.686, bunun 40.931'i order_items'ın 2,5 milyon satırlık sıralı taraması, 25.310'u da orders'ın taraması.

EXPLAIN ANALYZE ve BUFFERS: Tahmini Gerçekle Yan Yana Okumak

EXPLAIN sorguyu çalıştırmaz, yalnızca planı gösterir. ANALYZE seçeneği sorguyu gerçekten çalıştırır ve her node'a gerçekleşen değerleri ekler. PostgreSQL 18'den beri ANALYZE ile birlikte BUFFERS da varsayılan olarak açık. TIMING OFF node başına süre ölçümünü kapatır; satır ve buffer sayıları kalır, ölçümün kendi ek yükü de azalır.

EXPLAIN (ANALYZE, TIMING OFF)
SELECT b.book_id, b.title, sum(oi.quantity) AS sold
...;
 Limit  (cost=74686.63..74686.65 rows=10 width=30) (actual rows=10.00 loops=1)
   Buffers: shared hit=13223 read=13464 written=97
   ...
                     ->  Hash Join  (cost=26565.84..74199.25 rows=5220 width=24) (actual rows=5376.00 loops=1)
                           Hash Cond: (oi.book_id = b.book_id)
                           ->  Hash Join  (cost=25572.05..73067.85 rows=52420 width=6) (actual rows=53224.00 loops=1)
                                 Hash Cond: (oi.order_id = o.order_id)
                                 ->  Seq Scan on order_items oi  (cost=0.00..40931.96 rows=2500496 width=10) (actual rows=2500496.00 loops=1)
                                       Buffers: shared hit=6193 read=9734 written=91
                                 ->  Hash  (cost=25310.00..25310.00 rows=20964 width=4) (actual rows=21351.00 loops=1)
                                       Buckets: 32768  Batches: 1  Memory Usage: 1007kB
                                       Buffers: shared hit=6580 read=3730 written=6
                                       ->  Seq Scan on orders o  (cost=0.00..25310.00 rows=20964 width=4) (actual rows=21351.00 loops=1)
                                             Filter: ((created_at >= '2026-09-20 00:00:00+00'::timestamp with time zone) AND (status <> 'Cancelled'::text))
                                             Rows Removed by Filter: 978649
                                             Buffers: shared hit=6580 read=3730 written=6
 Planning:
   Buffers: shared hit=30
 Planning Time: 0.102 ms
 Execution Time: 140.326 ms

Okurken üç şeye bak. Birincisi tahmin ile gerçeğin oranı: orders için 20.964'e karşı 21.351, join sonucu için 52.420'ye karşı 53.224. Tahminler yüzde birkaç içinde; planner doğru bilgiyle karar vermiş. İkincisi Rows Removed by Filter: 21 bin satır için bir milyon satır okunmuş, index'in işe yarayacağı yer burası. Üçüncüsü buffer'lar: hit shared buffers'tan, read işletim sisteminden gelen 8 KB'lık page (01'de gördüğümüz gibi diskten gelmek zorunda değil), written ise yer açmak için bu sorgunun diske yazmak zorunda kaldığı dirty page. Buffer sayıları her node'da altındakilerin toplamını da içerir; en üstteki 26.687 page sorgunun tamamı.

Satır sayılarındaki .00 18 ile geldi: PostgreSQL 18 gerçekleşen satırı ondalıklı gösteriyor. Bunun nedeni loops. Bir node birden çok kez çalıştığında (nested loop'un iç tarafı, paralel worker'lar) rows bir çalıştırmanın ortalamasıdır; toplam satır rows × loops. 17 ve öncesinde bu ortalama tam sayıya yuvarlanıyordu ve 0,4 satır döndüren bir iç döngü 0 görünüyordu.

ANALYZE'ın sorguyu gerçekten çalıştırdığını unutma. DELETE ya da UPDATE'in planına bakacaksan transaction içinde yap ve geri al:

BEGIN;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
DELETE FROM order_items WHERE order_id <= 1000;
ROLLBACK;
SELECT count(*) FROM order_items WHERE order_id <= 1000;
 Delete on order_items (actual rows=0.00 loops=1)
   Buffers: shared hit=2507 read=20 dirtied=16 written=17
   ->  Index Scan using ix_order_items_order_id on order_items (actual rows=2488.00 loops=1)
         Index Cond: (order_id <= 1000)

 count
-------
  2488

2.488 satır gerçekten silindi, ROLLBACK ile geri geldi. Production'da bu bile dikkat ister: silme süresince satırlar kilitli kalır ve trigger'lar çalışır.

Scan Node'ları

Dört temel tablo erişimini 06'da gördük; plan okurken hatırlatma:

NodeNe yaparPlanda dikkat edilecek satır
Seq ScanTablonun bütün page'lerini sırayla okurRows Removed by Filter büyükse seçici bir index eksik olabilir
Index ScanIndex'te bulur, her satır için tabloya giderIndex Cond ile Filter ayrımı: yalnız Index Cond index'ten yararlanır
Index Only ScanCevabı index'ten verir, tabloya yalnız visibility map gerektirdiğinde giderHeap Fetches sıfırdan uzaklaşıyorsa tablo yeterince vacuum'lanmıyor
Bitmap Heap ScanÖnce index'ten page listesi çıkarır, sonra page'leri fiziksel sırayla okurB-tree'den gelen bitmap'te Heap Blocks: lossy görünüyorsa bitmap work_mem'e sığmamış

18 ile index taramalarına Index Searches satırı eklendi; index'e kaç kez inildiğini söylüyor. 06'daki skip scan'de 6, aşağıdaki nested loop'ta 5.453 göreceğiz.

Join Node'ları: Nested Loop, Hash Join, Merge Join

PostgreSQL'in üç join algoritması SQL Server'dakilerle aynı fikirde. SQL Server Derinlemesine serisinin ilk execution plan yazısındaki seçim mantığı burada da geçerli; farklı olan, planda nasıl göründükleri. Önce tahmin sorusunun ikinci yarısı: orders (created_at) index'ini ekleyip aynı sorguyu tekrar çalıştırdık.

CREATE INDEX ix_orders_created_at ON orders (created_at);
 Limit  (cost=50281.13..50281.16 rows=10 width=30) (actual rows=10.00 loops=1)
   Buffers: shared hit=6977 read=9688
   ...
                           ->  Hash Join  (cost=1166.55..48662.35 rows=52420 width=6) (actual rows=53224.00 loops=1)
                                 Hash Cond: (oi.order_id = o.order_id)
                                 ->  Seq Scan on order_items oi  (cost=0.00..40931.96 rows=2500496 width=10) (actual rows=2500496.00 loops=1)
                                       Buffers: shared hit=6303 read=9624
                                 ->  Hash  (cost=904.50..904.50 rows=20964 width=4) (actual rows=21351.00 loops=1)
                                       ->  Index Scan using ix_orders_created_at on orders o  (cost=0.42..904.50 rows=20964 width=4) (actual rows=21351.00 loops=1)
                                             Index Cond: (created_at >= '2026-09-20 00:00:00+00'::timestamp with time zone)
                                             Filter: (status <> 'Cancelled'::text)
                                             Rows Removed by Filter: 889
                                             Buffers: shared hit=230 read=64

orders tarafı 10.310 page'ten 294 page'e indi, ama toplam yalnızca 26.687'den 16.665'e düştü ve cost 74.686'dan 50.281'e. order_items hâlâ baştan sona okunuyor. Hash join'in yapısı bu: küçük girdiden (21 bin sipariş) bellekte bir hash tablosu kurar (Hash node'u, Batches: 1 sığdığı anlamına gelir), sonra büyük girdiyi bir kez tarayıp her satırı tabloda arar. Alternatif, 21 bin siparişin her biri için order_items index'ine inmek; planner bunu daha pahalı buldu. Aralığı yedi güne indirince karar değişiyor:

 Nested Loop  (cost=0.85..33334.33 rows=12495 width=6) (actual rows=13697.00 loops=1)
   Buffers: shared hit=21914 read=28
   ->  Index Scan using ix_orders_created_at on orders o  (cost=0.42..221.37 rows=4997 width=4) (actual rows=5453.00 loops=1)
         Index Cond: (created_at >= '2026-10-13 00:00:00+00'::timestamp with time zone)
   ->  Index Scan using ix_order_items_order_id on order_items oi  (cost=0.43..6.60 rows=3 width=10) (actual rows=2.51 loops=5453)
         Index Cond: (order_id = o.order_id)
         Index Searches: 5453

Nested loop dış girdinin her satırı için iç tarafı bir kez çalıştırır: 5.453 sipariş, 5.453 index araması, döngü başına ortalama 2,51 kalem, toplam 13.697 satır. Buffer sayısına dikkat: 21.914 page "okunmuş", ama bunlar çoğunlukla aynı index page'lerine tekrar tekrar gelen hit'ler. Döngü sayısı büyüdükçe bu maliyet doğrusal artar; o yüzden nested loop'un iç tarafında index yoksa ya da dış taraf tahminin çok üstünde çıkarsa en pahalı plan bu olur. 30 gün ile 7 gün arasındaki eşik, tam olarak planner'ın satır tahminine bağlı.

Merge join ise iki girdi de join kolonuna göre sıralıysa ikisini fermuar gibi birlikte yürür. orders ve payments'ın order_id index'leri sıralı girdi sağlıyor:

EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders o JOIN payments p ON p.order_id = o.order_id WHERE o.order_id <= 300000;
 Aggregate  (cost=41515.24..41515.25 rows=1 width=8) (actual rows=1.00 loops=1)
   ->  Merge Join  (cost=2.16..40765.77 rows=299786 width=0) (actual rows=300000.00 loops=1)
         Merge Cond: (o.order_id = p.order_id)
         ->  Index Only Scan using orders_pkey on orders o  (cost=0.42..8538.68 rows=299786 width=4) (actual rows=300000.00 loops=1)
               Index Cond: (order_id <= 300000)
               Heap Fetches: 0
         ->  Index Only Scan using ux_payments_order_id on payments p  (cost=0.42..25980.42 rows=1000000 width=4) (actual rows=300000.00 loops=1)
               Heap Fetches: 0

payments tarafında tahmin bir milyon satır, gerçekleşen 300 bin: merge join, sol taraf bitince sağ tarafı okumayı bırakır. Başlangıç cost'u da çok düşük (2,16), çünkü ilk eşleşmeyi hemen bulur; LIMIT'li sorgularda planner bu yüzden onu sık seçer. Nested loop'un iç tarafında bazen göreceğin Memoize node'u (14+) ise aynı key ile tekrar gelen aramaların sonucunu bellekte tutar.

Planner Statistics: pg_stats ve CREATE STATISTICS

Satır tahminleri ANALYZE'ın topladığı istatistiklerden geliyor. Autovacuum bunu kendiliğinden yapar; elle çalıştırmak, toplu yüklemeden sonra ilk iştir. pg_stats view'ı ne bilindiğini gösterir:

SELECT n_distinct, most_common_vals, most_common_freqs
FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
 n_distinct |                   most_common_vals                   |                        most_common_freqs
------------+------------------------------------------------------+-----------------------------------------------------------------
          5 | {Delivered,Cancelled,Shipped,Pending,PaymentPending} | {0.94226664,0.038533334,0.014233333,0.0045333332,0.00043333333}

Gerçekte 940.502 sipariş Delivered (%94,05), istatistik %94,23 diyor; çünkü ANALYZE tablonun tamamını değil bir örneklemi okur. Örneklem büyüklüğü default_statistics_target (varsayılan 100) ile orantılı: ANALYZE VERBOSE orders çıktısı "30000 rows in sample" diyor. En sık değerler listesi (MCV; most common values, en sık görülen değerler) ve sıklıkları dışında sıralanabilir kolonlar için 100 dilimlik bir histogram da tutulur. Dağılımı çarpık bir kolonda tahminler tutmuyorsa ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 500 ile o kolonun hedefini yükseltebilirsin.

İstatistikler kolon başınadır ve planner kolonları birbirinden bağımsız varsayar. Bookstore'da şehir ülkeye bağlı: Lille'deki her müşteri Fransa'da. Planner ise "Fransa'da olma" ile "Lille'de olma" olasılıklarını çarpıyor:

EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT o.currency, count(*), sum(o.total_amount)
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
WHERE c.country = 'FR' AND c.city = 'Lille'
GROUP BY o.currency;
 GroupAggregate  (cost=9274.14..9279.46 rows=2 width=44) (actual rows=1.00 loops=1)
   Buffers: shared hit=5142 read=3205 written=347
   ->  Sort  (cost=9274.14..9275.46 rows=530 width=10) (actual rows=5096.00 loops=1)
         ->  Nested Loop  (cost=4.61..9250.15 rows=530 width=10) (actual rows=5096.00 loops=1)
               ->  Seq Scan on customers c  (cost=0.00..2665.00 rows=53 width=4) (actual rows=697.00 loops=1)
                     Filter: ((country = 'FR'::bpchar) AND (city = 'Lille'::text))
               ->  Bitmap Heap Scan on orders o  (cost=4.61..123.92 rows=33 width=14) (actual rows=7.31 loops=697)
                     Recheck Cond: (c.customer_id = customer_id)

Tahmin 53 müşteri, gerçek 697: 13 kat yanılgı. Planner 53 müşteri için nested loop'u seçti ve iç taraf 697 kez çalıştı. Kolonlar arasındaki ilişkiyi extended statistics ile öğretiyoruz:

CREATE STATISTICS st_customers_country_city (dependencies) ON country, city FROM customers;
ANALYZE customers;
SELECT dependencies FROM pg_stats_ext WHERE statistics_name = 'st_customers_country_city';
 HashAggregate  (cost=25655.85..25655.87 rows=2 width=44) (actual rows=1.00 loops=1)
   Buffers: shared hit=8228 read=3247
   ->  Hash Join  (cost=2672.96..25608.07 rows=6370 width=10) (actual rows=5096.00 loops=1)
         Hash Cond: (o.customer_id = c.customer_id)
         ->  Seq Scan on orders o  (cost=0.00..20310.00 rows=1000000 width=14) (actual rows=1000000.00 loops=1)
         ->  Hash  (cost=2665.00..2665.00 rows=637 width=4) (actual rows=697.00 loops=1)
               ->  Seq Scan on customers c  (cost=0.00..2665.00 rows=637 width=4) (actual rows=697.00 loops=1)

     dependencies
----------------------
 {"6 => 5": 1.000000}

"6 => 5": 1.000000, 6. kolonun (city) 5. kolonu (country) tamamen belirlediğini söylüyor. Tahmin 637'ye çıktı ve planner hash join'e geçti. Bu küçük örnekte iki plan birbirine yakın, yenisinin buffer sayısı biraz yüksek bile; ama 13 katlık yanılgı, büyük tablolarda nested loop'un on binlerce kez döndüğü planların en yaygın nedeni. dependencies dışında ndistinct (birlikte kullanılan kolonların farklı değer sayısı, GROUP BY tahmini için) ve mcv türleri de var; 05'te JSON kolonundaki bir ifade için kullandığımız CREATE STATISTICS da aynı mekanizmaydı. SQL Server Derinlemesine serisinin statistics yazısındaki histogram ve density'nin karşılığı pg_stats; çok kolonlu statistics nesnesinin karşılığı da CREATE STATISTICS.

Yavaş Sorguyu Bulmak: pg_stat_statements ve auto_explain

Hangi sorgunun planına bakacağını bilmek için önce yükün nereden geldiğini görmek gerekir. pg_stat_statements, sabitleri $1, $2 ile değiştirip aynı biçimdeki sorguları gruplar ve her grup için çağrı sayısı, toplam süre, okunan buffer gibi değerleri biriktirir. SQL Server'daki Query Store'un ya da sys.dm_exec_query_stats'ın karşılığı. Sunucu açılırken yüklenmesi gerektiği için bir kez restart istiyor:

docker exec -it bookstore-pg psql -U postgres -c "ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'"
docker restart bookstore-pg
docker exec -it bookstore-pg psql -U postgres -d bookstore -c "CREATE EXTENSION pg_stat_statements" -c "GRANT pg_read_all_stats TO bookstore_owner"

pg_read_all_stats rolü olmadan, owner başka rollerin (uygulama rolünün) sorgu metnini göremez. Extension mekanizmasının ayrıntıları 13. yazıda. Uygulamayı bir süre çalıştırdıktan sonra en pahalı sorgular:

SELECT calls, round(total_exec_time::numeric) AS total_ms, round(mean_exec_time::numeric, 1) AS mean_ms,
       shared_blks_hit + shared_blks_read AS blocks, left(query, 40) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 3;
 calls | total_ms | mean_ms | blocks |                  query
-------+----------+---------+--------+------------------------------------------
    20 |     1054 |    52.7 | 551980 | SELECT o.book_id AS "BookId", b.title AS

Sıralamayı total_exec_time'a göre yap: saniyede bin kez çalışan 5 ms'lik sorgu, günde bir kez çalışan 10 saniyelik rapordan daha çok yük getirir. Buradaki sorgu Entity Framework Core'dan (EF Core; tabloları C# nesnelerine eşleyen Microsoft kütüphanesi) geliyor; aşağıda ona döneceğiz.

pg_stat_statements sorgu metnini parametresiz tutar. Bu metnin planına EXPLAIN (GENERIC_PLAN) ile (PostgreSQL 16+) değer vermeden bakabilirsin:

EXPLAIN (GENERIC_PLAN)
SELECT o.book_id, sum(o.quantity) FROM order_items AS o
JOIN orders AS o0 ON o.order_id = o0.order_id
WHERE o0.created_at >= $1
GROUP BY o.book_id;
 HashAggregate  (cost=95789.70..96100.98 rows=31128 width=12)
   ->  Hash Join  (cost=18403.41..91622.21 rows=833498 width=6)
         ->  Seq Scan on order_items o  (cost=0.00..40931.96 rows=2500496 width=10)
         ->  Hash  (cost=12933.75..12933.75 rows=333333 width=4)
               ->  Index Scan using ix_orders_created_at on orders o0  (cost=0.42..12933.75 rows=333333 width=4)
                     Index Cond: (created_at >= $1)

Değer bilinmeyince planner aralık koşulu için sabit bir oran kullanıyor: tablonun üçte biri, 333.333 satır. Generic plan yapıyı gösterir ama satır tahminleri gerçek değerle çalışan plandan çok farklı olabilir. Gerçek planı görmek için değerleri bilmen gerekir; onu da auto_explain verir. Bu modül, belirli bir süreyi aşan her sorgunun planını server log'una yazar. Reload yeterli:

ALTER SYSTEM SET session_preload_libraries = 'auto_explain';
ALTER SYSTEM SET auto_explain.log_min_duration = '40ms';
ALTER SYSTEM SET auto_explain.log_analyze = on;
ALTER SYSTEM SET auto_explain.log_buffers = on;
ALTER SYSTEM SET auto_explain.log_timing = off;
SELECT pg_reload_conf();

02'de gördüğümüz log_min_duration_statement yavaş sorgunun yalnız metnini ve süresini yazar; auto_explain o anki gerçek planı da ekler. log_analyze açıkken log'a düşmeyen sorgular da ölçüm yükünü taşır; log_timing = off bu yükün en pahalı kısmını, node başına saat okumayı kapatır.

EF Core'un Ürettiği Sorgunun Planı

Aynı çok satanlar sorgusunu EF Core ile yazdık. Snapshot'taki Reporting.cs, şemadaki üç tabloyu basit entity'lerle eşliyor; snake_case isimleri burada küçük bir döngüyle veriyoruz, 12. yazıda bu işi EFCore.NamingConventions paketi yapacak:

var query = db.OrderItems
    .TagWith("bestsellers-by-category")
    .Where(i => i.Order.CreatedAt >= since && i.Order.Status != "Cancelled" && i.Book.Category == category)
    .GroupBy(i => new { i.BookId, i.Book.Title })
    .Select(g => new { g.Key.BookId, g.Key.Title, Sold = g.Sum(i => i.Quantity) })
    .OrderByDescending(b => b.Sold)
    .Take(10);

// üretilen SQL'i parametreleriyle birlikte yazdır
Console.WriteLine(query.ToQueryString());
$env:BOOKSTORE_PG = "Host=localhost;Port=5432;Database=bookstore;Username=bookstore_app;Password=<app-password>"
dotnet run --project src/Bookstore.PgLab -- bestsellers
-- @since='2026-09-20T00:00:00.0000000Z' (DbType = DateTime)
-- @category='Fantasy'
-- @p='10'
-- bestsellers-by-category

SELECT o.book_id AS "BookId", b.title AS "Title", COALESCE(sum(o.quantity::int), 0)::int AS "Sold"
FROM order_items AS o
INNER JOIN orders AS o0 ON o.order_id = o0.order_id
INNER JOIN books AS b ON o.book_id = b.book_id
WHERE o0.created_at >= @since AND o0.status <> 'Cancelled' AND b.category = @category
GROUP BY o.book_id, b.title
ORDER BY COALESCE(sum(o.quantity::int), 0)::int DESC
LIMIT @p

ToQueryString() geliştirirken en hızlı yol; SQL'i kopyalayıp @since yerine değeri yazarak psql'de EXPLAIN (ANALYZE) ile çalıştırabilirsin. Uygulama 20 kez çalıştırdığında ise auto_explain log'a şunu yazdı:

LOG:  duration: 50.786 ms  plan:
	Query Text: -- bestsellers-by-category

	SELECT o.book_id AS "BookId", b.title AS "Title", COALESCE(sum(o.quantity::int), 0)::int AS "Sold"
	...
	Query Parameters: $1 = '2026-09-20 00:00:00+00', $2 = 'Fantasy', $3 = '10'
	Limit  (cost=48759.78..48759.81 rows=10 width=26) (actual rows=10.00 loops=1)
	  Buffers: shared hit=3983 read=23616 written=464
	  ...
	              ->  Gather Merge  (cost=47921.58..48529.53 rows=5220 width=24) (actual rows=5376.00 loops=1)
	                    Workers Planned: 2
	                    Workers Launched: 2
	                    ...
	                                ->  Parallel Hash Join  (cost=16669.19..45749.85 rows=21842 width=6) (actual rows=17741.33 loops=3)
	                                      ->  Parallel Seq Scan on order_items o  (cost=0.00..26345.73 rows=1041873 width=10) (actual rows=833498.67 loops=3)

Üç şey dikkat çekiyor. TagWith yorumu log'da sorgunun başında duruyor, bu yüzden log'da hangi C# sorgusunun yavaş olduğunu bulmak kolay. Ama pg_stat_statements'ta yukarıda gördüğün metinde bu yorum yok; extension sorgu metnini statement'ın kendisinden alıyor, başındaki yorumu almıyor. İkincisi parametre değerleri: planı değerlerle yeniden üretmek için gereken her şey burada. Üçüncüsü uygulamanın oturumunda paralel sorgu açık olduğu için plan farklı: iki worker ile birlikte üç process çalışmış (loops=3) ve rows=833498.67 process başına ortalama. Planları karşılaştırırken oturum ayarlarını da aynı tut.

Üretimde Dikkat

pg_stat_statements'ı ilk günden aç; restart gerektirdiği için sorun çıktıktan sonra açmak zordur. Değerleri ara ara sıfırlayıp (pg_stat_statements_reset()) dönemleri karşılaştır ya da düzenli olarak bir tabloya kopyala. Managed servislerde (Azure Database for PostgreSQL, Amazon RDS (Amazon'un managed veritabanı servisi)) modül genellikle önceden yüklü gelir, yalnızca CREATE EXTENSION gerekir.

auto_explain'ı production'da yüksek bir eşikle ve log_timing = off ile kullan; çok yoğun sistemlerde auto_explain.sample_rate ile oturumların bir kısmını örnekle. Log hacmini de izle: her yavaş sorgu birkaç kilobaytlık bir plan demek.

Bu yazıda süreleri değil buffer ve satır sayılarını karşılaştırdık. Süre aynı sorguda bile cache'in durumuna göre değişir; bir değişikliğin etkisini ölçerken okunan page sayısı daha güvenilir.

Toplu yükleme ya da silmeden sonra autovacuum'u beklemeden ANALYZE çalıştır. 18 ile pg_upgrade istatistikleri koruyor ama extended statistics'i korumuyor; upgrade sonrası ANALYZE yine gerekli (14. yazı).

Sık Düşülen Tuzaklar

Cost'u milisaniye sanmak: cost planner'ın kendi birimi ve yalnızca aynı sorgunun alternatif planlarını karşılaştırmaya yarar.

Tahmin ile gerçeği karşılaştırmamak: yavaş bir planda ilk bakılacak yer, rows tahmininin gerçekleşenden 10 kat ya da daha fazla saptığı en alttaki node. Ondan yukarıdaki kararların hepsi o hatalı sayıya dayanır.

loops'u görmezden gelmek: actual rows=7.31 loops=697 yedi satır değil, 5.096 satır demek. Nested loop'un iç tarafındaki küçük görünen bir node, döngü sayısıyla çarpınca sorgunun en pahalı kısmı olabilir.

Planı enable_seqscan = off gibi ayarlarla zorlamak: bu ayarlar teşhis için var. "Index'le daha mı hızlı olurdu?" sorusunu cevaplamak için tek oturumda kullanılabilir, ama kalıcı çözüm istatistiği, index'i ya da sorguyu düzeltmektir. PostgreSQL çekirdeğinde SQL Server'daki gibi query hint yok; pg_hint_plan gibi üçüncü parti extension'lar var.

Küçük Bir Deneme

★☆☆ EXPLAIN SELECT count(*) FROM customers WHERE country = 'TR' çalıştır ve pg_class'tan relpages, reltuples değerlerini alıp Seq Scan cost'unu elle hesapla.

★★☆ Çok satanlar sorgusunda tarih aralığını 30 günden geriye doğru gün gün kısalt ve planın hash join'den nested loop'a geçtiği eşiği bul. O eşikte iki planın buffer sayılarını karşılaştır.

★★★ customers üzerinde country ve city için (ndistinct) türünde bir statistics nesnesi oluştur ve SELECT country, city, count(*) FROM customers GROUP BY 1, 2 sorgusunun tahmini grup sayısının nasıl değiştiğini öncesi ve sonrasıyla göster.

Bir sonraki yazıda planlarda sürekli karşımıza çıkan bir konunun içine giriyoruz: MVCC'nin (Multi-Version Concurrency Control; satırın birden çok sürümünü tutan eşzamanlılık yöntemi) bıraktığı dead tuple'lar, VACUUM ve autovacuum, HOT (Heap-Only Tuple; index'e dokunmayan güncelleme) update, visibility map ve transaction ID wraparound.