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.
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
);
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.
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 KEYveUNIQUEconstraint'ler partition key'i içermek zorundadır. Bu yüzdenIdtek 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 ANALYZEplanning time şişerpg_catalogmetadata 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()veyaPARTITIONkeyword'ü 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_idvb.) 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