PostgreSQL’de Partition Table Tasarımı ve Yönetimi

Büyük veri tablolarıyla çalışırken er ya da geç “bu sorgu neden bu kadar yavaş?” sorusunu kendine sorarsın. Milyonlarca, hatta milyarlarca satır içeren bir tabloda WHERE tarih BETWEEN '2023-01-01' AND '2023-03-31' gibi bir sorgu çalıştırdığında PostgreSQL’in tüm tabloyu taraması gerekebilir. İşte bu noktada partition (bölümleme) devreye giriyor. PostgreSQL’deki partition table tasarımı, doğru kurulduğunda hem sorgu performansını dramatik biçimde artırır hem de veri yönetimini kolaylaştırır. Bu yazıda gerçek dünya senaryolarıyla birlikte partition table tasarımını baştan sona inceleyeceğiz.

Partition Table Nedir ve Neden Gerekli?

Partition table, tek bir büyük tabloyu mantıksal olarak daha küçük parçalara ayırma yöntemidir. PostgreSQL 10’dan itibaren declarative partitioning (bildirimsel bölümleme) desteği geldi ve PostgreSQL 12 ile birlikte bu sistem son derece olgunlaştı.

Partition kullanmayı düşünmen gereken durumlar:

  • Tablo boyutu 100 GB’ı aştığında
  • Tablonun büyük bölümü sorgulanmıyor, sadece belirli zaman dilimleri veya kategoriler sorgulanıyorsa
  • Eski verilerin düzenli olarak silinmesi veya arşivlenmesi gerekiyorsa
  • VACUUM işlemlerinin çok uzun sürdüğünü fark ettiğinde
  • Farklı partition’ların farklı tablespace’lerde saklanması gerektiğinde

Partition kullanmanın temel faydası partition pruning mekanizmasıdır. PostgreSQL, sorgu planlarken hangi partition’ların ilgili olduğunu anlayarak gereksiz olanları tamamen atlar. 12 aylık veriye bölünmüş bir tabloda Ocak ayı verisini sorgularken sadece Ocak partition’ına bakar, geri kalanı görmezden gelir.

PostgreSQL’de Partition Türleri

PostgreSQL üç temel partition türü sunar:

  • RANGE partitioning: Tarih, sayı gibi aralık değerlerine göre bölümleme. En yaygın kullanılan yöntem
  • LIST partitioning: Belirli değer listelerine göre bölümleme. Ülke kodu, kategori gibi sınırlı değer kümelerinde kullanılır
  • HASH partitioning: Hash değerine göre bölümleme. Verinin dengeli dağılması istendiğinde tercih edilir

Range Partitioning: Tarih Bazlı Bölümleme

En klasik kullanım senaryosu tarih bazlı log veya sipariş tablolarıdır. Bir e-ticaret sistemindeki sipariş tablosunu düşünelim:

-- Ana partition tablosunu oluştur
CREATE TABLE siparisler (
    siparis_id      BIGSERIAL,
    musteri_id      INTEGER NOT NULL,
    siparis_tarihi  DATE NOT NULL,
    toplam_tutar    NUMERIC(12,2),
    durum           VARCHAR(20),
    olusturma_zamani TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (siparis_tarihi);

-- 2023 yılı için aylık partition'lar oluştur
CREATE TABLE siparisler_2023_01 PARTITION OF siparisler
    FOR VALUES FROM ('2023-01-01') TO ('2023-02-01');

CREATE TABLE siparisler_2023_02 PARTITION OF siparisler
    FOR VALUES FROM ('2023-02-01') TO ('2023-03-01');

CREATE TABLE siparisler_2023_03 PARTITION OF siparisler
    FOR VALUES FROM ('2023-03-01') TO ('2023-04-01');

-- Default partition - tanımlanmamış aralıklar için
CREATE TABLE siparisler_default PARTITION OF siparisler DEFAULT;

Partition oluşturduktan sonra index eklemeyi unutmamalısın. Ana tablo üzerinde oluşturulan index otomatik olarak tüm partition’lara yansır:

-- Ana tablo üzerinde index oluştur, tüm partition'lara otomatik uygulanır
CREATE INDEX idx_siparisler_musteri ON siparisler (musteri_id);
CREATE INDEX idx_siparisler_durum ON siparisler (durum, siparis_tarihi);

-- Partition pruning'in çalıştığını doğrula
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM siparisler
WHERE siparis_tarihi BETWEEN '2023-01-01' AND '2023-01-31'
  AND musteri_id = 12345;

EXPLAIN çıktısında sadece siparisler_2023_01 partition’ının tarandığını göreceksin. Diğer partition’lar için Subplans Removed ibaresini arayabilirsin.

List Partitioning: Kategori Bazlı Bölümleme

Farklı ülkelere hizmet veren bir SaaS uygulamasında kullanıcı verilerini ülkeye göre bölümlemek hem performans hem de veri egemenliği (data sovereignty) açısından avantajlı olabilir:

-- Ülke bazlı list partition örneği
CREATE TABLE kullanici_etkinlikleri (
    etkinlik_id   BIGSERIAL,
    kullanici_id  INTEGER NOT NULL,
    ulke_kodu     CHAR(2) NOT NULL,
    etkinlik_tipi VARCHAR(50),
    etkinlik_zamani TIMESTAMPTZ DEFAULT NOW(),
    meta_data     JSONB
) PARTITION BY LIST (ulke_kodu);

CREATE TABLE kullanici_etkinlikleri_tr PARTITION OF kullanici_etkinlikleri
    FOR VALUES IN ('TR');

CREATE TABLE kullanici_etkinlikleri_de PARTITION OF kullanici_etkinlikleri
    FOR VALUES IN ('DE');

CREATE TABLE kullanici_etkinlikleri_fr PARTITION OF kullanici_etkinlikleri
    FOR VALUES IN ('FR');

-- Diğer tüm ülkeler için
CREATE TABLE kullanici_etkinlikleri_diger PARTITION OF kullanici_etkinlikleri
    FOR VALUES IN ('US', 'GB', 'NL', 'ES', 'IT');

-- Her partition'a özel tablespace atayabilirsin
-- Bu özellik ülke bazlı veri depolama gereksinimi olan durumlarda işe yarar
CREATE TABLESPACE ts_eu LOCATION '/mnt/eu-storage/postgresql';
ALTER TABLE kullanici_etkinlikleri_de SET TABLESPACE ts_eu;
ALTER TABLE kullanici_etkinlikleri_fr SET TABLESPACE ts_eu;

Hash Partitioning: Dengeli Dağılım

Veriyi belirli bir kritere göre değil, dengeli biçimde dağıtmak istiyorsan hash partitioning kullanırsın. Özellikle çok sayıda küçük müşteriye ait verinin depolandığı sistemlerde kullanışlıdır:

-- Hash partitioning ile 4 eşit parçaya bölme
CREATE TABLE urun_katalogu (
    urun_id     BIGSERIAL,
    urun_kodu   VARCHAR(50) NOT NULL,
    urun_adi    VARCHAR(200),
    fiyat       NUMERIC(10,2),
    stok_adedi  INTEGER DEFAULT 0
) PARTITION BY HASH (urun_id);

CREATE TABLE urun_katalogu_p0 PARTITION OF urun_katalogu
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE urun_katalogu_p1 PARTITION OF urun_katalogu
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE urun_katalogu_p2 PARTITION OF urun_katalogu
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE urun_katalogu_p3 PARTITION OF urun_katalogu
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Alt Partition (Sub-Partitioning) Tasarımı

Gerçek dünya senaryolarında tek seviyeli partition yeterli olmayabilir. Hem tarihe hem de bölgeye göre bölümleme yapmak istediğinde iç içe partition yapısı kurarsın:

-- Ana tablo: önce yıla göre range partition
CREATE TABLE satis_verileri (
    satis_id        BIGSERIAL,
    tarih           DATE NOT NULL,
    bolge           VARCHAR(20) NOT NULL,
    satis_temsilcisi INTEGER,
    tutar           NUMERIC(14,2),
    urun_id         INTEGER
) PARTITION BY RANGE (tarih);

-- 2023 yılı partition'u, kendi içinde bölgeye göre sub-partition
CREATE TABLE satis_verileri_2023 PARTITION OF satis_verileri
    FOR VALUES FROM ('2023-01-01') TO ('2024-01-01')
    PARTITION BY LIST (bolge);

-- 2023 yılı altında bölge partition'ları
CREATE TABLE satis_verileri_2023_kuzey PARTITION OF satis_verileri_2023
    FOR VALUES IN ('KUZEY');

CREATE TABLE satis_verileri_2023_guney PARTITION OF satis_verileri_2023
    FOR VALUES IN ('GUNEY');

CREATE TABLE satis_verileri_2023_bati PARTITION OF satis_verileri_2023
    FOR VALUES IN ('BATI');

CREATE TABLE satis_verileri_2023_dogu PARTITION OF satis_verileri_2023
    FOR VALUES IN ('DOGU');

-- Veri ekle ve partition routing'in çalışmasını kontrol et
INSERT INTO satis_verileri (tarih, bolge, tutar, urun_id)
VALUES ('2023-06-15', 'KUZEY', 1500.00, 42);

-- Hangi partition'a gittiğini kontrol et
SELECT tableoid::regclass, * FROM satis_verileri
WHERE tarih = '2023-06-15';

Otomatik Partition Yönetimi

Canlı sistemlerde her ay yeni partition oluşturmayı unutmak büyük sorun yaratır. Default partition varsa veri oraya düşer, yoksa insert hatası alırsın. Bu yüzden otomatik partition oluşturma mekanizması kurman şart:

-- Otomatik partition oluşturan fonksiyon
CREATE OR REPLACE FUNCTION aylik_partition_olustur(
    p_tablo_adi TEXT,
    p_tarih DATE
) RETURNS VOID AS $$
DECLARE
    v_partition_adi TEXT;
    v_baslangic DATE;
    v_bitis DATE;
    v_sql TEXT;
BEGIN
    v_baslangic := DATE_TRUNC('month', p_tarih);
    v_bitis := v_baslangic + INTERVAL '1 month';
    v_partition_adi := p_tablo_adi || '_' ||
                       TO_CHAR(v_baslangic, 'YYYY_MM');

    -- Partition zaten var mı kontrol et
    IF NOT EXISTS (
        SELECT 1 FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE c.relname = v_partition_adi
    ) THEN
        v_sql := FORMAT(
            'CREATE TABLE %I PARTITION OF %I
             FOR VALUES FROM (%L) TO (%L)',
            v_partition_adi,
            p_tablo_adi,
            v_baslangic,
            v_bitis
        );
        EXECUTE v_sql;

        RAISE NOTICE 'Partition olusturuldu: %', v_partition_adi;
    ELSE
        RAISE NOTICE 'Partition zaten mevcut: %', v_partition_adi;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Gelecek 3 ay için partition'ları oluştur
SELECT aylik_partition_olustur('siparisler', NOW()::DATE);
SELECT aylik_partition_olustur('siparisler', (NOW() + INTERVAL '1 month')::DATE);
SELECT aylik_partition_olustur('siparisler', (NOW() + INTERVAL '2 month')::DATE);

-- Bu fonksiyonu pg_cron ile otomatik çalıştır
-- Ayın son günü çalışacak şekilde zamanla
SELECT cron.schedule(
    'partition-olustur',
    '0 0 25 * *',
    $$SELECT aylik_partition_olustur('siparisler',
      (NOW() + INTERVAL '1 month')::DATE)$$
);

Eski Partition’ları Arşivleme ve Silme

Partition table kullanmanın en büyük pratik faydalarından biri eski verileri silmenin son derece hızlı olmasıdır. Normal bir tabloda DELETE FROM siparisler WHERE siparis_tarihi < '2022-01-01' işlemi saatler sürebilir ve yoğun I/O oluşturur. Partition’larda ise tek komut yeterlidir:

-- Eski partition'ı tamamen bırak (saniyeler içinde tamamlanır)
DROP TABLE siparisler_2021_01;

-- Ya da veriyi silmeden önce arşivle
-- Önce partition'ı ana tablodan ayır (detach)
ALTER TABLE siparisler DETACH PARTITION siparisler_2021_01;

-- Artık bağımsız bir tablo olan eski partition'ı arşiv sunucusuna taşı
-- ya da sıkıştırılmış formata dönüştür
ALTER TABLE siparisler_2021_01 SET TABLESPACE ts_arsiv;

-- İstersen tekrar bağlayabilirsin
ALTER TABLE siparisler ATTACH PARTITION siparisler_2021_01
    FOR VALUES FROM ('2021-01-01') TO ('2021-02-01');

-- Arşiv yönetimi için örnek prosedür
CREATE OR REPLACE PROCEDURE eski_partition_arsivle(
    p_tablo_adi TEXT,
    p_kesim_tarihi DATE
) LANGUAGE plpgsql AS $$
DECLARE
    v_partition RECORD;
BEGIN
    FOR v_partition IN
        SELECT c.relname
        FROM pg_class c
        JOIN pg_inherits i ON i.inhrelid = c.oid
        JOIN pg_class p ON p.oid = i.inhparent
        WHERE p.relname = p_tablo_adi
          AND c.relname < p_tablo_adi || '_' ||
              TO_CHAR(p_kesim_tarihi, 'YYYY_MM')
    LOOP
        RAISE NOTICE 'Arsivleniyor: %', v_partition.relname;
        EXECUTE FORMAT('ALTER TABLE %I DETACH PARTITION %I CONCURRENTLY',
                       p_tablo_adi, v_partition.relname);
    END LOOP;
END;
$$;

Partition Table’larda Dikkat Edilmesi Gereken Noktalar

PRIMARY KEY ve UNIQUE kısıtlamaları konusunda dikkatli olmalısın. Partition key sütunu primary key veya unique constraint’in bir parçası olmak zorundadır. Aksi halde PostgreSQL hata verir:

-- YANLIS: Partition key dahil edilmeden primary key oluşturmak hata verir
-- CREATE TABLE test (...) PARTITION BY RANGE (tarih);
-- ALTER TABLE test ADD PRIMARY KEY (id);  -- HATA!

-- DOGRU: Partition key'i primary key'e dahil et
CREATE TABLE log_kayitlari (
    kayit_id    BIGSERIAL,
    tarih       DATE NOT NULL,
    log_mesaji  TEXT,
    seviye      VARCHAR(10),
    PRIMARY KEY (kayit_id, tarih)  -- tarih partition key olarak dahil
) PARTITION BY RANGE (tarih);

-- Foreign key referansları partition edilmiş tablolara verilebilir
-- Ancak partition edilmiş tablodan diğerine foreign key VERILEMEZ
-- Bu kısıtlamayı uygulama katmanında yönetmek gerekir

Constraint exclusion ve partition pruning ayarlarını kontrol et:

-- postgresql.conf ayarlarını kontrol et
SHOW enable_partition_pruning;
SHOW constraint_exclusion;

-- Performans testleri için
SET enable_partition_pruning = on;

-- Kaç partition'ın tarandığını görmek için
EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT COUNT(*) FROM siparisler
WHERE siparis_tarihi >= '2023-06-01'
  AND siparis_tarihi < '2023-07-01';

Partition Table İzleme ve Boyut Takibi

Partition’larını düzenli olarak izlemek, hangisinin şiştiğini ve ne zaman yeni partition açman gerektiğini anlamana yardımcı olur:

-- Tüm partition'ların boyutlarını listele
SELECT
    c.relname AS partition_adi,
    pg_size_pretty(pg_relation_size(c.oid)) AS tablo_boyutu,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS toplam_boyut,
    pg_stat_get_live_tuples(c.oid) AS canli_satir,
    pg_stat_get_dead_tuples(c.oid) AS olu_satir,
    TO_CHAR(
        CAST(pg_stat_get_dead_tuples(c.oid) AS FLOAT) /
        NULLIF(pg_stat_get_live_tuples(c.oid) + pg_stat_get_dead_tuples(c.oid), 0) * 100,
        'FM990.00'
    ) || '%' AS olue_satir_orani
FROM pg_class c
JOIN pg_inherits i ON i.inhrelid = c.oid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'siparisler'
ORDER BY c.relname;

-- Partition'lara yapılan sorgu istatistiklerini incele
SELECT
    schemaname,
    tablename,
    seq_scan,
    idx_scan,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum,
    last_analyze
FROM pg_stat_user_tables
WHERE tablename LIKE 'siparisler%'
ORDER BY tablename;

pg_partman ile Yönetim Kolaylaştırma

Büyük ortamlarda partition yönetimini manuel yapmak zaman alıcıdır. pg_partman eklentisi bu işi otomatize eder:

-- pg_partman kurulumu (Debian/Ubuntu)
sudo apt-get install postgresql-15-partman

-- PostgreSQL'de aktifleştir
CREATE SCHEMA partman;
CREATE EXTENSION pg_partman SCHEMA partman;

-- pg_partman ile partition yönetimi
SELECT partman.create_parent(
    p_parent_table => 'public.siparisler',
    p_control => 'siparis_tarihi',
    p_type => 'native',
    p_interval => 'monthly',
    p_premake => 3  -- 3 ay önceden partition oluştur
);

-- Periyodik bakım için (pg_cron ile birlikte kullan)
SELECT partman.run_maintenance('public.siparisler');

-- pg_partman konfigürasyonunu görüntüle
SELECT * FROM partman.part_config
WHERE parent_table = 'public.siparisler';

Gerçek Dünya Senaryosu: IoT Veri Platformu

Bir fabrikada binlerce sensörden saniyede milyonlarca ölçüm verisi gelen bir sistem düşün. Bu tür sistemlerde partition tasarımı hayati önem taşır:

-- Sensör verisi için optimize edilmiş partition yapısı
CREATE TABLE sensor_olcumleri (
    olcum_id    BIGSERIAL,
    sensor_id   INTEGER NOT NULL,
    olcum_zamani TIMESTAMPTZ NOT NULL,
    deger       DOUBLE PRECISION,
    birim       VARCHAR(10),
    kalite_kodu SMALLINT DEFAULT 0
) PARTITION BY RANGE (olcum_zamani);

-- Günlük partition'lar (yüksek veri hacmi için daha granüler)
CREATE TABLE sensor_olcumleri_2024_01_01 PARTITION OF sensor_olcumleri
    FOR VALUES FROM ('2024-01-01 00:00:00+00')
    TO ('2024-01-02 00:00:00+00');

-- Her partition için sensör bazlı index
CREATE INDEX idx_sensor_2024_01_01
ON sensor_olcumleri_2024_01_01 (sensor_id, olcum_zamani DESC);

-- TimescaleDB yoksa bile bu yapıyla ciddi performans kazanımı elde edilir
-- Son 1 saatin verisi için sorgu
SELECT
    sensor_id,
    AVG(deger) AS ortalama,
    MIN(deger) AS minimum,
    MAX(deger) AS maksimum
FROM sensor_olcumleri
WHERE olcum_zamani >= NOW() - INTERVAL '1 hour'
GROUP BY sensor_id
ORDER BY sensor_id;

-- Partition pruning devreye gireceği için yalnızca ilgili günlük partition taranır
-- EXPLAIN ile doğrula:
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM sensor_olcumleri
WHERE olcum_zamani >= NOW() - INTERVAL '1 hour';

Yaygın Hatalar ve Çözümleri

Partition key üzerinde fonksiyon kullanmak partition pruning’i bozar:

-- YANLIS: Fonksiyon kullanımı pruning'i devre disi birakir
SELECT * FROM siparisler
WHERE DATE_TRUNC('month', siparis_tarihi) = '2023-06-01';

-- DOGRU: Aralık sorgusu kullan
SELECT * FROM siparisler
WHERE siparis_tarihi >= '2023-06-01'
  AND siparis_tarihi < '2023-07-01';

-- DOGRU: EXTRACT yerine aralık kullan
-- YANLIS:
-- WHERE EXTRACT(YEAR FROM siparis_tarihi) = 2023

-- DOGRU:
-- WHERE siparis_tarihi >= '2023-01-01' AND siparis_tarihi < '2024-01-01'

VACUUM ve ANALYZE yönetimi konusunda da dikkatli olmak gerekir. Her partition bağımsız bir tablo gibi davrandığından autovacuum her biri için ayrı ayrı çalışır. Bu bazen çok sayıda eş zamanlı VACUUM işlemine yol açabilir:

-- Aktif VACUUM işlemlerini izle
SELECT pid, query, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE query LIKE '%VACUUM%' OR query LIKE '%autovacuum%';

-- Autovacuum eşiklerini partition bazında ayarla
ALTER TABLE siparisler_2023_01
SET (autovacuum_vacuum_scale_factor = 0.01,
     autovacuum_analyze_scale_factor = 0.005);

Sonuç

PostgreSQL partition table tasarımı, büyük veri tablolarını yönetmenin en etkili yollarından biridir. Doğru uygulandığında sorgu performansını katlarca artırır, bakım operasyonlarını kolaylaştırır ve veri yaşam döngüsü yönetimini basitleştirir.

Özetlemek gerekirse dikkat etmen gereken kritik noktalar şunlar:

  • Partition key seçimini sorgularının WHERE koşuluna göre yap, partition pruning olmadan kazanım olmaz
  • Primary key ve unique constraint’lere partition key sütununu dahil et
  • Partition’larını önceden oluştur, default partition’a güvenme
  • Sorguları partition pruning bozacak şekilde yazma, fonksiyon kullanımından kaçın
  • pg_partman gibi araçlarla yönetimi otomatize et
  • Partition boyutlarını ve istatistiklerini düzenli izle

Yeni başlıyorsan önce tarih bazlı range partitioning ile küçük adımlarla başla. Log tablolarından başlamak iyi bir tercih çünkü hem yüksek hacimli hem de eski verilerin düzenli temizlenmesi gerekiyor. Sistemin olgunlaştıkça sub-partitioning ve pg_partman entegrasyonuna geçebilirsin. Partition tasarımı bir kez kurulup unutulan bir şey değil, büyüyen sisteminle birlikte evrilmesi gereken süregelen bir mimari karardır.

Yorum yapın