Database-per-Tenant: AbstractRoutingDataSource ve Connection Pool Patlaması
Spring'in AbstractRoutingDataSource'unu neden Hibernate'in resmi multi-tenancy API'sine tercih ettim, ortak veritabanını silince routing'in nasıl sadeleştiği ve tenant × pool × pod hesabının neden uykumu kaçırdığı. Multi-tenant mimari serisinin ikinci bölümü.
Bu yazı 3 bölümlük bir serinin ikinci bölümü. Bölüm 1: Tenant Routing'i Nereye Koyacağıma 4 Kez Karar Verdim Bölüm 2: Database Layer (buradasınız) Bölüm 3: Frontend'de Tenant Context — Provider'sız Yaklaşım
Önceki bölümde gelen isteğin hangi tenant'a ait olduğunu nasıl çözdüğümüzü anlattım. Geriye asıl soru kaldı:
TenantContext.getTenantId() "acme" döndüğünde, Spring nasıl acme_db PostgreSQL veritabanına bağlanıyor?
Bu bölümde veritabanı katmanını anlatacağım: hangi Spring/Hibernate mekanizmasını seçtiğimi, resmi alternatifini neden tercih etmediğimi, ortak veritabanını silmenin bu katmanda ne değiştirdiğini ve "her şey çalışıyor" dediğim noktada beni uyandıran connection pool matematiğini.
AbstractRoutingDataSource — Tek Sınıflık Çözüm
Spring'in AbstractRoutingDataSource'unu extend ederek başladım. Mekanizmanın özü bu kadar küçük:
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getTenantId(); // ThreadLocal'dan okur
}
}Spring, JPA üzerinden bir bağlantı edinirken — yani transaction başında — bu metodu çağırıyor. Dönen key (örn. "tenant1") DataSource map'indeki HikariCP pool'unu seçiyor.
Konfigürasyon tarafında tenant'ları map olarak veriyorum:
@Bean
public DataSource dataSource() {
TenantRoutingDataSource routing = new TenantRoutingDataSource();
Map<Object, Object> targets = new HashMap<>();
tenantProperties.getDatasources()
.forEach((tid, cfg) -> targets.put(tid, buildDataSource(tid, cfg)));
routing.setTargetDataSources(targets);
// Boot/seed aşaması için hedef — çalışma zamanındaki "varsayılan DB" DEĞİL.
routing.setDefaultTargetDataSource(targets.get(bootTargetTenant));
return routing;
}Buradaki setDefaultTargetDataSource satırı, mimarinin evriminde en çok anlam değiştiren yer oldu — ve hikâyenin can alıcı kısmı orada.
null'ın anlamı değişince mimari de değişti
Projenin ilk sürümünde ortak bir basedb vardı ve setDefaultTargetDataSource(basedb) deniyordu. determineCurrentLookupKey() null döndüğünde Spring sessizce oraya gidiyordu. Yani null şu anlama geliyordu: "tenant belirsiz, merkezi veritabanına düş."
Bu "güvenli varsayılan" gibi görünüyordu. Aslında hataları gizleyen bir battaniyeydi. Tenant context'i kurmayı unuttuğum her yerde uygulama patlamıyor, sessizce yanlış veritabanına yazıyordu.
Ortak veritabanını tamamen kaldırdıktan sonra (bunun hikâyesi Bölüm 1'de) null'ın anlamını da değiştirdim: artık "düşülecek yer" değil, hata:
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getTenantId();
if (tenantId == null || tenantId.isBlank()) {
if (strict) {
throw new TenantRequiredException(
"Tenant context bos — JWT tenantId claim veya /api/v1/public/{tid} URL gerekli");
}
return null; // yalnız boot/seed aşamasında
}
return tenantId;
}strict bayrağı neden var? Çünkü uygulama açılırken henüz "istek" kavramı yok ama Hibernate'in bağlantıya ihtiyacı var: dialect tespiti, şema doğrulaması, tenant şemalarının seed'i. Bu yüzden bayrak açılışta false, ApplicationReadyEvent ile true'ya çevriliyor:
@EventListener(ApplicationReadyEvent.class)
public void enableStrictRouting() {
routingDataSource.enableStrict();
// Bundan sonra tenant'sız her sorgu = TenantRequiredException
}Sonuç: setDefaultTargetDataSource artık "varsayılan veritabanı" değil, sadece boot hedefi. Uygulama hazır olduktan sonra hiçbir istek oraya düşemez.
Bu ayrım küçük görünüyor ama etkisi büyük oldu: multi-tenant sistemlerdeki en sinsi hata sınıfı — doğru çalışıyor gibi görünüp yanlış veritabanına yazmak — mimari olarak imkânsız hale geldi.
Bir Tuzak: Tek Transaction İçinde Tenant Değiştirilemez
determineCurrentLookupKey ne zaman çağrılıyor? Bağlantı ilk edinildiğinde. Yani:
@Transactional
public void runMigration(String fromTenant, String toTenant) {
TenantContext.setTenantId(fromTenant);
repo.someQuery(); // tenant1 DB'sine gider
TenantContext.setTenantId(toTenant);
repo.anotherQuery(); // hâlâ tenant1 DB'sinde! Transaction zaten açık.
}Bir transaction içinde tenant değiştirmek istediğinde bu metot yeniden çağrılmıyor — Spring bağlantıyı bir önceki tenant'tan çoktan almış. Cross-tenant bir iş yazacaksan her tenant için ayrı transaction açman gerekiyor.
Bu kısıtın günlük hayattaki en önemli sonucu şu: tenant'ın gövdeden veya URL'den geldiği uçlarda (login, register, e-posta doğrulama, refresh) context'i @Transactional metoda girmeden önce, yani controller'da kurmak zorundasın. Servisin ilk satırında kurmak geç kalıyor — transaction zaten açılmış oluyor. Strict mod bunu affetmiyor; iyi tarafı, hatayı sessizce yutmak yerine anında yüzüne söylüyor.
Aynı sebeple boot sırasında her tenant'ın şeması ayrı ayrı, kendi transaction'ında seed ediliyor:
tenantProperties.getDatasources().keySet().forEach(tid -> {
TenantContext.setTenantId(tid);
try {
seedService.initializeTenant(tid); // rol, izin, ilk admin, e-posta şablonları
} finally {
TenantContext.clear();
}
});Neden Hibernate Multi-Tenancy API Değil
Hibernate'in resmi multi-tenancy desteği var: MultiTenantConnectionProvider + CurrentTenantIdentifierResolver. Mantıken ilk düşünmem gereken seçenekti; denedim ve AbstractRoutingDataSource'a döndüm.
1. Daha az boilerplate. Resmi API iki ayrı arayüz implementasyonu ve ek konfigürasyon bayrakları istiyor. AbstractRoutingDataSource aynı işi tek sınıfla yapıyor.
2. JPA tamamen transparan kalıyor. Entity'lerimde @TenantId yok, repository'lerimde tenant filtresi yok. Tenant kavramı yalnızca filtre katmanında ve TenantContext içinde yaşıyor — domain katmanına hiç sızmıyor. 30'dan fazla repository'nin tek satırı bile tenant'tan haberdar değil.
3. Sürüm stabilitesi. Hibernate'in multi-tenancy API'si 5 → 6 geçişinde değişti. AbstractRoutingDataSource Spring'in yıllardır neredeyse hiç değişmeyen bir parçası.
4. Database-per-tenant'a doğal oturuyor. Her tenant'ı ayrı bir DataSource (ayrı HikariCP pool) olarak ifade etmek istediğimde, bu sınıf tam da bunun için tasarlanmış.
Genel ders: Framework'ün "resmi olarak desteklediği" yol her zaman senin için doğru yol olmuyor. Önemli olan kendi domain'ine en az sürtünme yaratan yol.
HikariCP Ayarları ve Connection Pool Patlaması
Her tenant için açılışta ayrı bir HikariCP pool ayağa kalkıyor:
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000 # 30 saniye
idle-timeout: 600000 # 10 dakika
max-lifetime: 1800000 # 30 dakika
leak-detection-threshold: 60000 # 60 saniyemax-lifetime ve leak-detection-threshold ayarlarını bilhassa Kubernetes ortamı için seçtim: pod'lar gelip gidiyor, yük dengeleyici uzun ömürlü TCP bağlantılarını ara sıra yeniden dağıtmak istiyor. leak-detection-threshold ise geliştirme sırasında bağlantı sızdırdığımı erken yakalatıyor.
Tek tek bakıldığında ayarlar makul. Sonra şu hesabı yaptım:
tenant sayısı × pool boyutu × pod sayısı = toplam PostgreSQL bağlantısı
Multi-tenant'ın korkutucu yanı burada: bağlantı maliyeti doğrusal değil, çarpımsal büyüyor. Tenant eklediğinde artış pod sayısıyla çarpılıyor:
3 tenant × 10 connection × 3 pod = 90 connection (PostgreSQL default max_connections = 100)
3 tenant × 10 connection × 5 pod = 150 connection ❌
10 tenant × 10 connection × 5 pod = 500 connection ❌❌
Üstüne izleme ajanları, yedekleme işleri ve admin araçlarının bağlantıları biniyor. Bugün 2 tenant ve tek replika ile rahatım; ama otomatik ölçekleme (HPA) devrede olduğu için "yoğun bir gün" bu tabloyu bir anda değiştirebilir.
Bu hesabı production'a çıkmadan yaptığım için şanslıyım. İlk müdahale maximum-pool-size'ı düşürmek oldu — bu acil önlem; gerçek çözüm farklı.
Gerçek Çözüm: Connection Pooler
İki yapısal seçenek var:
1. PgBouncer — Uygulama ile PostgreSQL arasına konan bir TCP proxy. Uygulama "mantıksal" bağlantı görür, PgBouncer arkada çok daha az sayıda gerçek bağlantı kullanır. Database-per-tenant ile birlikte kullanmak için tenant başına pool tanımı gerekiyor.
2. max_connections'ı yükseltmek — Basit ama her bağlantı bellek tüketiyor. 500 bağlantıya çıkmak PostgreSQL'in RAM ayarlarını yeniden düşünmeyi gerektirir.
Şimdilik düşürülmüş pool boyutu yetiyor; tenant sayısı arttığında PgBouncer kaçınılmaz.
Ders: Multi-tenant mimaride ölçek hesabını "kullanıcı sayısı"yla değil, "tenant × pod × pool" çarpımıyla yapın.
ThreadLocal'ın Sınırı: RabbitMQ Consumer Tuzağı
TenantContext ThreadLocal tabanlı. HTTP isteklerinde kusursuz çalışıyor: filtre tenant'ı set ediyor, aynı thread'deki tüm JPA çağrıları onu görüyor, finally temizliyor.
Ama bir senaryoda kendi başına yetmiyor: asenkron mesaj kuyrukları.
Elly'de mail gönderimi RabbitMQ üzerinden. Bir form-to-email tetiklendiğinde:
- HTTP thread'i mesajı kuyruğa atıyor (
TenantContextdolu) - RabbitMQ consumer'ı farklı bir thread'de mesajı işliyor (
TenantContextboş!) - Consumer şablonu DB'den çekmeye çalışıyor → tenant yok
ThreadLocal'ın temel kısıtı: bir thread'de set edilen değer başka bir thread'e taşınmıyor.
Bu tuzağın sonucu, ortak veritabanını sildikten sonra kökten değişti. Eskiden 3. adımda null görülüyor ve mesaj sessizce merkezi DB'ye gidiyordu — mail "gönderildi" görünüyor ama yanlış şablonla gidiyordu. Şimdi aynı durum TenantRequiredException fırlatıyor: mesaj işlenmiyor, retry kuyruğuna düşüyor, log'da net hata var. Sinsi bug, gürültülü bug'a dönüştü.
Çözüm: Mesaj Payload'una Tenant Gömme
// Producer (HTTP request thread)
public void sendMailTask(MailTask task) {
task.setTenantId(TenantContext.getTenantId()); // payload'a göm
rabbitTemplate.convertAndSend("email-queue", task);
}
// Consumer (RabbitMQ thread pool)
@RabbitListener(queues = "email-queue")
public void handleMail(MailTask task) {
try {
TenantContext.setTenantId(task.getTenantId()); // elle set et
mailService.send(task);
} finally {
TenantContext.clear(); // sonraki mesaja sızmasın
}
}İki kural, ikisi de pazarlıksız:
Her consumer kendi setTenantId + finally'sini yazar. Unutulması kolay olduğu için proje kurallarına yazdım; strict mod da arkadan destekliyor — unutan consumer sessizce yanlış çalışmıyor, patlıyor.
Producer, tenant'ı payload'a kopyalar. Kopyalanmazsa consumer tarafı tenant'sız kalır.
Bu örüntü RabbitMQ'ya özgü değil: @Async metotlar, @Scheduled görevler, WebSocket oturumları, hatta WebRTC çağrı zaman aşımını tetikleyen zamanlayıcı — her asenkron sınır geçişinde aynı soruyu cevaplaman gerekiyor.
Ders: ThreadLocal HTTP istekleri için harika, ama bir thread sınırını geçtiğin anda tenant'ı elle taşımaya karar vermelisin. Tasarım aşamasında "bu bilgi başka bir thread'e nasıl gidecek?" sorusunu mutlaka sorun.
Bonus: Database-per-Tenant'ın Görünmeyen Faturası
Şema yönetimi. Tek veritabanında bir migration çalıştırırsın; database-per-tenant'ta her tenant DB'sinde çalıştırman gerekir. Production'da ddl-auto=validate kullandığım için bu bir tercih değil, zorunluluk: yeni bir entity ekleyip migration'ı tenant'lardan birine uygulamayı unutursam uygulama o tenant için açılışta doğrulamayı geçemez ve pod ayağa kalkmaz.
Bu, database-per-tenant'ın izolasyon avantajının faturası. Karşılığında aldığın şey de küçük değil: bir tenant'ın verisi başka bir tenant'ın sorgusunda teknik olarak görünemez — WHERE tenant_id = ? koşulunu unutma riski diye bir şey yoktur, çünkü öyle bir kolon yoktur.
Sırada Ne Var?
Bu yazıda backend'in veritabanı katmanını bitirdik:
AbstractRoutingDataSourceile tenant başına HikariCP pool seçiminull'ın "varsayılan DB" olmaktan çıkıp "hata" olmasının etkisi- Hibernate multi-tenancy API yerine bu seçimin sebepleri
- Connection pool matematiği ve PgBouncer ihtiyacı
- ThreadLocal'ın asenkron sınırdaki limiti
Şimdiye kadar her şey backend'de geçti. Ama Panel ve Tenant Website projeleri Next.js — tenant bağlamını frontend'de de bir şekilde taşımak gerekiyor.
React Context API ile bir TenantProvider yazmak ilk içgüdüydü. Sonunda farklı bir yolda karar kıldım:
Bölüm 3 → Frontend'de Tenant Context — Provider'sız Yaklaşım
Elly ekosistemi hakkında daha fazla bilgi için: huseyindol.com/projects/elly