PostgreSQL'de Partitioning: Range, List, Hash, Partition Pruning ve Attach/Detach ile Arşiv
PostgreSQL'de partitioning sorguyu kendiliğinden hızlandırmaz, ama arşivi ve bakımı kolaylaştırır. Lab'da partition key'i kullanmayan "Siparişlerim" sorgusu 50 aylık partition'da 7 kat fazla buffer okudu, 1.522 günlük partition'da yalnız planlaması 49 ms sürdü; buna karşılık on ayı DETACH CONCURRENTLY ile pgbench yükü altında, ay başına 40 ms'nin altında ve hatasız arşive taşıdık. Range, list, hash, pruning ve PostgreSQL 18'in ANALYZE ONLY'si.
Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7, .NET 10 ve Npgsql 10.0.3 ile tek makinelik bir lab'da çalıştırılmıştır. Süreler bu lab'a aittir; karşılaştırmada asıl önemli olan okunan buffer sayıları ve plan'daki partition sayısı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) dört yıllık sipariş verisini aylara bölmeye karar verdi. Sipariş tablosunun aynısını created_at kolonuna göre aylık partition'lara ayırdık: orders_p, 50 partition, aynı 1 milyon sipariş, her partition'da aynı (customer_id, created_at DESC) index'i. 01'den beri izlediğimiz "Siparişlerim" sorgusu:
SELECT order_id, status, total_amount, created_at FROM orders_p
WHERE customer_id = 4242 ORDER BY created_at DESC LIMIT 20;
Tahmin et: bu sorgu partition'lı tabloda partition'sız tabloya göre daha mı hızlı, daha mı yavaş? Aynı sorguyu iki tabloda EXPLAIN (ANALYZE, BUFFERS) ile üçer kez çalıştırdık; değerler her seferinde aynıydı. İlk satır partition'sız orders, ikincisi orders_p; ilk Buffers sorgunun kendisinin, ikincisi planlamanın okuduğu sayfa sayısı:
Limit (actual rows=10.00 loops=1) Buffers: shared hit=16 Buffers: shared hit=125 Planning Time: 0.176 ms Execution Time: 0.061 ms
Limit (actual rows=10.00 loops=1) Buffers: shared hit=109 Buffers: shared hit=3627 Planning Time: 2.832 ms Execution Time: 0.321 ms
Partition'lı tablo yaklaşık 7 kat fazla buffer okudu, planlaması 16 kat uzun sürdü. Sorgu partition key'i (created_at) filtrelemediği için PostgreSQL 50 partition'ın 50 index'ine de baktı. Partitioning bir sorgu hızlandırma aracı değil; asıl işi veri yaşam döngüsünü yönetmek. Bu yazıda PostgreSQL'in declarative partitioning'ini kuruyor, pruning'in ne zaman çalıştığını ölçüyor ve 03'te saatlerce batch'lerle yaptığımız arşivlemeyi eski ayların partition'larını tablodan ayırarak milisaniyelerde yapıyoruz.
Partitioning Ne Zaman İşe Yarar?
Partitioning büyük bir tabloyu, dışarıdan tek tablo gibi görünen daha küçük tablolara böler. Kazançlar üç yerde: eski veriyi silmek yerine bütün bir partition'ı ayırmak ya da düşürmek (log üretmeden, lock beklemeden, bloat bırakmadan); bakım işlerini (VACUUM, index rebuild) küçük parçalarda yapmak; ve sorgu partition key'i filtrelediğinde yalnız ilgili partition'ları okumak (partition pruning). Üçüncüsü çoğu zaman beklenen kazançtır ama en az garantili olanıdır: iyi bir index zaten aynı işi yapar.
Gereksiz olduğu durumlar da net. Tablo birkaç GB'ın altındaysa ve büyüme hızı yavaşsa partitioning'in karmaşıklığı kazancından büyüktür. Sorguların çoğu partition key'i kullanmıyorsa, yukarıda gördüğümüz gibi her sorgu bütün partition'lara dağılır. Bookstore'da durum partitioning lehine: sipariş tablosu her ay büyüyor (01'de yılda 3,1 GB hesaplamıştık, 10 kat büyümeyle), raporların çoğu ay bazında ve 3 yıldan eski siparişler arşive gidiyor.
Declarative Partitioning: Range, List ve Hash
PostgreSQL'de partition'lı tablo, kendisi veri tutmayan bir parent'tır; satırlar PARTITION OF ile tanımlanan child tablolarda durur. İlk deneme bir kısıtla karşılaştı:
CREATE TABLE orders_p (
order_id bigint GENERATED BY DEFAULT AS IDENTITY,
customer_id int NOT NULL,
status text NOT NULL,
total_amount numeric(12,2) NOT NULL,
currency char(3) NOT NULL DEFAULT 'TRY',
created_at timestamptz NOT NULL,
PRIMARY KEY (order_id)
) PARTITION BY RANGE (created_at);
ERROR: unique constraint on partitioned table must include all partitioning columns
DETAIL: PRIMARY KEY constraint on table "orders_p" lacks column "created_at" which is part of the partition key.
Her partition'ın index'i yalnız kendi içinde tekilliği garanti edebilir; PostgreSQL global bir unique index tutmaz. Bu yüzden primary key ve unique constraint'ler partition key'i içermek zorunda. Sipariş için primary key (order_id, created_at) oldu. Bunun bir yan etkisi var: order_id tek başına artık veritabanı tarafından tekil garanti edilmiyor; identity kolonu pratikte tekil değer üretir ama uygulama bir id'yi elle yazarsa kimse engellemez. Sipariş kalemleri de aynı ay sınırlarıyla bölündü, created_at kolonu kalemlere taşındı ve foreign key iki kolonlu oldu:
CREATE TABLE order_items_p (
order_id bigint NOT NULL,
line_no smallint NOT NULL,
created_at timestamptz NOT NULL,
book_id int NOT NULL REFERENCES books,
quantity int NOT NULL,
unit_price numeric(10,2) NOT NULL,
PRIMARY KEY (order_id, line_no, created_at),
FOREIGN KEY (order_id, created_at) REFERENCES orders_p
) PARTITION BY RANGE (created_at);
CREATE INDEX ix_orders_p_customer_created ON orders_p (customer_id, created_at DESC);
Parent'ta tanımlanan index her partition'da otomatik oluşur, sonradan eklenen partition'larda da. Ayları bir fonksiyon açıyor; zaten var olan ayı atlıyor, böylece aynı fonksiyon her gün güvenle çağrılabiliyor:
CREATE FUNCTION create_month_partitions(p_from date, p_to date)
RETURNS int
LANGUAGE plpgsql AS $$
DECLARE
m date;
created int := 0;
BEGIN
FOR m IN SELECT generate_series(date_trunc('month', p_from), p_to, interval '1 month')::date LOOP
CONTINUE WHEN to_regclass('orders_' || to_char(m, 'YYYY_MM')) IS NOT NULL;
EXECUTE format('CREATE TABLE %I PARTITION OF orders_p FOR VALUES FROM (%L) TO (%L)',
'orders_' || to_char(m, 'YYYY_MM'), m, (m + interval '1 month')::date);
EXECUTE format('CREATE TABLE %I PARTITION OF order_items_p FOR VALUES FROM (%L) TO (%L)',
'order_items_' || to_char(m, 'YYYY_MM'), m, (m + interval '1 month')::date);
created := created + 1;
END LOOP;
RETURN created;
END
$$;
SELECT create_month_partitions('2022-11-01', '2026-12-01');
Range'in alt sınırı dahil, üst sınırı hariç; 2026-10-01 ile 2026-11-01 arasındaki partition ekimin tamamını tutar ve ay sonundaki milisaniyeler hiçbir zaman iki partition arasında kaybolmaz. 50 partition açıldı, 1 milyon sipariş ve 2,5 milyon kalem INSERT ... SELECT ile taşındı. Diğer iki tür kısaca: list partitioning ayrık değerlere (ülke, bölge, tenant), hash partitioning ise doğal bir aralığı olmayan bir key'i eşit parçalara bölmek içindir:
CREATE TABLE customers_by_country (
customer_id int NOT NULL,
country char(2) NOT NULL,
email text NOT NULL,
PRIMARY KEY (customer_id, country)
) PARTITION BY LIST (country);
CREATE TABLE customers_tr PARTITION OF customers_by_country FOR VALUES IN ('TR');
CREATE TABLE customers_eu PARTITION OF customers_by_country FOR VALUES IN ('DE', 'NL');
CREATE TABLE customers_other PARTITION OF customers_by_country DEFAULT;
CREATE TABLE cart_items (
cart_id uuid NOT NULL,
book_id int NOT NULL,
quantity int NOT NULL,
PRIMARY KEY (cart_id, book_id)
) PARTITION BY HASH (cart_id);
CREATE TABLE cart_items_0 PARTITION OF cart_items FOR VALUES WITH (MODULUS 4, REMAINDER 0);
partition | count
-----------------+-------
customers_tr | 50000
customers_eu | 33334
customers_other | 16666
Hash partitioning arşivlemeye yardım etmez, çünkü eski veri her partition'a dağılır; büyük bir tablonun bakımını bölmek ya da 09'da göreceğimiz sharding'e hazırlık için kullanılır. Bookstore'un sipariş tablosu için doğru seçim tarihe göre range.
Partition Pruning: Plan Time ve Execution Time
Pruning, sorgunun filtresinden hangi partition'ların gerekmediğini çıkarıp onları hiç okumamaktır. Ekim ayının cirosu:
SELECT count(*), sum(total_amount) FROM orders_p
WHERE created_at >= '2026-10-01' AND created_at < '2026-11-01';
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=352
-> Seq Scan on orders_2026_10 orders_p (actual rows=42233.00 loops=1)
Filter: ((created_at >= '2026-10-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-11-01 00:00:00+00'::timestamp with time zone))
Buffers: shared hit=352
Planning Time: 0.922 ms
Execution Time: 3.075 ms
Plan'da tek bir partition var; diğer 49'u planner daha plan aşamasında eledi. Aynı sorgu partition'sız orders tablosunda paralel seq scan ile 16.667 buffer okudu (29,9 ms). created_at üzerine bir index eklediğimizde 7.514 buffer'a indi (18,2 ms): ekim ayının 42 bin satırı tablonun 7.396 farklı sayfasına dağılmıştı, çünkü satırlar yüklendikten sonra güncellenmiş ve yeniden yazılmıştı. Üretimde sipariş durumunu güncelleyen her işlem aynı etkiyi yapar. Partition ise bir ayın satırlarını fiziksel olarak bir arada tutar.
Değer plan anında bilinmiyorsa pruning execution sırasında yapılır. Bir prepared statement'ın generic plan'ı ve now() kullanan bir filtre:
SET plan_cache_mode = force_generic_plan;
PREPARE month_revenue(timestamptz, timestamptz) AS
SELECT count(*), sum(total_amount) FROM orders_p WHERE created_at >= $1 AND created_at < $2;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) EXECUTE month_revenue('2026-10-01', '2026-11-01');
EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders_p WHERE created_at >= now() - interval '7 days';
-> Parallel Append (actual rows=14077.67 loops=3)
Buffers: shared hit=352
Subplans Removed: 49
-> Parallel Seq Scan on orders_2026_10 orders_p_1 (actual rows=14077.67 loops=3)
...
-> Parallel Append
Subplans Removed: 48
-> Parallel Seq Scan on orders_2026_11 orders_p_1
-> Parallel Seq Scan on orders_2026_12 orders_p_2
Subplans Removed execution başında elenen partition sayısı. Npgsql'in prepared statement'ları ve PgBouncer'ın max_prepared_statements'ı (02) generic plan'a yol açabildiği için bu satırı tanımak önemli: generic plan'da pruning yine çalışıyor, ama yalnız filtre partition key'e doğrudan uygulanıyorsa. created_at::date = $1 gibi kolonu bir fonksiyona sokan filtrelerde pruning çalışmaz.
Partition Key'i Kullanmayan Sorgular ve Partition Sayısı
"Siparişlerim" sorgusuna geri dönelim. Plan'da 50 index scan vardı (kısaltıldı):
Limit (actual rows=10.00 loops=1)
Buffers: shared hit=7 read=102
-> Append (actual rows=10.00 loops=1)
-> Index Scan using orders_2026_12_customer_id_created_at_idx on orders_2026_12 orders_p_50 (actual rows=0.00 loops=1)
Index Cond: (customer_id = 4242)
Buffers: shared hit=2
-> Index Scan using orders_2026_11_customer_id_created_at_idx on orders_2026_11 orders_p_49 (actual rows=0.00 loops=1)
Index Cond: (customer_id = 4242)
Buffers: shared read=2
...
-> Index Scan using orders_2022_11_customer_id_created_at_idx on orders_2022_11 orders_p_1 (actual rows=0.00 loops=1)
Index Cond: (customer_id = 4242)
Buffers: shared read=1
Müşterinin 10 siparişi var; 20 satıra ulaşılamadığı için Limit erken duramadı ve 50 partition'ın her birinde index'in kökünden yaprağına kadar inildi. Bu tür sorgular partition sayısıyla doğrusal büyür. Partition sayısını abartmanın bedelini görmek için aynı veriyi günlük partition'lara böldük (1.522 partition):
orders: Buffers: shared hit=16 Buffers: shared hit=130 Planning Time: 0.218 ms Execution Time: 0.038 ms
orders_p: Buffers: shared hit=95 Buffers: shared hit=2957 Planning Time: 2.138 ms Execution Time: 0.294 ms
orders_daily: Buffers: shared hit=2783 Buffers: shared hit=78275 Planning Time: 49.300 ms Execution Time: 10.535 ms
Bu ölçümde orders_p'nin partition sayısı 40'tı (aşağıdaki arşivden sonra). 1.522 partition'da yalnız planlama 49 ms; 01'deki SLO'nun (Service Level Objective; servisin tutturmayı hedeflediği performans değeri) 50 ms'lik p99 hedefi tek bu sorgunun planlamasıyla tükeniyor. PostgreSQL dokümanı, sorguların çoğu partition'ları birkaç taneye indirebiliyorsa birkaç bin partition'ın makul çalıştığını, pruning'den sonra çok partition kalıyorsa planlama süresinin ve belleğin arttığını söylüyor. Her session dokunduğu partition'ların metadata'sını kendi belleğine de yükler. Pratik kural: partition boyutunu arşiv ve bakım biriminize göre seçin (Bookstore için ay), sorguların çoğunun partition key'i filtrelediğinden emin olun, gerisini index'e bırakın. "Siparişlerim" için çözüm, sorguya makul bir tarih alt sınırı eklemek (örneğin son 2 yıl) ya da bu ekranı partition sayısından etkilenmeyecek kadar az partition'la tasarlamak.
Detach ile Arşiv: 03'teki Batch Job'un Yerine Tek Komut
03'te 3 yıldan eski siparişleri canlı tablodan arşive keyset batch'leriyle taşımıştık. Partition'lı tabloda aynı iş, eski ayın partition'ını parent'tan ayırmak (detach) ve başka bir schema'ya taşımaktır. Veri kopyalanmaz, silinmez; yalnız catalog değişir ve WAL'a (Write-Ahead Log; PostgreSQL'in değişiklikleri önce yazdığı transaction log'u) yalnız bu değişiklik yazılır. İlk denemede foreign key araya girdi:
ALTER TABLE orders_p DETACH PARTITION orders_2022_11 CONCURRENTLY;
ERROR: removing partition "orders_2022_11" violates foreign key constraint "order_items_p_order_id_created_at_fkey_1"
DETAIL: Key (order_id, created_at)=(1, 2022-11-15 10:33:36+00) is still referenced from table "order_items_p".
Önce referans veren tarafı, kalemlerin partition'ını ayırmak gerekiyor. Ayrılan order_items_2022_11 ise foreign key'ini koruyor ve hâlâ orders_p'yi gösteriyor; o constraint düşürülmeden siparişlerin partition'ı yine ayrılamıyor. Sıra şu: kalemleri ayır, kalemlerin foreign key'ini düşür, siparişleri ayır.
CONCURRENTLY'nin neden önemli olduğunu ölçtük. A session'ı orders_p üzerinde 8 saniyelik bir rapor çalıştırıyor; 1. saniyede B detach'i başlatıyor, 2. saniyede C bir müşterinin "Siparişlerim" sorgusunu çalıştırıyor:
== plain
A long report: Time: 8007.962 ms (00:08.008)
B detach: Time: 6975.415 ms (00:06.975)
C my orders: Time: 5963.788 ms (00:05.964)
== concurrently
C my orders: Time: 3.156 ms
waiting: ALTER TABLE orders_p DETACH PARTITION orders_2022_12 CONCURR / Lock:virtualxid
A long report: Time: 8021.425 ms (00:08.021)
B detach: Time: 7008.036 ms (00:07.008)
Normal DETACH PARTITION parent üzerinde ACCESS EXCLUSIVE lock ister. A'nın lock'u yüzünden bekler ve beklerken lock queue'nun başında durduğu için arkasından gelen basit bir okuma da (C) 6 saniye bekler. CONCURRENTLY parent'ta yalnız SHARE UPDATE EXCLUSIVE alır ve iki transaction'da çalışır: önce partition'ı yeni sorgular için görünmez yapar, sonra o partition'ı görebilecek eski transaction'ların bitmesini bekler. C 3 ms'de tamamlandı; B, A bitene kadar bekledi ama kimseyi bekletmedi. Bedeli: CONCURRENTLY bir transaction bloğu içinde çalışmaz ve tabloda default partition varsa hiç çalışmaz (ikisini de birazdan göreceğiz).
Bu adımları her gün çalışan bir job'a koyduk. Job önce önümüzdeki 3 ayın partition'larını açar, sonra 36 aydan eski ayları arşiv schema'sına taşır:
async Task ArchiveMonthAsync(DateOnly month, CancellationToken ct)
{
string m = month.ToString("yyyy_MM", CultureInfo.InvariantCulture);
Stopwatch sw = Stopwatch.StartNew();
// order_items, orders'a referans veriyor: önce kalemleri ayır ve foreign key'lerini düşür
await ExecuteAsync($"ALTER TABLE order_items_p DETACH PARTITION order_items_{m} CONCURRENTLY", ct);
await ExecuteAsync($"ALTER TABLE order_items_{m} DROP CONSTRAINT order_items_p_order_id_created_at_fkey", ct);
await ExecuteAsync($"ALTER TABLE orders_p DETACH PARTITION orders_{m} CONCURRENTLY", ct);
await ExecuteAsync($"ALTER TABLE orders_{m} SET SCHEMA archive", ct);
await ExecuteAsync($"ALTER TABLE order_items_{m} SET SCHEMA archive", ct);
long rows = await ScalarAsync<long>($"SELECT count(*) FROM archive.orders_{m}", ct);
Console.WriteLine($"archived {month:yyyy-MM}: {rows} orders in {sw.ElapsedMilliseconds} ms");
}
Ay adları kodda üretildiği için SQL'e string olarak giriyor; dışarıdan gelen bir değer değil, DateOnly'den biçimlendiriliyor. Job'u, 8 client'lı bir pgbench yükü (her transaction bir "Siparişlerim" okuması ve bir yeni sipariş) çalışırken başlattık:
created 2 new monthly partitions up to 2027-02
archived 2023-01: 1823 orders in 34 ms
archived 2023-02: 2382 orders in 22 ms
archived 2023-03: 3868 orders in 18 ms
archived 2023-04: 4265 orders in 21 ms
archived 2023-05: 5457 orders in 30 ms
archived 2023-06: 5944 orders in 20 ms
archived 2023-07: 7250 orders in 17 ms
archived 2023-08: 7899 orders in 17 ms
archived 2023-09: 8462 orders in 19 ms
archived 2023-10: 9941 orders in 15 ms
progress: 4.0 s, 7745.8 tps, lat 1.029 ms stddev 0.648, 0 failed
progress: 8.0 s, 8413.1 tps, lat 0.948 ms stddev 0.717, 0 failed
progress: 12.0 s, 9780.0 tps, lat 0.815 ms stddev 0.188, 0 failed
progress: 16.0 s, 9820.1 tps, lat 0.812 ms stddev 0.185, 0 failed
number of failed transactions: 0 (0.000%)
Kasım ve Aralık 2022'yi yukarıdaki denemede elle ayırmıştık; job kalan on ayı taşıdı, her biri 40 ms'nin altında; job'un çalıştığı 4–8. saniye aralığında throughput hafifçe düştü, hiçbir transaction hata vermedi. 03'teki batch job'un yaptığı satır satır kopyalama, WAL ve replica lag'i burada yok. Arşivdeki tablolar sıradan tablolar: pg_dump ile alınıp düşürülebilir, ayrı bir veritabanına taşınabilir ya da "eski siparişler" ekranı için olduğu yerde okunabilir.
Default Partition ve Yeni Ayları Önceden Açmak
Partition'ı olmayan bir tarihle gelen satır hata verir. Bunu önlemenin ilk akla gelen yolu default partition, ama iki bedeli var:
CREATE TABLE orders_default PARTITION OF orders_p DEFAULT;
ALTER TABLE orders_p DETACH PARTITION orders_2027_02 CONCURRENTLY;
INSERT INTO orders_p (customer_id, status, total_amount, created_at) VALUES (4242, 'Pending', 99.90, '2027-03-02');
SELECT create_month_partitions('2027-03-01', '2027-03-01');
ERROR: cannot detach partitions concurrently when a default partition exists
INSERT 0 1
ERROR: updated partition constraint for default partition "orders_default" would be violated by some row
CONTEXT: SQL statement "CREATE TABLE orders_2027_03 PARTITION OF orders_p FOR VALUES FROM ('2027-03-01') TO ('2027-04-01')"
PL/pgSQL function create_month_partitions(date,date) line 8 at EXECUTE
Default partition varken CONCURRENTLY kullanılamıyor, yani arşiv job'u parent'ı kilitlemek zorunda kalıyor. Default'a düşen bir satır da o ayın partition'ının açılmasını engelliyor; satırı elle taşımadan job çalışmıyor. Ayrıca her yeni partition eklenirken PostgreSQL default partition'ı tarayıp yeni aralığa ait satır olmadığını doğrular. Bookstore'da default partition kullanmıyoruz; onun yerine job önümüzdeki 3 ayı önceden açıyor ve "en ileri partition 2 aydan yakınsa" bir alarm var. Böylece job birkaç gün çalışmasa bile yazmalar durmuyor.
Bu otomasyon için hazır araçlar da var: pg_partman extension'ı partition açma, retention ve arşivlemeyi yönetir; pg_cron ise bakım fonksiyonunu veritabanının içinden zamanlar. İkisini lab'da kurmadık. Managed servislerde hangi extension'ların desteklendiğini kontrol etmek gerekir. EF Core partition'lı tabloyu model olarak tanımaz; tabloyu ve fonksiyonu bir migration içinde migrationBuilder.Sql ile oluşturursun, EF sorgularında ise orders_p sıradan bir tablo gibi görünür. Composite primary key'i entity'de HasKey(o => new { o.OrderId, o.CreatedAt }) ile tanımlamayı unutma; aksi hâlde EF'in bildiği key ile veritabanındaki ayrışır.
VACUUM, ANALYZE ve PostgreSQL 18
Autovacuum partition'lı parent'ı işlemez, yalnız child'ları işler. Parent veri tutmadığı için VACUUM'a ihtiyacı yok, ama planner bütün tabloya yayılan sorgular için parent'ın istatistiklerini kullanır ve bu istatistikleri yalnız elle çalıştırılan ANALYZE üretir:
relname | n_mod_since_analyze | manual | auto
----------------+---------------------+--------+------
orders_2026_11 | 0 | t | t
orders_p | 0 | t | f
pgbench'in eklediği 182 bin siparişten sonra autovacuum ayın partition'ını analiz etmiş, parent'ı ise hiç analiz etmemiş. PostgreSQL 18'de iki değişiklik var: VACUUM ve ANALYZE artık parent'a verildiğinde child'ları da varsayılan olarak işliyor, ve yeni ONLY seçeneği yalnız parent'ı işliyor:
ANALYZE ONLY orders_p;
ANALYZE orders_p;
ANALYZE
Time: 43.883 ms
ANALYZE
Time: 590.088 ms
Parent'ın istatistiklerini yenilemek için child'ları tekrar analiz etmek gerekmiyor; bakım job'una günde bir ANALYZE ONLY orders_p eklemek yeterli.
Üretimde Dikkat
Yarım kalan detach'i izle. DETACH ... CONCURRENTLY iki transaction'da çalıştığı için job ikisinin arasında öldürülürse partition "detach pending" durumunda kalır. PostgreSQL dokümanına göre bu durumda ALTER TABLE ... DETACH PARTITION ... FINALIZE işi tamamlar. Job her çalışmada önce bu durumdaki partition'ları kontrol etmeli (pg_inherits.inhdetachpending).
Uzun transaction'lar detach'i bekletir. Lab'da 8 saniyelik rapor detach'i 7 saniye bekletti; saatlerce açık kalan bir transaction ya da unutulmuş bir idle in transaction session'ı job'u saatlerce bekletir. Job'a bir lock_timeout ver ve zaman aşımında bir sonraki gün tekrar denesin.
Partition key'i sorgulara taşı. Raporların ve listelerin çoğu tarih aralığı içeriyorsa partitioning'in faydası sorgulara da yansır. "Bütün zamanların en çok satan kitapları" gibi sorgular tüm partition'lara gider; bunları ayrı bir özet tabloya ya da raporlama veritabanına almak daha iyi.
Replica'da da aynı tablolar var. Detach ve SET SCHEMA catalog değişikliği olarak WAL ile replica'ya gider ve küçüktür; 03'teki toplu silmenin 22 MB'lık lag'i burada oluşmaz. Ama CONCURRENTLY'nin beklediği eski transaction'lara replica'daki sorgular dahil değildir; replica'da eski partition'ı okuyan bir rapor, 06'daki hot standby conflict'iyle iptal edilebilir.
Sık Düşülen Tuzaklar
Partitioning'i performans için yapmak: "Siparişlerim" sorgusu partition'lı tabloda 7 kat fazla buffer okudu. Önce index ve sorgu, sonra partitioning.
Çok küçük partition'lar: günlük partition'larda (1.522 tane) aynı sorgunun yalnız planlaması 49 ms sürdü. Partition'ı arşiv ve bakım biriminize göre seçin.
Partition key'i bir fonksiyonun içine almak: WHERE created_at::date = '2026-10-15' ya da date_trunc('month', created_at) = ... pruning'i kapatır; aralık olarak yazın (created_at >= ... AND created_at < ...).
Default partition'ı güvenlik ağı sanmak: CONCURRENTLY detach'i engeller ve yanlış tarihli bir satır yeni ayın açılmasını durdurur. Önceden açılan partition'lar ve bir alarm daha güvenli.
Foreign key'leri unutmak: referans veren tablonun partition'ı ayrıldığında foreign key ayrılan tabloda kalır ve parent'ın partition'ının ayrılmasını engeller. Sırayı job'a kodla.
Küçük Bir Deneme
★☆☆ "Siparişlerim" sorgusuna AND created_at >= now() - interval '2 years' ekle. Plan'daki partition sayısı ve okunan buffer ne oluyor?
★★☆ orders_p'yi customer_id'ye göre HASH ile 8 parçaya bölen bir ikinci seviye ekle (sub-partitioning: her ayın partition'ı kendi içinde hash'e bölünür). "Siparişlerim" ve aylık ciro sorgularının plan'larını tek seviyeli tabloyla kıyasla.
★★★ Arşiv job'una lock_timeout = '5s' ekle ve detach'in ortasında job'u öldür. Partition'ın inhdetachpending durumunu bul, FINALIZE ile tamamlayan bir kurtarma adımı yaz ve job'u tekrar çalıştırarak doğrula.
Sonraki yazıda aynı işi SQL Server'da yapıyoruz: partition function ve scheme, RANGE LEFT ile RANGE RIGHT arasındaki fark, aligned index, execution plan'da partition elimination ve en eski ayı SWITCH ile anında arşive taşıyan sliding window.