On milyon satırlık bir jobs tablosunda kuyrukta bekleyen iş 5.000 taneydi; claim sorgusunun kullandığı index ise 310 MB tutuyordu. Aynı işi 7,6 MB’lık bir partial index görüyordu ve ikisinin arasındaki hız farkı yüzde 6,9’du.

Yani mesele hız değildi. Mesele, neyin tablonun boyutuyla birlikte büyüdüğüydü.

Kuyruk tablosu iki tablodur

Bir kuyruk tablosunda iki ayrı küme yaşar:

  • Canlı küme — pending durumundaki satırlar. Worker’ların aradığı, claim’lerin kilitlediği, gerçekten iş gören kısım. Boyutu throughput ile üretim hızı arasındaki dengeye bağlıdır; sağlıklı bir sistemde kabaca sabit durur.
  • Arşiv — done durumundaki satırlar. Kimse onları sorgulamaz, ama silinmedikleri sürece tablo onlarla birlikte sonsuza kadar büyür.

PostgreSQL bu ayrımı bilmez. MVCC açısından tamamlanmış bir satır canlı bir tuple’dır; pg_class.reltuples onu sayar, planner istatistikleri onu sayar, autovacuum eşiği onu sayar. Ölçtüğüm tabloda pending satır sayısı 5.000’di, n_live_tup ise 10.005.726.

Bu yüzden kuyruk tablosunda iki yanlış ölçekleme kendiliğinden olur: index tabloyla büyür, vacuum eşiği de tabloyla büyür. İkisi de işi yapan kümeye değil, arşive bakar.

Aşağıdaki sayılar pg-queue-bench üzerinden geliyor: PostgreSQL 17, tek container, shared_buffers 1 GB, 8 client, FOR UPDATE SKIP LOCKED ile claim. Her pgbench çıktısı repoda duruyor.

Index’i tabloya değil kuyruğa kurun

Canlı küme her boyutta 5.000 satırda sabit tutuldu; değişen tek şey etrafındaki tamamlanmış satır sayısıydı. Saniye başına claim, üç tekrarın medyanı:

Strateji100 bin1 milyon10 milyonIndex boyutu (10 milyon)
index yok2.0012488—
(status)6.5006.4176.42666,1 MB
(status, created_at)12.41511.20310.796310,4 MB
partial (created_at) WHERE status = 'pending'13.04111.70811.5387,6 MB

Index’siz satır, neden bu sorunun var olduğunu gösteriyor: 10 milyon satırın içinden 5.000 satırı sequential scan ile bulmak saniyede yalnızca 7,6 kez yapılabiliyor.

Composite ile partial arasındaki throughput farkı yüzde 6,9. Bu, partial index’i seçmek için bir gerekçe değil. Gerekçe boyut:

  • Composite index tabloyla büyüdü: 11,2 → 40,3 → 310,4 MB.
  • Partial index 6,5 → 7,6 → 7,6 MB’ta durdu. İndekslediği şey tablo değil, kuyruk; kuyruk sabit olduğu için index de sabit.

On milyon satırda fark 41 kat. 310 MB’lık bir index’i shared_buffers içinde tutmak ile 7,6 MB’lık bir index’i tutmak aynı karar değil.

CREATE INDEX CONCURRENTLY jobs_pending_created_at
    ON jobs (created_at)
 WHERE status = 'pending';

ORDER BY created_at sıralamayı index’ten bedava alır; claim sorgusu predicate’i index’le birebir aynı yazmalıdır. Canlı tabloda index’i CONCURRENTLY ile kurmak, yazmaları durdurmamanın yoludur; kesintisiz şema değişikliğinin geri kalanı ayrı bir yazıda.

Parametreyle sorarsanız

PostgreSQL bir partial index’i ancak sorgunun WHERE koşulunun index predicate’ini kapsadığını planlama anında kanıtlayabilirse kullanır. Generic plan $1’in ne olduğunu bilmeden kurulur; status = $1 ifadesinin status = 'pending' olduğunu kanıtlayamaz ve partial index’i elemek zorunda kalır.

force_custom_plan altında saniyede 11.752 olan claim, force_generic_plan ile zorlandığında 7,2’ye düştü; ortalama gecikme 0,68 ms’den 1.113 ms’ye çıktı — yaklaşık 1.635 kat. Index’in tarama sayacı sıfırda kaldı. Aynı koşulda composite index etkilenmedi (saniyede 11.417), çünkü onun kanıtlanacak bir predicate’i yok; status index’in içindeki bir kolon.

Uçurum gerçek ama çitle çevrili. Varsayılan auto modda PostgreSQL ilk beş çalıştırmayı custom plan ile yapar, sonra generic planın tahmini maliyetini custom planların ortalamasıyla karşılaştırır. Tek oturumda 40 çalıştırmanın 40’ında custom plan kullanıldı; planner generic plana hiç geçmedi. Plan maliyetlerini kaydetmedim; en olası açıklama, partial index’i kullanamayan generic planın tahmini maliyetinin yüksek çıkması. Yine de tek neden bu olmayabilir: predicate’siz (status) index’i de 40 çalıştırmanın 40’ında custom planda kaldı. Composite index ise altıncı çalıştırmada generic plana geçti ve bundan bir şey kaybetmedi.

Bu korumanın bir bedeli var: partial index’e giden sorgu her çalıştırmada yeniden planlanır. Bu ölçekte fark ölçülemeyecek kadar küçük (son beş çalıştırmanın ortalaması: partial 0,20 ms, composite 0,21 ms). Çok tablolu bir join’de ya da uzun bir IN listesinde aynı şey söylenemez.

Kural şu: predicate’li bir index’e parametreyle ulaşmayın. status = 'pending' literal olarak yazılsın; plan_cache_mode’a dokunmayın.

Küçük index daha az vacuum demek değildir

Otuz saniyelik koşularda iki index neredeyse eşitti. On beş dakikalık sürekli yükte tablo değişti. Bu koşuda claim edilen satır done yapıldı ve yanında saniyede 2.000 iş üreten bir producer çalıştı.

Partial index 0,125 MB’tan 38,2 MB’a çıktı — yaklaşık 305 kat. Canlı küme koşunun büyük kısmında 3.000 ile 5.000 satır arasında kaldı; şişen şey index’teki dead entry’lerdi.

Mekanizma tek cümle: pending → done geçişi satırı partial index’in kapsamından çıkarır, ama index’teki girdi vacuum gelene kadar orada kalır. Partial index’in küçüklüğü canlı kümeden, şişme hızı throughput’tan gelir; ikisi arasında bir bağ yok. Küçük bir index vacuum’u ucuzlatır, seyrekleştirmez.

Autovacuum eşiği arşive bakar

On beş dakikada 1.753.949 dead tuple birikti. Autovacuum sayacı: 0.

Varsayılan eşik şöyle hesaplanır:

vacuum eşiği = autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples
             = 50 + 0,2 × ~10.000.000
             ≈ 2.000.000 dead tuple

reltuples tamamlanmış satırları da saydığı için eşik arşivle birlikte büyür. İş gören küme sabit kalır, üretilen dead tuple hızı throughput’a bağlıdır; ama vacuum’un gelmesi için gereken sayı tablonun her yeni done satırıyla biraz daha uzağa kayar. Tablo ne kadar büyükse vacuum o kadar geç gelir.

Bunun bedelini composite index ödedi. Aynı on beş dakikada 301 MB’tan 427 MB’a çıktı ve hedefin gerisine düştü:

StratejiBaşta900. saniyedeBekleyen iş
partial2.024 tps · 1,7 ms2.016 tps · 4.906 ms5.003 → 14.364
composite2.000 tps · 0,52 ms1.204 tps · 61.533 ms5.000 → 126.024

Gecikme pgbench’in zamanlama gecikmesini de içeriyor; hedef hızı tutturamayan client borcunu buraya yazar. Partial index de son 45 saniyede geriledi (855. saniyede 1.740 tps, bekleyen iş 4.549’dan 14.364’e), ama koşuyu hedef hızda bitirdi. Composite index 660. saniyeden sonra bir daha toparlanamadı.

Eşiği tablodan koparın

Global varsayılan bu erişim deseni için tasarlanmamış. Eşiği tablonun boyutundan koparıp sabit bir sayıya bağlayın:

ALTER TABLE jobs SET (
    autovacuum_vacuum_scale_factor = 0,
    autovacuum_vacuum_threshold    = 50000
);

scale_factor = 0 eşiği reltuples’tan bağımsız yapar; geriye yalnızca sabit taban kalır. Hangi sayının doğru olduğunu tablo boyutu değil throughput belirler: saniyede 2.000 claim işleyen bir kuyruk 50.000 dead tuple’ı yaklaşık 25 saniyede üretir.

Bu ayarı ölçmedim — ölçümdeki koşu varsayılan ayarlarla yapıldı ve autovacuum hiç çalışmadığı için vacuum’un maliyeti de ölçülmedi. 50.000 bir başlangıç noktası; kendi throughput’unuza ve vacuum’un kendi tablonuzdaki süresine göre ayarlayın.

Kendi tablonuzu ölçün

Önce iki kümenin oranına bakın:

SELECT status, count(*)
  FROM jobs
 GROUP BY status;

Sonra index’lerin boyutuna ve gerçekten kullanılıp kullanılmadığına:

SELECT indexrelname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS boyut,
       idx_scan
  FROM pg_stat_user_indexes
 WHERE relname = 'jobs';

Son olarak dead tuple sayısını global eşikle yan yana koyun:

SELECT s.n_dead_tup,
       s.autovacuum_count,
       s.last_autovacuum,
       current_setting('autovacuum_vacuum_threshold')::int
         + current_setting('autovacuum_vacuum_scale_factor')::float8 * c.reltuples
         AS global_esik,
       c.reloptions
  FROM pg_stat_user_tables s
  JOIN pg_class c ON c.oid = s.relid
 WHERE s.relname = 'jobs';

reltuples -1 ise tablo henüz analiz edilmemiştir ve hesap anlamsızdır. reloptions boşsa tablo global eşiği kullanıyor demektir. n_dead_tup saatler boyunca global_esik’in altında kalıp büyüyorsa ve autovacuum_count yerinde sayıyorsa, vacuum’unuz arşive bakıyor.

Ne zaman bu kalıbı bırakırsınız?

  • Tamamlanan satırları hemen siliyor ya da arşiv tablosuna taşıyorsanız. Tablo zaten canlı kümeye yakın kalır; composite index’in büyümesi de varsayılan eşiğin kayması da küçük kalır.
  • ORM ya da sürücünüz status’u parametre olarak bağlıyor ve generic plan zorlanıyorsa. Partial index’i kullanamazsınız; composite index daha güvenli.
  • Tabloyu zamana göre partition’lıyorsanız. Eski partition’ları düşürmek hem index’i hem vacuum yükünü tablo boyutundan ayırır; bu yazının çözdüğü problemi başka bir yoldan çözer.

Sayılar tek makineden, PostgreSQL 17’den ve tek bir erişim deseninden geliyor. Otuz saniyelik ve on beş dakikalık koşular gerçek bir kuyruğun aylarını temsil etmez; yön aynı kalır, büyüklükler iş yükünüze aittir.


Kuyruk tablosunda tablo boyutu bir gürültüdür. Index’i ve vacuum’u, işi yapan kümeye göre kurun.