2026-08-20mimari4 dk

Çok kiracılı (multi-tenant) veri modelinde izolasyon stratejileri

Ayrı veritabanı, ayrı şema ve ortak tablo modellerinin gerçek maliyetleri; PostgreSQL row-level security ile kiracı filtresini uygulamadan alıp veritabanına koymanın doğru yolu.

Çok kiracılı bir sistemde izolasyon, mimari şemada verilen bir karar değil; her sorguda yeniden verilen bir karardır. Şemayı doğru çizip tek bir WHERE unutmak, tüm tasarımı geçersiz kılar. Bu yüzden soru "hangi model daha güvenli" değil, şu olmalı: kiracı filtresini unutmayı imkânsız kılan yer neresi?

Üç model, üç ayrı başarısızlık biçimi

Kiracı başına ayrı veritabanı. İzolasyon işletim sistemi seviyesinde. Sızıntı için bağlantı dizesinin yanlış olması gerekir. Karşılığında migration sayısı kiracı sayısıyla çarpılır, bağlantı havuzu kiracı sayısıyla büyür ve "tüm kiracılarda kaç fatura var" sorusunun cevabı tek sorguyla alınamaz.

Ortak veritabanı, kiracı başına şema. Migration yükü aynı şekilde çarpılır, ama bağlantı havuzu ortaktır. search_path ile ayrım yapılır — ve search_path bağlantı seviyesinde bir durum olduğu için, havuzdan geri dönen kirli bir bağlantı doğrudan sızıntıdır.

Ortak tablo, tenant_id kolonu. Tek migration, tek havuz, kolay raporlama. Buna karşılık izolasyon tamamen uygulama koduna bırakılmıştır ve kod büyüdükçe filtre unutma olasılığı doğrusal artar.

Üçüncü modelin bu zayıflığı, veritabanı seviyesinde kapatılabilir. Kapatılmadığı sürece model değil, temenni olur.

Filtreyi uygulamadan alıp veritabanına koymak

PostgreSQL'de row-level security, tenant_id filtresini ORM'in insafına bırakmaktan çıkarır:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING      (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

İki satır kritik:

FORCE olmadan tablonun sahibi politikayı atlar. Uygulamanız migration'ları çalıştıran rolle bağlanıyorsa — küçük projelerde çoğunlukla öyledir — ENABLE tek başına hiçbir şey yapmaz. Bu, RLS kurulumlarında en sık görülen sessiz hatadır.

WITH CHECK olmadan okuma korunur, yazma korunmaz. USING yalnızca görünen satırları sınırlar; başka bir kiracının tenant_id'siyle INSERT etmeyi engellemez.

Ayrıca BYPASSRLS yetkisi olan roller ve superuser politikayı hiç değerlendirmez. Uygulama rolünüzün bu yetkiyi taşımadığını doğrulayın:

SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'app';

Değişkeni kim, ne zaman set ediyor

app.tenant_id bir bağlantı durumudur. Havuzlanmış bağlantılarda bu, doğrudan bir risk.

BEGIN;
SET LOCAL app.tenant_id = '...';
-- sorgular
COMMIT;

SET LOCAL değişkeni işlem sonunda geri alır; SET almaz. PgBouncer'ı transaction pooling modunda kullanıyorsanız SET LOCAL zorunludur, çünkü bağlantı işlem bittiği anda başka bir isteğe verilir. Session pooling'de SET çalışır ama bağlantı havuza dönerken temizlenmezse bir sonraki kiracıya önceki kiracının değeriyle açılır.

Değişken hiç set edilmezse ne olacağı da bir karar:

-- hata fırlatır: unrecognized configuration parameter
current_setting('app.tenant_id')

-- NULL döner, politika false olur, sonuç boş küme
current_setting('app.tenant_id', true)

İkisi de fail-closed. Ama ikincisi sessizce boş sonuç döndürdüğü için, hatayı üretimde "veri kayboldu" bildirimi olarak görürsünüz. Politikada gürültülü olanı tercih edin; nazik olması gereken yer varsa orada ikinci biçimi açıkça kullanın.

tenant_id eklemenin şema üzerindeki yan etkileri

Kolonu eklemek yetmez. Kolonun bulaştığı yerler:

  • Benzersizlik kısıtları. UNIQUE (invoice_no) çok kiracılıda yanlıştır; iki kiracı aynı fatura numarasını kullanabilmelidir. Doğrusu UNIQUE (tenant_id, invoice_no).
  • Yabancı anahtarlar. invoice_lines.invoice_id tek başına, farklı kiracıya ait bir faturaya bağlanmayı engellemez. Bileşik anahtar (tenant_id, id) üzerinden referans vermek bu sınıfı tamamen kapatır.
  • İndeksler. Sorguların çoğu tenant_id ile başlayacaksa, indekslerin ilk kolonu da o olmalı. Aksi halde büyük kiracı küçük kiracının sorgusunu yavaşlatır.
  • Toplu işler. Rapor ve arşiv işleri genelde tüm kiracıları gezer ve bu yüzden RLS'i atlamak isteyen ilk yer orasıdır. Atlatmak yerine kiracı listesi üzerinde dönün.

Model seçimi neye bağlı

Karar üç girdiye indirgenebiliyor: müşteri sayısı, sözleşmede fiziksel ayrım şartı olup olmadığı, ve kiracılar arası sorgu ihtiyacı.

  • Kiracı sayısı yüzlerin üzerine çıkacaksa, ayrı veritabanı ve ayrı şema modellerinin migration maliyeti operasyonel bir yük haline geliyor.
  • Sözleşme fiziksel ayrım yazıyorsa — bazı kurumsal ve kamu işlerinde yazıyor — tartışma bitmiştir, ayrı veritabanı.
  • Ürününüz kiracılar arası toplu analiz yapıyorsa, ortak tablo dışındaki her model bu işi ayrı bir veri ambarına taşımanızı gerektirir.

Çoğu KOBİ ürününde üçü de ortak tabloyu işaret ediyor. O durumda RLS'i sonradan eklenecek bir sertleştirme adımı olarak değil, ilk migration'ın parçası olarak yazın; tablo sayısı arttıktan sonra geriye dönük politika eklemek, tek tek unutulan tabloları aramaya dönüşür.

Doğrulama

İzolasyonun çalıştığını kanıtlamanın yolu koda bakmak değil, testtir. Her tablo için tek bir kalıp yeterli:

BEGIN;
SET LOCAL app.tenant_id = '<kiracı-a>';
SELECT count(*) FROM invoices;                    -- yalnızca A
INSERT INTO invoices (tenant_id, ...) VALUES ('<kiracı-b>', ...);  -- hata vermeli
COMMIT;

Bir de RLS'i açık olmayan tablo kalmadığını kontrol eden bir sorgu, CI'da çalışsın:

SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
  AND (NOT c.relrowsecurity OR NOT c.relforcerowsecurity);

Bu sorgunun boş dönmediği her gün, izolasyonunuz o tablo kadar zayıf.