# Postgres partial index'i generic plana çevirmedi: kırk çalıştırma, kırk custom plan

> Partial index ölçümü `plan_cache_mode`'u iki uca sabitleyip aradaki farkı buldu. Ölçmediği şey herkesin gerçekten koştuğu ayardı: `auto`. Bu kayıt kararı tahmin etmiyor, `pg_prepared_statements` sayaçlarından okuyor.

- Tür: Ölçüm
- Soru: Hazırlanmış bir deyimde Postgres partial index'li sorguyu kendiliğinden generic plana çeviriyor mu — yoksa bin altı yüz yetmiş üç katlık uçuruma ancak elle mi düşülüyor?
- Bulgu: Postgres geçişi reddediyor. Partial index'te kırk çalıştırmanın kırkı da custom plan — sayaç 40/0. Reddetmesinin sebebi tam olarak felaketin kendisi: generic plan partial index'i kullanamaz, bu yüzden tahmini maliyeti yüksek çıkar ve planner onu seçmez. Composite index ise ders kitabındaki gibi altıncı çalıştırmada geçiyor (5/35) ve hiçbir şey kaybetmiyor. Yani bin altı yüz yetmiş üç katlık uçurum gerçek ama çitli: ona düşmek için `plan_cache_mode = force_generic_plan` yazmak gerekiyor.
- Yöntem: Aynı tezgâh, aynı 10 milyon ölü + 5.000 canlı satırlık tablo, üç index stratejisi. Her strateji için tek bir psql oturumunda tek bir hazırlanmış deyim kırk kez çalıştırıldı ve her çalıştırmadan sonra `pg_prepared_statements` sorgulandı — sayaçlar oturuma özgü olduğu için bu ölçüm pgbench'ten yapılamaz. Karar zamanlamadan çıkarsanmıyor, sayaçtan okunuyor: `custom_plans` ve `generic_plans` alanları planner'ın o çalıştırmada ne seçtiğini doğrudan söylüyor. Zamanlamalar çift geliyor (deyimin kendisi, sonra sayaç sorgusu) ve ayrıştırıcı yalnız ilkini alıyor. psql'in hizalı çıktısı sayaç değerini boşlukla dolduruyor; çıktı bu yüzden hizasız moda alındı — ilk koşu bu yüzden boş seri üretmişti.
- Metrikler: Partial index, generic plan: 0 / 40 çalıştırma · Composite index, geçiş: 6. çalıştırma · Partial, ilk 5 → son 5: 0,36 → 0,20 ms · Uçuruma düşmek için gereken: force_generic_plan
- Ölçüm tarihi: 2026-08-22
- Güven: Yüksek güven
- Durum: Yürürlükte
- Program: Veritabanı & sorgu
- Ortam: Postgres 17-alpine · plan_cache_mode varsayılan (auto) · Tablo 10M ölü + 5.000 canlı satır · Ölçüm tek oturum · tek hazırlanmış deyim · 40 çalıştırma · Karar kaynağı pg_prepared_statements.custom_plans / generic_plans · Donanım Apple M4 Pro · 12 çekirdek · 24 GB · Docker Desktop 29.7.2
- Teknolojiler: PostgreSQL, SQL, Docker
- Tekrarlamak için: EXECUTIONS=40 ./bench/plan-switch.sh
- Kaynak kodu: https://github.com/muhammetsafak/pg-queue-bench
- Ham veri: https://github.com/muhammetsafak/pg-queue-bench/blob/main/results/2026-08-22/plan-switch.json
- Ham veri lisansı: https://github.com/muhammetsafak/pg-queue-bench/blob/main/LICENSE
- Yayın: 2026-08-23
- Kaynak: https://www.muhammetsafak.com.tr/research/planner-generic-plana-ne-zaman-geciyor/
- Dil: tr-TR
- Yazar: Muhammet Şafak

---
[Partial index ölçümü](/research/partial-index-kuyruk-tablosu/) `plan_cache_mode`'u
iki uca sabitledi ve aralarında bin altı yüz yetmiş üç kat buldu: custom planda
11.752 tps, generic planda 7. Ölçmediği şey herkesin gerçekten koştuğu ayardı —
`auto`.

O kayıt orada bir cümle kurdu: *"profil ısındıkça bozulur... staging'de
görünmez."* Makul bir çıkarımdı ve **yanlış** çıktı.

## Karar tahmin edilmedi, okundu

Postgres hazırlanmış bir deyimi ilk beş çalıştırmada custom planla koşar, sonra
generic planın **tahmini** maliyetini custom planların ortalamasıyla karşılaştırır
ve ancak generic daha ucuz görünüyorsa ona geçer. `pg_prepared_statements` bu
kararı sayaç olarak tutuyor, yani zamanlamadan çıkarsamaya gerek yok.

| Strateji | İlk generic plan | Son sayaç (custom/generic) | İlk 5 (ms) | Son 5 (ms) |
| --- | --- | --- | --- | --- |
| partial | hiç | 40 / 0 | 0,36 | 0,20 |
| composite | 6. çalıştırma | 5 / 35 | 0,40 | 0,21 |
| (status) | hiç | 40 / 0 | 0,98 | 0,78 |

Tek oturum, tek hazırlanmış deyim, kırk çalıştırma. Sayaçlar her çalıştırmadan sonra okundu.

Composite'in geçişi belgelendiği gibi, tam beşinciden sonra:

```
#5  0,266 ms   custom=5  generic=0
#6  0,275 ms   custom=5  generic=1   ← geçti
#7  0,185 ms   custom=5  generic=2
```

Partial index'te bu hiç olmuyor. Kırk çalıştırma, kırk custom plan.

## Reddetmesinin sebebi felaketin kendisi

Planner generic planı seçmiyor çünkü **maliyetini doğru tahmin ediyor**. Generic
plan `$1`'in ne olduğunu bilmez; partial index'i kullanabilmek için
`$1 = 'pending'` olduğunu kanıtlaması gerekir, kanıtlayamaz, dolayısıyla o plan
sequential scan'e düşer ve tahmini maliyeti tavan yapar. Custom planların
ortalaması bunun çok altında kalınca karşılaştırma her seferinde custom lehine
sonuçlanıyor.

> **Sonuç**
>
> Uçurum gerçek ama çitli. Maliyet modeli tam olarak sizi ondan uzak tutmak için
> çalışıyor ve bu işi kırk çalıştırma boyunca hiç şaşırmadan yapıyor. Ona düşmek
> için `plan_cache_mode = force_generic_plan` yazmanız — yani ayarı elle kapatmanız
> — gerekiyor.

## Gerçek takas daha küçük ve başka yerde

Korumanın bir bedeli var: partial index'li sorgu **her çalıştırmada yeniden
planlanıyor.** Kırk çalıştırma, kırk plan üretimi. Composite altıncıdan sonra
aynı planı tekrar kullanıyor.

Bu ölçekte fark ölçülemiyor — 0,20 ms'ye karşı 0,21 ms. Ama plan üretimi ucuz bir
sorguda ucuz; çok tablolu bir join'de ya da uzun bir `IN` listesinde değil. Partial
index'in gizli maliyeti bloat (bkz. [dayanıklılık ölçümü](/research/kuyruk-tablosunda-bloat-ve-autovacuum/))
ve sürekli yeniden planlama; ikisi de bu iş yükünde küçük, ikisi de başka bir iş
yükünde küçük kalmayabilir.

> **Sınır**
>
> Ölçüm tek sorgu şekli, tek Postgres sürümü (17) ve sağlıklı istatistiklerle
> alındı. Planner'ın kararı **tahmini** maliyete dayanıyor; istatistikler yanlışsa
> tahmin de yanlış olur, dolayısıyla "asla geçmez" değil "bu koşullarda geçmedi"
> denebilir. Bayat istatistikle aynı ölçümü tekrarlamak ayrı bir kayıt konusu — ve
> o senaryo, partial index kaydının fazla alarmcı bulduğum cümlesini haklı
> çıkarabilecek tek yol.

## Önceki kaydı düzeltiyor

Partial index kaydının "ısındıkça bozulan profil" paragrafı fazla ileri gitmişti
ve o kayda bu bulguya işaret eden bir not düşüldü. Ölçümün kendi yayınını
düzeltmesi bu bölümün sözleşmesinin gereği: bir sayı yayınlanıyorsa, onu yanlışlayan
sayı da yayınlanır.
