Partial index kuyruk tablosunu kırk bir kat küçültüyor — planner onu seçtiği sürece
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.
- 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×
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.
- Ölçüm tarihi
- Yayın
- Güncelleme
dün ölçüldü
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
Tekrarlamak için
./bench/run.sh Ağustos başında bir soru geldi:
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 en hızlı ve en küçük | 13.041 tps | 11.707 tps | 11.537 tps | 7,6 MB |
İ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.
- (status, created_at)
- partial
MB düşük olan iyi Kaynak: pg_relation_size, bench/run.sh
Veri tablosu
| Seri | 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 |
Grafik tarayıcıda çizilir; aşağıdaki tablo aynı veriyi taşır.
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.
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 index hiç taranmadı | 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 |
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.
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.
Sor Bakalım’da buna çok benzeyen başka bir soru
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.
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 |
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.
İlgili yazılar
Dual-write'tan outbox'a: idempotent tüketim ve alan-seviyesi şifreleme günlüğü
Aynı projede dual-write yüzünden kaybolan event'ler beni transactional outbox'a, at-least-once teslim de idempotent tüketime götürdü. Bir de GDPR alanlarını x-gdpr-sensitive ile satır seviyesinde şifreledim. Gerçek bir projeden notlar.
Aynı mesaj, farklı sonuç: event-driven mimaride determinizm
Event-driven mimaride aynı mesaj neden farklı sonuç üretir? Gerçek bir fatura akışından: determinizmi bozan gizli girdiler ve geri kazandıran dört hamle.