---
title: "PostgreSQL Kuyruk Tablosunu Canlı Kümeye Göre Kurmak"
description: "Kuyruk tablosunda tabloyla büyüyen index ve autovacuum eşiği yanlış ölçeklenir; ikisini bekleyen işlere göre kurmanın ölçülmüş gerekçesi"
url: https://sade.dev/tr/notes/kuyruk-tablosunu-canli-kumeye-gore-kurmak/
lang: tr
author: "Muhammet Şafak"
published: 2026-09-16
updated: 2026-10-07
section: Note
tags: ["postgresql","queue","performance","indexing","production"]
summary: "Bir kuyruk tablosunda iş gören küme bekleyen satırlardır, tablo ise tamamlanmış satırlarla sonsuza kadar büyür. Tablonun boyutuyla ölçeklenen her şey yanlış ölçeklenir: 10 milyon satırda composite index 310 MB'a çıkarken partial index 7,6 MB'ta kaldı, varsayılan autovacuum eşiği ise 15 dakikada biriken 1,75 milyon dead tuple'a hiç ulaşmadı."
---

# PostgreSQL Kuyruk Tablosunu Canlı Kümeye Göre Kurmak

> Bir kuyruk tablosunda iş gören küme bekleyen satırlardır, tablo ise tamamlanmış satırlarla sonsuza kadar büyür. Tablonun boyutuyla ölçeklenen her şey yanlış ölçeklenir: 10 milyon satırda composite index 310 MB'a çıkarken partial index 7,6 MB'ta kaldı, varsayılan autovacuum eşiği ise 15 dakikada biriken 1,75 milyon dead tuple'a hiç ulaşmadı.

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](https://github.com/muhammetsafak/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ı:

| Strateji | 100 bin | 1 milyon | 10 milyon | Index boyutu (10 milyon) |
| --- | --- | --- | --- | --- |
| index yok | 2.001 | 248 | 8 | — |
| `(status)` | 6.500 | 6.417 | 6.426 | 66,1 MB |
| `(status, created_at)` | 12.415 | 11.203 | 10.796 | 310,4 MB |
| partial `(created_at) WHERE status = 'pending'` | 13.041 | 11.708 | 11.538 | 7,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.

```sql
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ı](/tr/notes/zero-downtime-veritabani-gocleri) 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ü:

| Strateji | Başta | 900. saniyede | Bekleyen iş |
| --- | --- | --- | --- |
| partial | 2.024 tps · 1,7 ms | 2.016 tps · 4.906 ms | 5.003 → 14.364 |
| composite | 2.000 tps · 0,52 ms | 1.204 tps · 61.533 ms | 5.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:

```sql
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:

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

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

```sql
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:

```sql
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](/tr/notes/arsivleme-stratejisi).** 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.

## Sık sorulanlar

**Kuyruk tablosunda partial index daha mı hızlı?**

Belirgin biçimde değil. 10 milyon tamamlanmış satırlı tabloda partial index saniyede 11.538 claim, (status, created_at) composite index 10.796 claim taşıdı; fark yüzde 6,9. Asıl fark boyutta: partial index 7,6 MB'ta sabit kaldı, composite index 310 MB'a çıktı.

**Autovacuum kuyruk tablosuna neden geç gelir?**

Varsayılan eşik 50 + 0,2 × reltuples'tır ve reltuples tamamlanmış satırları da sayar. 10 milyon satırlık tabloda eşik yaklaşık 2 milyon dead tuple'dır; ölçümde 15 dakikada 1,75 milyon dead tuple birikti ve autovacuum bir kez bile çalışmadı. Tablo büyüdükçe eşik de büyür.


## Kaynaklar

- [Routine Vacuuming: the autovacuum daemon and its thresholds](https://www.postgresql.org/docs/17/routine-vacuuming.html) — PostgreSQL
- [Partial Indexes](https://www.postgresql.org/docs/17/indexes-partial.html) — PostgreSQL
- [PREPARE: generic and custom plans](https://www.postgresql.org/docs/17/sql-prepare.html) — PostgreSQL
- [pg-queue-bench: raw pgbench runs behind every number in this note](https://github.com/muhammetsafak/pg-queue-bench) — GitHub

## Uygulamada

- [Partial index kuyruk tablosunu kırk bir kat küçültüyor — planner onu seçtiği sürece](https://muhammetsafak.com/tr/research/partial-index-kuyruk-tablosu/)
- [Partial index on beş dakikada üç yüz beş kat şişti — ve autovacuum bir kez bile gelmedi](https://muhammetsafak.com/tr/research/kuyruk-tablosunda-bloat-ve-autovacuum/)
- [Postgres partial index'i generic plana çevirmedi: kırk çalıştırma, kırk custom plan](https://muhammetsafak.com/tr/research/planner-generic-plana-ne-zaman-geciyor/)
- [Sadece 'pending' satırları taranan bir kuyruk tablosunda partial index kullanmalı mıyım?](https://muhammetsafak.com/tr/sor-bakalim/sadece-pending-satirlari-taranan-bir-kuyruk-tablosunda-partial-index-kullanmali-miyim/)
