ana içeriğe geç

Multi-tenant SaaS'ta tenant isolation: 3 desen, 3 trade-off

Multi-tenant SaaS'ta üç ürünümüzde aynı sorunu üç farklı yolla çözdük: schema-per-tenant, row-level security, database-per-tenant. Trade-off'lar + karar matrisi.

Mühendislik — Multi-tenant SaaS'ta tenant isolation: 3 desen, 3 trade-off

Bir multi-tenant SaaS ürünü kuruyorsanız ve “tenant verisi başka tenant’a sızmasın” sorusu mimari masanızda duruyorsa bu yazı kararı netleştirir. Multi-tenant SaaS’ta “tenant isolation” sorusu, mimari kararların mimar olarak en sık geri döndüğümüz konusudur. Soru basit görünüyor — “bir tenant’ın verisi başka tenant’a sızmasın” — ama cevap üç farklı desene açılıyor ve her bir deseni seçmenin operasyonel maliyeti, performans tavanı ve audit/compliance avantajları çok farklı.

Üç ürünümüzde — caveflo, crm2b ve Mediatic AI — tenant isolation farklı şekillerde çözüldü, çünkü ölçek, hassasiyet ve operasyonel kapasite farklıydı. Bu yazı üç deseni — schema-per-tenant, row-level security (RLS), database-per-tenant — ürün gerçekliğimizden örneklerle anlatıyor; ardından performans karşılaştırması, migration stratejileri ve hangi deseni hangi durumda seçmek gerektiğini gösteren bir karar matrisi paylaşıyor. Postgres üzerinden konuşuyoruz çünkü üç ürünümüzde de Postgres var; pek çok kavram MySQL ve SQL Server için de geçerli, ama syntax farklı olur.

Desen 1: Schema-per-tenant — strict isolation

İlk desen klasik: her tenant için Postgres içinde ayrı bir schema. Bağlantı havuzu aynı veritabanına çıkıyor, ama uygulama her isteğin başında SET search_path komutu ile aktif tenant’ın schema’sına geçiyor.

-- Tenant onboarding sırasında
CREATE SCHEMA tenant_a23f9;
CREATE TABLE tenant_a23f9.contacts (
  id UUID PRIMARY KEY,
  email TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Request başlangıcında
SET search_path TO tenant_a23f9, public;
SELECT * FROM contacts WHERE email = $1;

Avantajlar. Isolation gerçekten strict — başka tenant’ın schema’sına yanlışlıkla query atmak için açıkça tenant_b.contacts yazmak gerekiyor. Per-tenant migration mümkün: bir müşteri için yeni özellik açılabilir, kendi schema’sında ayrı kolon olabilir. Backup ve restore tek bir tenant için temiz: pg_dump -n tenant_a23f9.

Dezavantajlar. Migration karmaşıklığı tenant sayısı ile lineer büyüyor. 50 tenant’lı bir kurulumda yeni bir kolon eklemek 50 kez ALTER TABLE demek. dbmate, sqitch ya da Flyway gibi araçlarla otomatize edilebilir, ama 500+ tenant’a çıktığınızda migration süresi tek başına bir incident-window oluyor. Her schema’nın ~5MB’lık metadata overhead’i de var; 1.000 tenant ekstra ~5GB metadata.

Connection pooling sorunu. PgBouncer transaction pooling ile schema-per-tenant kullanırken SET search_path komutu her transaction sonunda sıfırlanıyor — bu beklenmedik bug’lar üretiyor. Çözüm: session pooling kullanmak (ama o zaman bağlantı limit’i hızla doluyor) ya da her query’de SET search_path yapmak yerine tenant.contacts ile fully-qualified name kullanmak. İkinci çözüm temiz ama uygulama kodu tarafında her ORM query’sinde tenant prefix’i tutmak gerekiyor.

caveflo ürününde bu deseni başlangıçta seçtik çünkü her müşteri çoğunlukla bağımsız sales operation çalıştırıyor; tenant başına özelleştirme talebi yüksekti. ~120 tenant’ta migration süresi sürdürülebilir; ekstradan 2 mühendis migration tooling üzerinde çalışıyor. 500+ tenant’a giderken RLS’e geçiş planı yazıldı.

Desen 2: Row-level security (Postgres RLS) — shared schema

İkinci desen Postgres 9.5+ ile gelen RLS (row-level security) özelliğini kullanıyor. Tüm tenant’lar aynı tablolarda yaşıyor, her tabloda tenant_id kolonu var, ve Postgres seviyesinde policy’ler uygulanıyor.

-- Tablo tanımı
CREATE TABLE contacts (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  email TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_contacts_tenant ON contacts (tenant_id);

-- RLS aktivasyonu
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE contacts FORCE ROW LEVEL SECURITY;

-- Policy
CREATE POLICY tenant_isolation_policy ON contacts
  USING (tenant_id = current_setting('app.tenant_id')::UUID);

-- Request başlangıcında
SET app.tenant_id = 'a23f9-...';
SELECT * FROM contacts WHERE email = $1;
-- Postgres otomatik filtreliyor: WHERE tenant_id = 'a23f9-...' AND email = $1

Avantajlar. Migration tek bir kez koşuyor, tüm tenant’lar otomatik geçiyor. Backup tek veritabanı backup’ı. Postgres 14+ ile partitioning + RLS birleştirildiğinde performans schema-per-tenant’a yakın. Ekstradan: app-level guard ile RLS dual-guard kuruyoruz — uygulama her query’de WHERE tenant_id = ? ekliyor, RLS arka planda safety net. İki seviye savunma.

Dezavantajlar. Policy karmaşası ölçek arttıkça artıyor. Bir tenant başka bir tenant’ın verisini görmek istemiyor, ama “şirket grubu” kavramı varsa (örn. tenant A, tenant B’nin parent’i) policy artık tenant_id = current OR tenant_id IN (SELECT child FROM tenant_hierarchy WHERE parent = current) gibi karmaşık şekillere giriyor. Test coverage daha agresif olmalı çünkü RLS policy bug’ı kritik veri sızıntısı demek.

Index strategy kritik. Her tablodaki tenant_id kolonu mutlaka primary index’in sol tarafında olmalı. Yanlış: INDEX (created_at, tenant_id). Doğru: INDEX (tenant_id, created_at). Tenant başına ortalama 10K satır olan bir tabloda yanlış index sırası query latency’yi 50x büyütebilir. EXPLAIN ANALYZE her şey demektir.

Connection pooling daha kolay. RLS, transaction pooling ile sorunsuz çalışıyor — SET app.tenant_id her transaction başında ayarlanıyor; PgBouncer transaction pooling tabakası ile uyumlu. Schema-per-tenant’taki gibi session pooling kısıtı yok.

crm2b ürününde başlangıçtan RLS kullanıyoruz. Şu an ~700 tenant; query latency p95 değeri 12ms (tenant_id index’i sayesinde). Yeni özellik eklemek tek migration. Operasyonel ekibimizin bu deseni seçmesinin sebebi: 500+ tenant’lı bir SaaS’ta schema-per-tenant operasyonel olarak yormaya başlıyor; RLS’in policy karmaşası managable, schema-per-tenant’ın migration karmaşası değil.

Desen 3: Database-per-tenant — enterprise tier için

Üçüncü desen en sert: her tenant için ayrı bir Postgres database. Aynı sunucuda farklı database’ler ya da farklı sunucular. Uygulama tarafında “tenant routing” katmanı, her isteği doğru database’e yönlendiriyor.

// Pseudocode — connection routing
function getDbConnection(tenantId: string): Connection {
  const dbConfig = tenantConnectionMap.get(tenantId);
  return connectionPool.connect(dbConfig);
}

// Usage
const db = getDbConnection(req.tenantId);
const contacts = await db.query("SELECT * FROM contacts WHERE email = $1", [email]);

Avantajlar. Maximum isolation: bir tenant’ın database’i bozulduğunda diğer tenant’lar etkilenmiyor. Audit/compliance avantajı çok büyük — finansal sektör, sağlık, devlet projeleri için “data residency” ve “physical separation” gerekleri açıkça karşılanıyor. Backup, encryption at rest, compliance audit hepsi tek bir database üzerinde değerlendiriliyor; tenant başına ayrı SLA verilebiliyor.

Dezavantajlar. Operasyonel maliyet 100+ tenant’ta çekilmez. 1.000 tenant = 1.000 Postgres database = 1.000 backup pipeline + 1.000 connection pool + 1.000 migration koşumu. AWS RDS gibi managed servis kullanıyorsanız 1.000 RDS instance fiyatı astronomik. Self-managed Postgres’te tek bir cluster üzerinde 1.000 database tutmak teorik mümkün ama vacuum/analyze maliyeti üst üste binebilir.

Çözüm: enterprise-only deseni. Database-per-tenant’ı tüm tenant’lar için değil, sadece enterprise tier müşterileri için kullanmak. Ana ürün RLS üzerinde çalışıyor (700 tenant); enterprise kontratlı 5-15 müşteri kendi database’lerinde. Hibrit yaklaşım operasyonel maliyeti makul tutuyor, compliance avantajını gerektiren müşteriler için özel SLA imkanı veriyor.

Mediatic AI ürününde bu hibrit modeli uyguluyoruz: ana model RLS üzerinde, enterprise-tier müşteriler (büyük ajans grupları, kamu kurumları) kendi database’lerinde. Compliance sorularına “evet, sizin veriniz fiziksel olarak ayrı bir database’de” diye net cevap verebiliyoruz; bu enterprise satışlarında somut değer üretiyor.

Performans karşılaştırması

Üç ürünümüzdeki tipik metric’ler (production’da, son 90 gün ortalaması):

  • caveflo (schema-per-tenant, ~120 tenant): Query latency p95 8ms, connection pool overhead %4, vacuum/analyze haftada ~22 dakika.
  • crm2b (RLS, ~700 tenant): Query latency p95 12ms, connection pool overhead %2, vacuum/analyze haftada ~38 dakika.
  • Mediatic AI (hybrid: RLS ana + 9 database-per-tenant): Query latency p95 14ms (RLS), 7ms (database-per-tenant), connection pool overhead %6 (hybrid manager nedeniyle), vacuum/analyze haftada ~45 dakika.

İki gözlem öne çıkıyor:

  • Schema-per-tenant en hızlı query latency’yi veriyor — çünkü Postgres planner, tenant başına daha küçük tablo gördüğünde daha iyi index seçimleri yapıyor. Trade-off connection pool overhead’i yüksek (her schema farklı search_path bekliyor).
  • RLS scale ile vacuum/analyze maliyeti artıyor. 700 tenant’lı tek tabloda 7M satır vacuum maliyeti, 700 ayrı 10K satırlık tablonun vacuum maliyetinden ~%70 daha yüksek. Postgres 15+ ile parallel vacuum açıldığında bu fark azalıyor; otomatik tuning ile haftalık 38 dakika kabul edilebilir seviyede.

Performans tek karar faktörü değil. 100ms p95 vs 12ms p95 farkı çoğu B2B SaaS için önemsiz; saniyede 10K request alan bir consumer SaaS için önemli. Ölçeğinize ve UX hassasiyetinize göre değerlendirin.

Migration stratejileri: schema-per-tenant’tan RLS’e geçiş

Üç desenden birinden diğerine geçiş, kritik bir operasyonel zorluk. Pratikte en sık ihtiyaç duyulan: schema-per-tenant’tan RLS’e geçiş — çünkü çoğu SaaS başlangıçta schema-per-tenant ile başlıyor (yapılması daha sezgisel) ve 500+ tenant’a vardığında migration karmaşası ile çarpıyor.

caveflo için yazdığımız migration playbook (henüz uygulanmadı, ama önümüzdeki çeyrek planlanan):

  1. Hazırlık: shared schema oluşturma. Yeni bir schema’da tüm tablolar tenant_id kolonu eklenmiş olarak tanımlanıyor. RLS policy’ler önceden test ediliyor.
  2. Dual-write fazı. Uygulama hem eski schema-per-tenant’a hem yeni shared schema’ya yazıyor. 2-4 hafta. Bu fazda data consistency check’leri çalışıyor — her gün her iki taraftaki satır sayıları karşılaştırılıyor.
  3. Backfill. Geçmiş veri shared schema’ya taşınıyor. Tenant başına INSERT INTO shared.contacts (tenant_id, ...) SELECT 'tenant-a-uuid', ... FROM tenant_a23f9.contacts. 100M+ satırlık bir migration için 1-2 hafta, batch işle.
  4. Read switchover. Uygulama read trafiğini shared schema’ya kaydırıyor. Yazma hâlâ dual. Bir-iki hafta gözlem.
  5. Write cutover. Yazma tamamen shared schema’ya geçiyor. Eski schema-per-tenant tablolar 4 hafta okuma için canlı tutuluyor (rollback ihtiyacı için), sonra arşivleniyor.

Bu süreç tipik olarak 6-10 hafta alıyor ve tek bir mühendis tam zamanlı + bir DBA part-time gerektiriyor. Ürün takımı bu süreçte yeni özellik üretimini yavaşlatıyor — net “downtime” yok ama yeni feature velocity %30-50 düşüyor. Buna baştan yatırım yapılmazsa, schema-per-tenant’tan kaçış maliyeti her tenant ile artmaya devam ediyor.

Operasyonel realite: hangi desen ne büyüklük için

Üç desenin operasyonel gerçeği, tenant sayısı ve isolation hassasiyeti aksenleri üzerinde net olarak ayrışıyor.

  • <100 tenant + yüksek per-tenant özelleştirme: Schema-per-tenant kazanır. Migration karmaşası bu ölçekte yorucu değil.
  • 100-500 tenant + standart özellik seti: RLS kazanır. Single schema migration, tenant başına customizing yok ya da çok kısıtlı.
  • 500+ tenant + standart: RLS kesinlikle. Schema-per-tenant operasyonel olarak çekilmez.
  • Compliance / data residency / enterprise tier: Hibrit (ana ürün RLS + enterprise database-per-tenant). Database-per-tenant’ı tüm tenant tabanına uygulamak yerine sadece commit-eden enterprise müşterilere.
  • Tek “büyük” tenant tipi (örn. 5 müşteri ama her biri devasa): Database-per-tenant. RLS bu ölçekte aşırı şişen tek tablo problemi yaratıyor.

Karar matrisi

Bir ekip yeni multi-tenant SaaS başlatırken hangi deseni seçmesi gerektiğine üç sorudan karar veriyor:

SoruCevapÖneri
Tenant sayısı 500+ olacak mı?EvetRLS tercihli
Tenant sayısı 500+ olacak mı?HayırSchema-per-tenant uygun
Compliance / data residency zorunluluğu var mı?EvetHibrit (RLS + enterprise DB-per-tenant)
Per-tenant özelleştirme talebi yüksek mi?EvetSchema-per-tenant
Operasyonel ekipte 1 DBA + 2 backend?EvetHangi desen olursa olsun çalışır
1 backend + DBA yok?EvetRLS — daha az operasyonel yük

Karar matrisini tek başına kullanmak yerine, mevcut ürününüzün roadmap’inde 18 ay sonra nereye gideceğini düşünerek seçim yapın. Bugün 50 tenant’ta schema-per-tenant rahat görünüyor olabilir; 24 ay sonra 800 tenant’ta migration travması yaşamak istemiyorsanız RLS ile başlayın.

Multi-tenant mimarisini kuruyorsanız ya da mevcut sistemden migrate etme gereği görüyorsanız, enterprise sistemleri tarafında bu tür kararlar için tipik 2-3 haftalık bir architecture review yürütüyoruz. Bize ulaşın — discovery görüşmesinde mevcut tenant sayınız, isolation hassasiyetiniz ve operasyonel kapasiteniz üzerinden hangi deseni hangi rotada izlemenin sağlıklı olduğunu konuşuyoruz.

Multi-tenant SaaS'ta tenant isolation: 3 desen, 3 trade-off — bölüm görseli

Paylaş

Nereden başlayacağınızdan emin değil misiniz?

İhtiyacınıza uyan katmanı birlikte belirleyip mimarinin nereden kurulacağını çıkaralım.

İlgili yazılar

İlgili yazılar

Aynı konunun farklı pencereleri.

Bülten

MarTech, AI ve mühendislik operasyonları üzerine — beynart ekibinin doğrudan kaleminden. 3 ayda bir, spam yok.