Multi-Tenant SaaS Mimarisi: Veritabanı, Şema ve Satır Bazlı
SaaS ürününde kiracı izolasyonu, sonradan değiştirilmesi en pahalı mimari karardır. Kiracı başına veritabanı, şema ve satır bazlı (tenant_id) modelleri izolasyon, maliyet ve operasyon açısından karşılaştırıyor; Spring Boot ve Hibernate ile kiracı bağlamını, PostgreSQL RLS ile ikinci savunma hattını ve sahada gördüğümüz hataları anlatıyoruz.
Bir SaaS ürününde en pahalı mimari karar, sonradan değiştirmesi en zor olandır: kiracıların (tenant) verisi birbirinden nasıl ayrılacak? İlk on müşteride her model çalışır; sorun beş yüzüncü müşteride, ilk banka müşterisinde ya da ilk "verimizi ayrı tutun" talebinde ortaya çıkar ve o noktada modeli değiştirmek, ürünü yeniden yazmaya yakındır.
Bu yazıda üç izolasyon modelini — kiracı başına veritabanı, kiracı başına şema ve paylaşımlı tablo (satır bazlı, tenant_id) — izolasyon, maliyet ve operasyon açısından karşılaştırıyor; karar kriterlerini, Spring Boot ve Hibernate ile kiracı bağlamının taşınmasını, PostgreSQL satır düzeyi güvenliğini (RLS) ikinci savunma hattı olarak kurmayı, çapraz konuları (gürültülü komşu, önbellek, toplu işler, yedekleme) ve sahada gördüğümüz hataları anlatıyoruz.
Üç model
Ölçüt | Kiracı başına veritabanı (silo) | Kiracı başına şema | Paylaşımlı tablo, tenant_id (havuz) |
|---|---|---|---|
İzolasyon | En güçlü; fiziksel ayrım, ayrı kimlik bilgileri | Güçlü; aynı sunucu, ayrı ad alanı | Uygulama disiplinine bağlı; RLS ile güçlendirilir |
Maliyet (kiracı başına) | Yüksek; her kiracı için bağlantı havuzu, bellek, yönetilen DB ücreti | Orta; tek örnek, şema sayısıyla artan katalog yükü | En düşük; sıfıra yakın marjinal maliyet |
Migrasyon | N veritabanında N kez; yarım kalma riski | N şemada N kez; aynı risk, daha hızlı | Tek kez |
Yedekleme / tek kiracıyı geri yükleme | Kolay ve doğal | Şema düzeyinde mümkün | Zor; satır bazlı dışa aktarma gerekir |
Kiracı sayısı ölçeği | Onlar–yüzler | Yüzler–birkaç bin | On binler+ |
Kiracıya özel şema değişikliği | Mümkün | Mümkün ama migrasyonu karmaşıklaştırır | Yalnızca JSON/EAV ile |
Uygun senaryo | Az sayıda büyük, regüle müşteri (banka, sağlık) | Orta segment B2B, kiracı başına belirgin veri hacmi | Çok sayıda küçük kiracı, self-servis kayıt |
Karar kriterleri
Kiracı sayısı ve dağılımı: Yüz büyük müşteri ile on bin küçük müşteri farklı modeldir. Çoğu B2B SaaS'ta dağılım çarpıktır: birkaç dev, çok sayıda küçük.
Regülasyon ve sözleşme: Bazı müşteriler "verimiz ayrı veritabanında olsun" ister ve bunun için öder. Bu talep kural değilse tüm ürünü silo yapmak gereksiz maliyettir.
Operasyon kapasitesi: Üç kişilik ekip iki bin veritabanının migrasyonunu yönetemez; silo modeli otomasyon ister.
Özelleştirme: Kiracıya özel alanlar ve iş kuralları çoksa şema ya da silo; azsa havuz + JSON sütunları.
Pratikte en sağlam cevap karma modeldir: varsayılan olarak paylaşımlı tablo (havuz), büyük ya da regüle müşteriler için ayrı veritabanı (silo). Bunun ön koşulu, uygulamanın kiracı bağlamını ve veri erişimini baştan soyutlamış olmasıdır; havuzdan siloya taşınabilen bir kiracı, tasarımın doğru olduğunun kanıtıdır.
Kiracı bağlamı: kimlikten sorguya
Kiracı kimliği asla istemcinin verdiği bir değerden (URL, gövde) alınmaz; doğrulanmış kimlikten gelir: JWT'deki tenant_id claim'i ya da alt alan adının sunucu tarafında kiracıya çözülmesi. Bağlam istek başında belirlenir, her katmana taşınır ve istek sonunda temizlenir.
public final class KiraciBaglami {
private static final ThreadLocal<String> KIRACI = new ThreadLocal<>(); // sanal thread'lerde ScopedValue tercih edilir
public static void ayarla(String id) { KIRACI.set(id); }
public static String mevcut() { return Objects.requireNonNull(KIRACI.get(), "kiracı bağlamı yok"); }
public static void temizle() { KIRACI.remove(); }
}
@Component
public class KiraciFiltresi extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
var auth = (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
String kiraci = auth.getToken().getClaimAsString("tenant_id"); // doğrulanmış kaynak
KiraciBaglami.ayarla(kiraci);
try { chain.doFilter(req, res); } finally { KiraciBaglami.temizle(); }
}
}Paylaşımlı tabloda Hibernate ayrıştırıcı
@Entity
public class Musteri {
@Id private Long id;
@TenantId private String kiraciId; // Hibernate 6: her sorguya otomatik filtre, her insert'e otomatik değer
private String ad;
}
@Component
public class KiraciCozucu implements CurrentTenantIdentifierResolver<String>, HibernatePropertiesCustomizer {
@Override public String resolveCurrentTenantIdentifier() { return KiraciBaglami.mevcut(); }
@Override public boolean validateExistingCurrentSessions() { return true; }
@Override public void customize(Map<String, Object> props) { props.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this); }
}@TenantId, JPQL ve Criteria sorgularına kiracı koşulunu otomatik ekler; ama native SQL sorgularına eklemez. Raporlama sorguları, toplu güncellemeler ve elle yazılmış SQL en sık sızıntı noktasıdır. Bu yüzden ikinci savunma hattı veritabanında kurulur.
İkinci savunma hattı: PostgreSQL RLS
ALTER TABLE musteri ENABLE ROW LEVEL SECURITY;
ALTER TABLE musteri FORCE ROW LEVEL SECURITY; -- tablo sahibi için de geçerli
CREATE POLICY kiraci_izolasyonu ON musteri
USING (kiraci_id = current_setting('app.kiraci', true))
WITH CHECK (kiraci_id = current_setting('app.kiraci', true));// Bağlantı havuzdan alınırken kiracı oturum değişkenine yazılır (Hikari + JPA: bir DataSource sarmalayıcısı ya da Hibernate bağlantı sağlayıcısı)
try (var st = connection.createStatement()) {
st.execute("SELECT set_config('app.kiraci', '" + kiraci + "', false)"); // gerçek kodda parametreli prepared statement
}RLS ile uygulama kiracı filtresini unutsa bile veritabanı yalnızca o kiracının satırlarını döndürür. Maliyeti küçüktür; kazancı, uykuları kaçıran sızıntı sınıfını tümüyle kapatmasıdır. Bağlantı havuzuna dönen bağlantıda değişkenin sıfırlanması (RESET) zorunludur.
Kiracı başına şema: bağlantı yönlendirme
Şema modelinde Hibernate'in MultiTenantConnectionProvider'ı bağlantıyı alırken SET search_path TO kiraci_x çalıştırır; migrasyon ise tüm şemalar üzerinde döngüyle uygulanır (Flyway.configure().schemas(kiraci)). Migrasyonun yarıda kalması durumunda hangi şemaların hangi sürümde olduğunu bilmek için Flyway'in şema geçmiş tablosu her şemada ayrı tutulur ve dağıtım, tüm şemalar aynı sürüme gelene kadar tamamlanmış sayılmaz.
Çapraz konular
Gürültülü komşu: Bir kiracının toplu içe aktarımı diğerlerini yavaşlatmamalı; kiracı başına hız sınırı, toplu işler için kuyruk kotası, dış çağrılarda kiracı bazlı bulkhead.
Önbellek: Her önbellek anahtarı kiracı kimliğiyle başlar (
t:{kiraci}:urun:{id}); Spring Cache'de özelKeyGenerator. Kiracısız anahtar, en sessiz veri sızıntısıdır.Zamanlanmış işler: Toplu iş kiracı listesi üzerinde döner ve her yineleme için bağlamı ayarlar; tek büyük kiracının işi diğerlerini geciktirmesin diye kiracı bazında paralel ve zaman sınırlı.
Mesajlaşma: Kafka mesaj başlığında kiracı kimliği taşınır; tüketici bağlamı başlıktan kurar.
Gözlemlenebilirlik: Loglarda kiracı kimliği (MDC); metriklerde yalnızca kiracı sayısı yüzleri geçmiyorsa etiket, aksi hâlde kardinalite sorunu.
Yedekleme ve ayrılma: Havuz modelinde tek kiracıyı geri yüklemek ya da dışa aktarmak için kiracı bazlı export işi baştan yazılır; müşteri ayrıldığında verisinin silinmesi (KVKK) da aynı işle yapılır.
Kiracı yapılandırması: Plan, özellik bayrakları, limitler ve marka ayarları kiracı kaydında; kodda
if (kiraci.equals("acme"))yok.
İzolasyon testi: pazarlık yok
Her sürümde çalışan bir entegrasyon testi, kiracı A bağlamındayken kiracı B'nin verisinin hiçbir uçtan — REST, rapor sorgusu, toplu iş, önbellek — okunamadığını ve yazılamadığını doğrular. Bu test, yeni bir native sorgu eklendiğinde kırılmalıdır; kırılmıyorsa test eksiktir. Testcontainers ile gerçek PostgreSQL ve RLS politikalarıyla çalıştırılır.
Sahada sık yapılan hatalar
Kiracı kimliğini istemciden almak: URL'deki
/tenants/42/…parametresine güvenmek; kimlik doğrulanmış kaynaktan gelmeli.Native sorguda filtre unutmak: RLS olmadan kaçınılmaz; RLS ile zararsız.
Paylaşılan sıra numaraları: Tüm kiracıların ortak sequence'tan aldığı fatura numarası, bir kiracıya diğerlerinin hacmini gösterir; kiracı bazlı sayaç.
Bağlamı asenkron sınırda kaybetmek:
@Asyncve thread havuzlarında ThreadLocal taşınmaz; bağlam yayılımı (TaskDecorator) gerekir.Silo modelinde otomasyon eksikliği: Yeni kiracı açmak elle yapılan on adımsa ölçeklenmez; kiracı oluşturma, migrasyon ve silme tek komut olmalı.
Sık sorulan sorular
Havuz modelinden siloya sonradan geçilebilir mi?
Kiracı bağlamı ve veri erişimi soyutlanmışsa evet: kiracının satırları yeni veritabanına taşınır, yönlendirme tablosu güncellenir. Soyutlama yoksa "geçiş" bir yeniden yazma projesidir.
Kiracı başına ayrı uygulama örneği gerekir mi?
Genellikle hayır; uygulama katmanı paylaşımlıdır, izolasyon veri katmanındadır. Ayrı örnek yalnızca regülasyonun tam ayrımı zorunlu kıldığı ya da kiracının kendi sürüm takvimini istediği durumlarda.
RLS performansı düşürür mü?
Politika koşulu her sorguya eklenen bir WHERE ifadesidir; kiraci_id bileşik indekslerin ilk sütunuysa etkisi ihmal edilebilir. İndeks yoksa yavaşlayan RLS değil, zaten filtrelenmemiş sorgudur.
Sonuç
Kiracı izolasyonu bir kez verilen ve uzun süre yaşanan karardır: az sayıda büyük müşteri için silo, çok sayıda küçük müşteri için havuz, çoğu ürün için karma. Hangisi olursa olsun üç şey vazgeçilmezdir: doğrulanmış kaynaktan gelen ve her katmana taşınan kiracı bağlamı, veritabanında ikinci savunma hattı ve her sürümde çalışan izolasyon testi.
SaaS ürününüzün çok kiracılı mimarisini tasarlamak ya da mevcut ürünü karma modele taşımak için yazılım tasarımı ve mimari ile web ve e-ticaret geliştirme hizmetlerimize göz atabilirsiniz.