# Partial index kuyruk tablosunu kırk bir kat küçültüyor — planner onu seçtiği sürece

> Sor Bakalım'da bir soruya ölçmeden cevap vermiştim: partial index bu tablonun ders kitabı örneğidir. Üç tablo boyutu, dört index stratejisi ve iki sorgu biçimiyle o cevabı sınadım. Üç iddia tuttu, biri daraltılması gerekiyor.

- Tür: Ölçüm
- Soru: Milyonlarca ölü satır biriken bir Postgres kuyruk tablosunda partial index ne kazandırıyor, ve planner onu ne zaman seçmiyor?
- Bulgu: 10 milyon ölü satırda partial index saniyede 11.537 iş çıkarıyor, index'siz tablo 7. Composite index'le hız farkı küçük (%6,9) ama boyut farkı değil: 7,6 MB'a karşı 310,4 MB, ve partial tabloyla birlikte büyümüyor çünkü yalnız 5.000 canlı satırı indeksliyor. Asıl bulgu bunların hiçbiri: planner hazırlanmış bir deyimde generic plana geçtiği anda partial index tamamen devre dışı kalıyor — 11.752 tps 7'ye, 0,68 ms 1,1 saniyeye düşüyor. Bin altı yüz yetmiş üç kat. Aynı koşulda composite index etkilenmiyor.
- Yöntem: Postgres 17, tek konteyner, sabitlenmiş ayarlar (shared_buffers 1 GB, work_mem 64 MB, autovacuum açık — kapatmak sayıları güzelleştirir ve cevabı bozardı). `jobs` tablosunda canlı küme her kademede sabit 5.000 `pending` satır; ölü satır 100 bin, 1 milyon ve 10 milyon. Her boyut bir kez seed edilip **template veritabanı** olarak donduruluyor ve her koşu ondan kopyalanıyor: her stratejiyi yeniden seed etmek ölçümlerden uzun sürer ve her seferinde farklı karışmış bir tablo verir, yani sayılar stratejinin etkisini değil seed'in gürültüsünü taşırdı. Seed yüklerken `ORDER BY random()` ile karıştırıyor — sıralı tek geçiş, fiziksel düzeni `created_at`'e eşitleyip her index taramasını neredeyse sıralı okumaya çevirir ve dört stratejiyi de eşit biçimde kayırır. Tüketici pgbench: Postgres'le geliyor, gecikmeyi ortalama değil yüzdelikle veriyor, ve standart araç olduğu için tartışma tezgâha değil index'e kalıyor. 8 istemci, 30 saniye, üç tekrar, medyan. Talep edilen satır `done` yerine yeni bir `created_at` ile `pending`'e dönüyor: kuyruğunu kurutan bir tüketici koşunun ikinci yarısında boş tablo ölçerdi. Her koşuda throughput'un yanında `EXPLAIN` planı, index boyutu, gerçekleşen index tarama sayısı ve bırakılan ölü satır kaydediliyor — tek başına throughput, seçilmiş index ile yok sayılmış index'i ayırt edemez.
- Metrikler: Partial index, 10M ölü satır: 11.537 tps · Aynı tablo, index yokken: 7 tps · Index boyutu, partial / composite: 7,6 / 310,4 MB · Generic plana geçince: 11.752 → 7 tps (−1.673×)
- Ölçüm tarihi: 2026-08-22
- Güven: Yüksek güven
- Durum: Yürürlükte
- Program: Veritabanı & sorgu
- Ortam: Postgres 17-alpine · shared_buffers 1 GB · work_mem 64 MB · autovacuum açık · Tablo jobs · 5.000 canlı satır sabit · 100k / 1M / 10M ölü satır · Yük pgbench · 8 istemci · 30 sn · FOR UPDATE SKIP LOCKED · Donanım Apple M4 Pro · 12 çekirdek · 24 GB · macOS 26.6.1 · Sanallaştırma Docker Desktop 29.7.2 · aarch64 · Tekrar 3 · medyan raporlanır · Determinizm her koşu aynı template veritabanının kopyasından başlar
- Teknolojiler: PostgreSQL, SQL, pgbench, Docker
- Tekrarlamak için: ./bench/run.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/queue.json
- Ham veri lisansı: https://github.com/muhammetsafak/pg-queue-bench/blob/main/LICENSE
- Yayın: 2026-08-22
- Güncelleme: 2026-08-23
- Kaynak: https://www.muhammetsafak.com.tr/research/partial-index-kuyruk-tablosu/
- Dil: tr-TR
- Yazar: Muhammet Şafak

---
Ağustos başında [bir soru geldi](/sor-bakalim/sadece-pending-satirlari-taranan-bir-kuyruk-tablosunda-partial-index-kullanmali-miyim/):
milyonlarca `completed` satırın arasında birkaç bin `pending` satırı sorgulayan
bir kuyruk tablosunda partial index mi, düz index mi? "Evet, bu partial index'in
ders kitabı örneğidir" diye cevapladım ve altı madde saydım. Hiçbirini
ölçmemiştim.

Bu kayıt o cevabı sınıyor.

## Üç kademe, dört strateji

| Strateji | 100k ölü satır | 1M | 10M | Index boyutu (10M) |
| --- | --- | --- | --- | --- |
| index yok | 2.001 tps | 247 tps | 7 tps | — |
| (status) | 6.499 tps | 6.417 tps | 6.426 tps | 66,1 MB |
| (status, created_at) | 12.414 tps | 11.202 tps | 10.795 tps | 310,4 MB |
| partial (created_at) WHERE pending | 13.041 tps | 11.707 tps | 11.537 tps | 7,6 MB |

8 istemci, 30 saniye, üç tekrarın medyanı. Canlı küme her kademede 5.000 satır; değişen tek şey etrafındaki ölü satır sayısı.

İlk satır cevabın gerekçesini doğruluyor: index'siz bir kuyruk tablosu ölü
satırla birlikte çökmüyor, **yok oluyor**. 2.001'den 7 tps'ye. 10 milyon satırın
arasından 5.000 satır bulmak sequential scan ile saniyede yedi kez yapılabilir.

Düz `(status)` index'i de iddia edildiği gibi: kardinalitesi iki olan bir kolona
index koymak yardım ediyor ama tavanı düşük — üç kademede de 6.400 civarında
takılıyor.

## Asıl fark hızda değil, boyutta

Partial ile composite arasında throughput farkı %6,9. Küçük. Ama boyut farkı
tabloyla birlikte açılıyor:

**Index boyutu, tablo büyüdükçe**

Composite index bütün satırları indeksliyor, partial yalnız pending olanları. Canlı küme sabit olduğu için partial da sabit kalıyor.

Kaynak: pg_relation_size, bench/run.sh

|  | 100k ölü satır | 1M | 10M |
| --- | --- | --- | --- |
| (status, created_at) | 11.2 MB | 40.3 MB | 310.4 MB |
| partial | 6.5 MB | 7.6 MB | 7.6 MB |

Composite index tabloyla büyüyor: 11,2 → 40,3 → 310,4 MB. Partial 7,6 MB'da
duruyor, çünkü indekslediği şey tablo değil **kuyruk** — ve kuyruk sabit. On
milyon satırlık tabloda **kırk bir kat** fark.

Bu, cevabın "küçük olur, belleğe sığar, güncellemesi ucuzdur" cümlesinin sayısal
karşılığı. 310 MB'lık bir index'i shared_buffers'da tutmak 7,6 MB'lık birini
tutmakla aynı şey değil, ve her insert/update'te güncellenen ağacın boyutu da
öyle.

> **Sonuç**
>
> Partial index'i seçme sebebiniz hız olmamalı — orada fark %7. Sebep, index'in
> tabloyla birlikte büyümemesi. Kuyruk tablosu tanımı gereği sonsuza kadar büyür;
> onu indeksleyen şeyin büyümemesi mimari bir karar.

## Ve sonra planner fikrini değiştiriyor

Cevabın ikinci maddesi şuydu: *"`status = $1` gibi parametreyle sorarsanız
planner index'i seçemeyebilir; koşulu literal `'pending'` olarak yazın."*
Uyarıyı yazarken ne kadar ciddi olduğunu bilmiyordum.

| 10M ölü satır, parametreyle | tps | Ortalama gecikme | Plan |
| --- | --- | --- | --- |
| partial · custom plan | 11.752 | 0,68 ms | Limit → LockRows → Index Scan |
| partial · generic plan | 7 | 1.113 ms | Limit → LockRows → Sort → Seq Scan |
| composite · custom plan | 11.105 | 0,72 ms | Index Scan |
| composite · generic plan | 11.416 | 0,70 ms | Index Scan |

Aynı tablo, aynı index, aynı sorgu. Değişen tek şey Postgres'in hazırlanmış deyim için genel mi yoksa o çağrıya özel mi plan kullandığı.

**Bin altı yüz yetmiş üç kat.** Partial index generic planda taranmıyor — index
tarama sayacı sıfır — ve sorgu index'i hiç olmayan tablonun sayısına düşüyor:
7 tps. Gecikme 0,68 ms'den 1,1 saniyeye çıkıyor.

Composite index aynı koşulda hiç etkilenmiyor.

Mekanizma plan ağacında görünüyor. Generic plan, `$1`'in ne olduğunu bilmeden
kurulur. Partial index'in kullanılabilmesi için planner'ın `$1 = 'pending'`
olduğunu **kanıtlaması** gerekir — çünkü index yalnız o satırları içeriyor.
Kanıtlayamaz, dolayısıyla index'i eleyip sequential scan'e düşer. Composite
index'in kanıtlanacak bir predicate'i yoktur; `status` kolonu index'in içindedir
ve `$1` ne olursa olsun index taranabilir.

> **Sınır**
>
> Bu, uyarının kapsamını **daraltıyor**: sorun parametreyle sormak değil,
> **predicate'li bir index'i** parametreyle sormak. Cevabın 2. maddesi bu yüzden
> düzeltme istiyor — ve düzeltmesi "literal yazın" değil, "predicate'li index'e
> parametreyle gitmeyin" ya da "custom plan'da kalın".

## Bunun nasıl production'da patladığı

`auto` modunda Postgres ilk beş çalıştırmada custom plan kullanır, sonra generic
planın maliyetini karşılaştırıp ona geçebilir. Yani bu profil **ısındıkça
bozulur**: uygulama açılır, ilk istekler hızlıdır, worker'lar birkaç dakika
çalıştıktan sonra aynı sorgu bin kat yavaşlar.

Staging'de görünmez, çünkü staging'de o deyim beş kez koşmadan test biter.

> **Sonradan not — 23 Ağustos 2026**
>
> Yukarıdaki paragraf fazla ileri gitti ve [ölçüldüğünde yanlış çıktı](/research/planner-generic-plana-ne-zaman-geciyor/).
> Postgres bu geçişi **kendiliğinden yapmıyor**: partial index'te kırk çalıştırmanın
> kırkı da custom plan kaldı (`pg_prepared_statements` sayacı 40/0). Reddetmesinin
> sebebi tam olarak buradaki felaket — generic plan index'i kullanamayacağı için
> tahmini maliyeti yüksek çıkıyor ve planner onu seçmiyor. Yani aşağıdaki 1.673
> katlık uçurum gerçek ama çitli: ona düşmek için `plan_cache_mode` ayarını elle
> `force_generic_plan` yapmak gerekiyor. "Isındıkça bozulur" cümlesi bir çıkarımdı,
> ölçüm değildi; bölümün kuralı gereği düzeltmesi de yayınlanıyor.

Sor Bakalım'da [buna çok benzeyen başka bir soru](/sor-bakalim/surekli-guncellenen-bir-sessions-tablosunda-stagingde-index-scan-alan-sorgu-productionda/)
vardı: staging'de Index Scan alan sorgu production'da neden Seq Scan'e düşüyor?
Orada teşhisi bayat istatistik ve tablo bloat'u diye koymuştum. Bu ölçüm üçüncü
bir sebep gösteriyor, ve bu sebebin `ANALYZE` ile ilgisi yok.

> **Sınır**
>
> Ölçüm tek makinede, tek Postgres sürümünde (17) ve tek erişim deseninde alındı.
> Talep edilen satır `done` yerine `pending`'e döndüğü için insert trafiği yok —
> yazma deseni ve talep başına ölü satır aynı, ama gerçek bir kuyruğun üretici
> tarafı bu tezgâhta temsil edilmiyor. `force_generic_plan` bir üretim ayarı
> değil; gerçek hayatta plan seçimi Postgres'e aittir ve ne zaman geçeceği iş
> yüküne bağlıdır. Buradaki iki uç, aradaki gerçek davranışın sınırlarını çiziyor.
> Autovacuum açık bırakıldı; 30 saniyelik koşular onun uzun vadeli etkisini
> ölçmeye yetmez, bu ayrı bir kayıt konusu.

## Cevabın karnesi

| Sor Bakalım cevabındaki iddia | Sonuç |
| --- | --- |
| Partial index küçük olur, belleğe sığar | doğru — 7,6 MB, tabloyla büyümüyor |
| Düz (status) index'inden net üstün | doğru — 11.537 vs 6.426 tps |
| ORDER BY kolonunu index'e koyun | doğru — Index Scan sıralamayı bedavaya alıyor |
| Parametreyle sorarsanız planner seçemeyebilir | doğru ama dar: sorun predicate'li index |
| Churn ve bloat'a dikkat | ölçülmedi — 30 sn autovacuum için kısa |

Altı maddenin dördü sınandı. Beşincisi kendi kaydını hak ediyor.

Bir tavsiyeyi ölçmenin değeri onu doğrulamak değil — dördü zaten doğruydu.
Değeri, doğru olan bir cümlenin **hangi koşulda tersine döndüğünü** bulmakta.
