EFEF Core Handbook

UZMAN

Table Splitting & Entity Splitting

Tek tabloyu birden fazla entity olarak okumak (Table Splitting — büyük BLOB sütunlarını lazy load etmek için) veya tek entity'yi birden fazla tabloya yaymak (Entity Splitting — sık/nadir okunan alanları ayırmak için) imkanı verir.

Veritabanı sağlayıcısı Bu sayfadaki eşleşen örnekleri seçilen sağlayıcıya göre gösterir.

Table Splitting — Büyük Tabloları Parçala

// Senaryo: Products tablosunda büyük bir Description NVARCHAR(MAX) var.
// Listede lazım değil ama detayda lazım → ayrı entity'ye çıkar.

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public decimal Price { get; set; }
    
    // Navigation — aynı tablodaki "detay" kısmı
    public ProductDetail Detail { get; set; } = null!;
}

public class ProductDetail
{
    public int Id { get; set; }             // Aynı PK
    public string Description { get; set; } = null!;
    public string Specifications { get; set; } = null!;
    public byte[]? MainImage { get; set; }
    
    public Product Product { get; set; } = null!;
}

// Configuration
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products");
        builder.HasOne(p => p.Detail)
               .WithOne(d => d.Product)
               .HasForeignKey<ProductDetail>(d => d.Id);
    }
}

public class ProductDetailConfiguration : IEntityTypeConfiguration<ProductDetail>
{
    public void Configure(EntityTypeBuilder<ProductDetail> builder)
    {
        // Aynı tabloya map!
        builder.ToTable("Products");
    }
}

SQL (Table Splitting — Tek Tablo)

-- Tek tablo ama EF iki entity olarak görür
CREATE TABLE [Products] (
    [Id]             INT            IDENTITY(1,1) NOT NULL,
    [Name]           NVARCHAR(200)  NOT NULL,
    [Price]          DECIMAL(18,2)  NOT NULL,
    [Description]    NVARCHAR(MAX)  NOT NULL,      -- ProductDetail
    [Specifications] NVARCHAR(MAX)  NOT NULL,      -- ProductDetail
    [MainImage]      VARBINARY(MAX) NULL,          -- ProductDetail
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
-- Tek tablo ama EF iki entity olarak görür
CREATE TABLE products (
    id             INTEGER GENERATED ALWAYS AS IDENTITY,
    name           VARCHAR(200) NOT NULL,
    price          NUMERIC(18,2) NOT NULL,
    description    TEXT NOT NULL,              -- ProductDetail
    specifications TEXT NOT NULL,              -- ProductDetail
    main_image     BYTEA NULL,                -- ProductDetail (VARBINARY → BYTEA)
    CONSTRAINT pk_products PRIMARY KEY (id)
);

Kullanım Avantajı

// Listelemede sadece hafif verileri çek
var products = await context.Products
    .Select(p => new { p.Id, p.Name, p.Price })
    .ToListAsync();
// SQL: SELECT Id, Name, Price FROM Products (Description yüklenmez!)

// Detayda her şeyi çek
var product = await context.Products
    .Include(p => p.Detail)
    .FirstOrDefaultAsync(p => p.Id == 42);
// SQL: SELECT Id, Name, Price, Description, Specifications, MainImage FROM Products WHERE Id = 42

Entity Splitting (EF Core 7+) — Bir Entity Birden Fazla Tabloda

// Tek entity ama veriler farklı tablolarda
public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    
    // Bunlar ayrı tabloda olacak
    public string Phone { get; set; } = null!;
    public string Email { get; set; } = null!;
    public string Address { get; set; } = null!;
}

// Configuration
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.ToTable("Customers");
        
        builder.SplitToTable("CustomerContacts", split =>
        {
            split.Property(c => c.Id);       // PK her tabloda
            split.Property(c => c.Phone);
            split.Property(c => c.Email);
            split.Property(c => c.Address);
        });
    }
}

SQL (Entity Splitting — İki Tablo)

CREATE TABLE [Customers] (
    [Id]   INT           IDENTITY(1,1) NOT NULL,
    [Name] NVARCHAR(MAX) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);

CREATE TABLE [CustomerContacts] (
    [Id]      INT           NOT NULL,
    [Phone]   NVARCHAR(MAX) NOT NULL,
    [Email]   NVARCHAR(MAX) NOT NULL,
    [Address] NVARCHAR(MAX) NOT NULL,
    CONSTRAINT [PK_CustomerContacts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_CustomerContacts_Customers] 
        FOREIGN KEY ([Id]) REFERENCES [Customers]([Id]) ON DELETE CASCADE
);
CREATE TABLE customers (
    id   INTEGER GENERATED ALWAYS AS IDENTITY,
    name TEXT NOT NULL,
    CONSTRAINT pk_customers PRIMARY KEY (id)
);

CREATE TABLE customer_contacts (
    id      INTEGER NOT NULL,
    phone   TEXT NOT NULL,
    email   TEXT NOT NULL,
    address TEXT NOT NULL,
    CONSTRAINT pk_customer_contacts PRIMARY KEY (id),
    CONSTRAINT fk_customer_contacts_customers 
        FOREIGN KEY (id) REFERENCES customers(id) ON DELETE CASCADE
);
Customers int IdPK string Name CustomerContacts int IdPK,FK string Phone string Email string Address 1 1 Entity Splitting

PostgreSQL Table Partitioning

SQL Server'da table splitting uygulama seviyesinde yapılır. PostgreSQL'de ise veritabanı seviyesinde native partitioning vardır — milyonlarca satırlı tablolarda büyük performans kazanımı sağlar.

PostgreSQL Partitioning Nedir?

Tek bir mantıksal tabloyu, fiziksel olarak birden fazla alt tabloya (partition) böler. Uygulama ve EF Core tek tablo görür — PostgreSQL arka planda doğru partition'a yönlendirir.

orders (mantıksal tablo) orders_2024 (2024 data) orders_2025 (2025 data) orders_2026 (2026 data) ... SELECT * FROM orders WHERE order_date = '2026-05-18' ; → PG sadece orders_2026 partition tarar (Partition Pruning)

Partitioning Stratejileri

Strateji Kullanım Örnek
RANGE Zaman serisi, tarih bazlı Siparişler yıl/ay bazlı
LIST Kategorik veri, sabit değerler Ülke, bölge, tenant
HASH Eşit dağılım, belirli bir kolon yok User ID hash'ine göre

1. Range Partitioning (Tarih Bazlı) — En Yaygın

-- Parent tablo (partitioned):
CREATE TABLE orders (
    id           BIGINT GENERATED ALWAYS AS IDENTITY,
    customer_id  INT NOT NULL,
    order_date   DATE NOT NULL,
    total_amount NUMERIC(18,2) NOT NULL,
    status       TEXT NOT NULL,
    CONSTRAINT pk_orders PRIMARY KEY (id, order_date)  -- ⚠️ Partition key PK'da olmalı!
) PARTITION BY RANGE (order_date);

-- Partition'lar:
CREATE TABLE orders_2024 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

CREATE TABLE orders_2025 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

CREATE TABLE orders_2026 PARTITION OF orders
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

-- Her partition'a ayrı index:
CREATE INDEX ix_orders_2024_customer ON orders_2024 (customer_id);
CREATE INDEX ix_orders_2025_customer ON orders_2025 (customer_id);
CREATE INDEX ix_orders_2026_customer ON orders_2026 (customer_id);

2. List Partitioning (Kategorik)

-- Multi-tenant senaryo:
CREATE TABLE invoices (
    id         BIGINT GENERATED ALWAYS AS IDENTITY,
    tenant_id  TEXT NOT NULL,
    amount     NUMERIC(18,2) NOT NULL,
    issued_at  TIMESTAMPTZ NOT NULL,
    CONSTRAINT pk_invoices PRIMARY KEY (id, tenant_id)
) PARTITION BY LIST (tenant_id);

CREATE TABLE invoices_acme    PARTITION OF invoices FOR VALUES IN ('acme');
CREATE TABLE invoices_globex  PARTITION OF invoices FOR VALUES IN ('globex');
CREATE TABLE invoices_initech PARTITION OF invoices FOR VALUES IN ('initech');

-- Yeni tenant geldiğinde:
CREATE TABLE invoices_newcorp PARTITION OF invoices FOR VALUES IN ('newcorp');

3. Hash Partitioning (Eşit Dağılım)

-- Çok büyük tablo, belirli bir range/list yok:
CREATE TABLE events (
    id         BIGINT GENERATED ALWAYS AS IDENTITY,
    user_id    INT NOT NULL,
    event_type TEXT NOT NULL,
    payload    JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT pk_events PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

-- 4 eşit partition:
CREATE TABLE events_p0 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_p1 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_p2 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_p3 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 3);

EF Core ile Partitioned Table Kullanımı

EF Core partitioning'den habersizdir — tek tablo olarak görür. Ancak bazı önemli konfigürasyon gereksinimleri vardır:

// Entity — normal tanımlanır:
public class Order
{
    public long Id { get; set; }
    public int CustomerId { get; set; }
    public DateOnly OrderDate { get; set; }    // Partition key!
    public decimal TotalAmount { get; set; }
    public string Status { get; set; }
}

// Configuration:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("orders");

        // ⚠️ KRİTİK: Partition key composite PK'da olmalı!
        // PostgreSQL kısıtı: UNIQUE ve PK constraint'ler partition key'i İÇERMELİ
        builder.HasKey(o => new { o.Id, o.OrderDate });

        builder.Property(o => o.Id)
               .UseIdentityAlwaysColumn();

        builder.Property(o => o.TotalAmount)
               .HasPrecision(18, 2);
    }
}

En Önemli Kural: PostgreSQL'de partitioned tablolarda PRIMARY KEY ve UNIQUE constraint'ler partition key'i içermek zorundadır. Bu yüzden Id tek başına PK olamaz → (Id, OrderDate) composite PK gerekir.

Migration'da Partitioning (Raw SQL ile)

EF Core migration'ları partitioned tablo oluşturamaz — raw SQL ile kendin yazarsın.

public partial class CreatePartitionedOrders : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // EF'in normal CreateTable'ını KULLANMA — raw SQL yaz:
        migrationBuilder.Sql(@"
            CREATE TABLE orders (
                id           BIGINT GENERATED ALWAYS AS IDENTITY,
                customer_id  INT NOT NULL,
                order_date   DATE NOT NULL,
                total_amount NUMERIC(18,2) NOT NULL,
                status       TEXT NOT NULL DEFAULT 'pending',
                CONSTRAINT pk_orders PRIMARY KEY (id, order_date)
            ) PARTITION BY RANGE (order_date);

            -- 2024-2026 partition'ları
            CREATE TABLE orders_2024 PARTITION OF orders
                FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
            CREATE TABLE orders_2025 PARTITION OF orders
                FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
            CREATE TABLE orders_2026 PARTITION OF orders
                FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

            -- Index'ler (her partition'da ayrı oluşur):
            CREATE INDEX ix_orders_customer ON orders (customer_id);
            CREATE INDEX ix_orders_status ON orders (status);
        ");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("DROP TABLE orders CASCADE;");
    }
}

Partition Bakımı — Yeni Partition Ekleme

-- Her yıl başında (veya cron job ile) yeni partition ekle:
CREATE TABLE orders_2027 PARTITION OF orders
    FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');

-- Eski partition'ı arşivle (detach → ayrı tablo olur, sorgularda görünmez):
ALTER TABLE orders DETACH PARTITION orders_2022;
-- Artık orders_2022 bağımsız bir tablo — arşive taşınabilir veya silinebilir
-- ✅ Partition sayısından DÜŞER → planning overhead azalır
-- ✅ SELECT * FROM orders_2022 WHERE ... ile hâlâ sorgulanabilir (bağımsız tablo)

-- Default partition (aralık dışı veriler için güvenlik ağı):
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
-- Hiçbir partition'a uymayan veri buraya düşer

EF Core Sorguları ve Partition Pruning

// ✅ Partition key WHERE'de → PG sadece ilgili partition'ı tarar:
var orders2026 = await context.Orders
    .Where(o => o.OrderDate >= new DateOnly(2026, 1, 1)
             && o.OrderDate < new DateOnly(2027, 1, 1))
    .ToListAsync();
// SQL: SELECT ... FROM orders WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'
// → Sadece orders_2026 taranır! (Partition Pruning ✅)

// ✅ Birden fazla partition'a yayılan sorgu — PG otomatik birleştirir:
var lastTwoYears = await context.Orders
    .Where(o => o.OrderDate >= new DateOnly(2025, 1, 1)
             && o.OrderDate < new DateOnly(2027, 1, 1))
    .ToListAsync();
// → orders_2025 + orders_2026 taranır (2 partition), diğerleri atlanır

// ✅ Yıl sınırını geçen sorgular da sorunsuz:
var last6Months = await context.Orders
    .Where(o => o.OrderDate >= DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-6)))
    .ToListAsync();
// → PG: Gerekli partition'ları otomatik saptar (ör. orders_2025 + orders_2026)
// Developer partition sınırlarını bilmek ZORUNDA DEĞİL — normal sorgu yaz, PG halleder.

// ⚠️ Partition key olmadan sorgu → TÜM partition'lar taranır:
var expensive = await context.Orders
    .Where(o => o.TotalAmount > 10000)
    .ToListAsync();
// → orders_2024 + orders_2025 + orders_2026 hepsi taranır (Sequential Scan)

Partition Pruning nasıl çalışır?

WHERE order_date >= '2025-06-01' AND order_date < '2026-03-01'

PG kontrol eder:
  orders_2024 → range: [2024-01-01, 2025-01-01) → EŞLEŞMEZ → atla ✅
  orders_2025 → range: [2025-01-01, 2026-01-01) → KISMİ EŞLEŞME → tara
  orders_2026 → range: [2026-01-01, 2027-01-01) → KISMİ EŞLEŞME → tara
  orders_2027 → range: [2027-01-01, 2028-01-01) → EŞLEŞMEZ → atla ✅

Sonuç: 2/4 partition taranır. Tablo büyüdükçe (yüzlerce partition) kazanım katlanır.

Performans Karşılaştırma

Senaryo Partitioned Değil Partitioned (Range by Year)
50M satırlık orders tablosu Full scan: ~5 sn Tek partition scan: ~0.3 sn
Yıllık arşivleme DELETE milyonlarca satır (yavaş, WAL şişer) DETACH PARTITION (anında)
Index boyutu 50M satırlık dev index 3-4 küçük index (yıl başına ~15M)
VACUUM Tüm tablo (uzun lock) Partition başına (kısa)
Yeni yıl ekleme Tablo büyümeye devam CREATE PARTITION (anında)

Ne Zaman Partitioning Kullanılmalı?

Durum Partition? Neden
< 1M satır Overhead'ı haketmez, normal index yeterli
1M - 10M satır Sorgu pattern'ine bağlı (tarih filtreleme varsa evet)
> 10M satır + tarih bazlı sorgular Partition pruning dramatik fark yaratır
Arşivleme gereksinimi DETACH ile anında arşive taşıma
Multi-tenant (fiziksel izolasyon) LIST partition ile tenant bazlı ayırma
Eşit dağılımlı yazma yükü HASH partition ile paralel I/O

Partition Limitleri — Ne Kadar Partition Oluşturulabilir?

Provider Teorik Limit Pratik Önerilen Sınır Aşılırsa Ne Olur?
PostgreSQL Sınır yok (binlerce mümkün) < 1000 partition Planlama süresi artar, VACUUM yavaşlar
SQL Server 15.000 partition/tablo < 1000 Hard limit — hata verir
MySQL (InnoDB) 8192 partition/tablo < 200 Açık dosya limiti, metadata overhead

PostgreSQL'de aşırı partition'ın etkileri:

Partition Sayısı     Planlama Süresi    Pratik Durum
──────────────────   ───────────────    ─────────────────────
< 100                ~1 ms              ✅ İdeal
100 - 500            ~5-10 ms           ✅ Sorunsuz
500 - 1000           ~20-50 ms          🟡 Kabul edilebilir
1000 - 5000          ~100-500 ms        ⚠️ Query planning yavaşlar
> 5000               > 1 sn             ❌ Ciddi overhead, yeniden tasarla

Neden fazla partition sorun olur?

  • PostgreSQL her sorgu için tüm partition metadata'sını tarar (hangileri relevant diye karar vermek için)
  • Partition sayısı arttıkça EXPLAIN ANALYZE planning time şişer
  • pg_catalog metadata tabloları büyür → autovacuum, DDL operasyonları yavaşlar
  • Her partition bir dosya (8KB page'ler) → OS seviyesinde açık dosya limiti (ulimit -n)

Pratik öneriler:

-- ❌ YANLIŞ: Günlük partition (365 × yıl sayısı = binlerce)
CREATE TABLE logs_20260101 PARTITION OF logs FOR VALUES FROM ('2026-01-01') TO ('2026-01-02');
CREATE TABLE logs_20260102 PARTITION OF logs FOR VALUES FROM ('2026-01-02') TO ('2026-01-03');
-- 5 yıl = 1825 partition → planning overhead!

-- ✅ DOĞRU: Aylık partition (12 × yıl sayısı = yönetilebilir)
CREATE TABLE logs_2026_01 PARTITION OF logs FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE logs_2026_02 PARTITION OF logs FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- 5 yıl = 60 partition → ideal

-- ✅ ALTERNATİF: Sub-partitioning (çok büyük veri setleri)
-- Önce yıla göre böl, sonra her yılı aya göre:
CREATE TABLE logs (
    id BIGINT, created_at DATE, message TEXT
) PARTITION BY RANGE (created_at);

CREATE TABLE logs_2026 PARTITION OF logs
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')
    PARTITION BY RANGE (created_at);  -- Sub-partition!

CREATE TABLE logs_2026_q1 PARTITION OF logs_2026
    FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE logs_2026_q2 PARTITION OF logs_2026
    FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');

Altın kural: Partition granularity'yi sorgu pattern'ine göre belirle:

  • Genelde aylık/haftalık sorgular → aylık partition
  • Genelde yıllık raporlama → yıllık partition
  • Gerçek zamanlı log → çeyrek veya aylık + eski partition'ları DETACH

Sık Yapılan Hatalar

Hata Sonuç Çözüm
PK'da partition key yok ERROR: unique constraint must include partition key Composite PK: (Id, PartitionKey)
FK referansı partitioned tabloya PostgreSQL izin vermez (PG 11'de) PG 12+: FK destekler, veya uygulama seviyesinde kontrol
Partition key olmadan sorgu Tüm partition'lar taranır WHERE'de partition key kolonunu filtrele
Default partition unutmak Aralık dışı INSERT hata verir CREATE ... DEFAULT ile fallback ekle
EF migration'da CreateTable kullanmak Normal (non-partitioned) tablo oluşur Raw SQL migration kullan

❓ "WHERE'de partition key olmalı" Ne Demek?

Bu ifade bazı geliştiricileri yanıltıyor. Açıklayalım:

"Partition key" = tabloyu partition'lamak için seçtiğin kolon. Örneğin PARTITION BY RANGE (order_date) dediysen, partition key = order_date kolonu.

"WHERE'de partition key olmalı" demek:

  • LINQ'e özel bir .WithPartition() veya PARTITION keyword'ü eklemek DEĞİL
  • Connection string'e veya DbContext'e bir şey eklemek DEĞİL
  • Sadece sorgunun WHERE koşulunda o kolonu filtrelemen demek
// Tablo: PARTITION BY RANGE (order_date)
// Partition key = order_date kolonu

// ✅ DOĞRU — order_date WHERE'de var → PG sadece ilgili partition'ı tarar
var orders = await context.Orders
    .Where(o => o.OrderDate >= new DateOnly(2026, 1, 1))  // ← partition key filtresi
    .Where(o => o.Status == "Pending")
    .ToListAsync();
// PG: "order_date 2026 aralığında → sadece orders_2026'ya bak" (Partition Pruning)

// ⚠️ YANLIŞ — order_date hiç yok → PG hangi partition'a bakacağını bilemez
var expensive = await context.Orders
    .Where(o => o.TotalAmount > 10000)  // ← partition key yok!
    .ToListAsync();
// PG: "Tüm partition'ları tara, order_date koşulu yok" → Full Scan
// Partition'ın hiçbir faydası yok, sanki normal tabloymuş gibi çalışır

Özetle:

Sen:  .Where(o => o.OrderDate >= start)     ← normal LINQ, özel bir şey yok
       ↓
EF:   WHERE order_date >= '2026-01-01'      ← normal SQL
       ↓
PG:   "order_date kolonu benim partition    ← PostgreSQL kendi karar verir
       key'im, hangi partition'lara bakmalıyım?"
       → orders_2024? Hayır (range dışı)
       → orders_2025? Hayır (range dışı)
       → orders_2026? EVET → sadece bunu tara ✅

Özet: EF Core ve LINQ tarafında partition ile ilgili hiçbir özel kod yazmazsın. Normal .Where() yazarsın. Tek kuralın: o .Where() içinde partition key kolonunu (order_date, tenant_id vb.) filtrele. Gerisini PostgreSQL otomatik halleder.

Bu, tasarım kararıdır: Partition key'i seçerken "sorgularımda en çok hangi kolonu filtreliyorum?" sorusunu sor. Cevap ne ise, partition key o olsun.

Gerçek Dünya Senaryosu: E-Fatura Uygulaması

Durum: Multi-tenant e-dönüşüm sistemi. Müşteri A günde 10.000 fatura, Müşteri B günde 10 fatura kesiyor. Nasıl partition'lanmalı?

Yaklaşım 1: LIST by tenant_id (Her müşteriye özel partition)

CREATE TABLE invoices (...) PARTITION BY LIST (tenant_id);
CREATE TABLE invoices_customer_a PARTITION OF invoices FOR VALUES IN ('cust-a');
CREATE TABLE invoices_customer_b PARTITION OF invoices FOR VALUES IN ('cust-b');
-- ... 500 müşteri = 500 partition

Sorunlar:

  • 500 müşteri = 500 partition → planning overhead
  • Partition boyutları aşırı dengesiz (biri 3.6M/yıl, biri 3.600/yıl)
  • Yeni müşteri geldiğinde DDL gerekli (partition oluşturma)
  • Tarih bazlı sorgular ("bu ayın faturaları") tüm partition'ları tarar

Yaklaşım 2: Hacim bazlı tier kolonu

-- tenant'ları tier'a göre grupla (high_volume, low_volume)
ALTER TABLE invoices ADD COLUMN volume_tier TEXT;
CREATE TABLE invoices (...) PARTITION BY LIST (volume_tier);

Sorunlar:

  • Müşteri büyüyünce tier değişir → veri taşımak gerekir (DELETE + INSERT)
  • Partition key değiştirilemez (satır taşımak = silip yeniden eklemek)
  • Yapay bir kolon eklemiş olursun, iş mantığını kirletir

Yaklaşım 3: RANGE by date (Önerilen)

CREATE TABLE invoices (
    id            BIGINT GENERATED ALWAYS AS IDENTITY,
    tenant_id     UUID NOT NULL,
    invoice_no    TEXT NOT NULL,
    amount        NUMERIC(18,2) NOT NULL,
    issued_at     DATE NOT NULL,
    status        TEXT NOT NULL DEFAULT 'draft',
    payload       JSONB,         -- e-fatura XML/UBL içeriği
    CONSTRAINT pk_invoices PRIMARY KEY (id, issued_at)
) PARTITION BY RANGE (issued_at);

-- Aylık partition:
CREATE TABLE invoices_2026_01 PARTITION OF invoices
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE invoices_2026_02 PARTITION OF invoices
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- ... (12 partition/yıl, 5 yıl = 60 partition → ideal)

-- tenant_id üzerine INDEX (partition pruning + index birlikte çalışır):
CREATE INDEX ix_invoices_tenant ON invoices (tenant_id, issued_at);

Neden doğru:

  • Sorgu pattern'i: "Müşteri X'in Ocak 2026 faturaları" → partition pruning (ay) + index (tenant)
  • Tüm müşteriler aynı partition'da → dengesizlik yok
  • Yeni müşteri = sadece INSERT, DDL gerekmez
  • Arşivleme: DETACH PARTITION invoices_2024_01 (eski ayı ayır)
  • 60 partition (5 yıl aylık) → planning overhead minimal

Sorgu örnekleri:

// "Müşteri A'nın bu ayki faturaları" — çok hızlı:
var invoices = await context.Invoices
    .Where(i => i.TenantId == tenantId
             && i.IssuedAt >= new DateOnly(2026, 5, 1)
             && i.IssuedAt < new DateOnly(2026, 6, 1))
    .OrderByDescending(i => i.IssuedAt)
    .ToListAsync();
// → Sadece invoices_2026_05 partition'ı taranır
// → tenant_id index ile filtrelenir (10K faturalık müşteri bile hızlı)

// "Tüm müşterilerin bu ayki toplam fatura sayısı" — raporlama:
var summary = await context.Invoices
    .Where(i => i.IssuedAt >= new DateOnly(2026, 5, 1)
             && i.IssuedAt < new DateOnly(2026, 6, 1))
    .GroupBy(i => i.TenantId)
    .Select(g => new { TenantId = g.Key, Count = g.Count(), Total = g.Sum(i => i.Amount) })
    .ToListAsync();
// → Sadece invoices_2026_05 taranır (tek partition)
-- EXPLAIN ANALYZE çıktısı:
-- Seq Scan on invoices_2026_05    (actual rows=312000)
--   Filter: (tenant_id = 'cust-a')
--   Rows Removed by Filter: 288000
--   → Index Scan kullanılırsa: (actual rows=10000, ~2ms)

Çok büyük müşteriler için (günde 100K+ fatura): Sub-partitioning

-- Yıl → Ay sub-partition (extreme hacim):
CREATE TABLE invoices_2026 PARTITION OF invoices
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')
    PARTITION BY RANGE (issued_at);

CREATE TABLE invoices_2026_w01 PARTITION OF invoices_2026
    FOR VALUES FROM ('2026-01-01') TO ('2026-01-08');  -- Haftalık
-- Toplam: 52 sub-partition/yıl × yıl sayısı (çok büyük hacimler için)

Karar ağacı:

E-fatura partition stratejisi:
│
├── Sorgu pattern nedir?
│   ├── Genelde tarih bazlı (ay/yıl) → ✅ RANGE by date
│   ├── Genelde tenant bazlı         → 🟡 LIST by tenant (az müşteri ise)
│   └── Her ikisi de eşit           → ✅ RANGE by date + tenant index
│
├── Kaç müşteri var?
│   ├── < 20 büyük müşteri          → LIST by tenant düşünülebilir
│   ├── 20-1000 müşteri             → ✅ RANGE by date + index
│   └── > 1000 müşteri              → ✅ Kesinlikle RANGE by date
│
└── Arşivleme gerekli mi?
    ├── Evet (yasal saklama süresi)  → ✅ RANGE by date (DETACH ile arşiv)
    └── Hayır                        → Index yeterli olabilir, partition şart değil