Java backend geliştirme ekipleri için Hibernate ORM tabanlı tenant izolasyonunu ele alıyoruz: @TenantId, PostgreSQL RLS, bağlantı havuzu sızıntıları, sorgu planı ölçümü ve entegrasyon testleri.
Spring Framework'te Hibernate ORM ile Çok Kiracılı Veri İzolasyonu
Spring Framework ve Hibernate ORM ile tenant sınırını uygulama katmanında kurmak
Çok kiracılı bir sistemde tenant_id alanını sadece Spring Data JPA repository metoduna parametre geçirmek güvenlik sınırı değildir. Bir geliştirici yeni bir repository metodu eklediğinde parametreyi unutabilir, native SQL yazabilir veya findById çağrısıyla farklı bir erişim yolu açabilir. Hibernate ORM tarafında discriminator tabanlı izolasyon için @TenantId kullanmak, ORM'nin ürettiği INSERT ve SELECT ifadelerine aktif tenant koşulunu ekler. Bu yaklaşım, shared-schema Java microservices tasarımında her tenant için ayrı bağlantı havuzu veya ayrı şema işletme maliyetini de ortadan kaldırır.
@Entity
@Table(name = "invoice")
public class Invoice {
@Id
private UUID id;
@TenantId
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@Column(nullable = false)
private Instant createdAt;
@Column(nullable = false)
private BigDecimal total;
}Hibernate'in tenant kimliğini her Session için çözebilmesi gerekir. Kimliği HTTP header'dan doğrudan almak yerine, API gateway tarafından doğrulanmış JWT içindeki tenant_id claim'inden üretin. Aşağıdaki resolver, tenant bağlamı yoksa null dönmek yerine isteği reddeder. Bu ayrım önemlidir: null tenant ile çalışan bir yönetici sorgusu, yanlış yapılandırmada tüm satırları döndürebilecek bir kaçış yoluna dönüşebilir.
@Component
public final class TenantResolver
implements CurrentTenantIdentifierResolver<UUID> {
@Override
public UUID resolveCurrentTenantIdentifier() {
return TenantContext.requireTenantId();
}
@Override
public boolean validateExistingCurrentSessions() {
return false;
}
}
@Configuration
class HibernateTenantConfiguration {
@Bean
HibernatePropertiesCustomizer tenantResolver(
CurrentTenantIdentifierResolver<UUID> resolver) {
return properties -> properties.put(
"hibernate.tenant_identifier_resolver", resolver);
}
}TenantContext bir ThreadLocal kullanıyorsa @Async, Scheduler, Reactor ve mesaj tüketicisi sınırlarında bağlam otomatik taşınmaz. Servlet isteğinde OncePerRequestFilter ile JWT'den UUID okuyup finally bloğunda clear etmek zorunludur. Executor kullanan bir Java backend geliştirme servisi için TaskDecorator ile tenant kimliğini kopyalayın; aksi halde havuzdan yeniden kullanılan worker thread önceki isteğin tenant'ını görebilir. Sanal thread kullanımı bu mantıksal sızıntıyı çözmez, yalnızca thread yaşam döngüsünü değiştirir.
PostgreSQL RLS ile Spring Data JPA ve native SQL için ikinci savunma hattı
@TenantId yalnızca Hibernate'in ürettiği SQL için koruma sağlar. EntityManager.createNativeQuery, JdbcTemplate veya veri dışa aktarma kodu tenant filtresini atlayabilir. Bu nedenle PostgreSQL Row Level Security politikası, veritabanına ulaşan her sorgu için ikinci sınır olmalıdır. Tablo sahibi varsayılan olarak RLS'yi bypass edebildiğinden uygulama kullanıcısını migration sahibinden ayırın, uygulama rolüne BYPASSRLS vermeyin ve tabloya FORCE ROW LEVEL SECURITY uygulayın.
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
CREATE POLICY invoice_tenant_policy ON invoice
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON invoice TO app_runtime;RLS değişkenini connection seviyesinde SET ile ayarlamak HikariCP altında tehlikelidir: fiziksel bağlantı havuza döndüğünde sonraki isteğe eski tenant değeri kalabilir. Bunun yerine aynı veritabanı transaction'ında set_config(..., true) kullanın. true üçüncü parametresi ayarı transaction-local yapar; commit veya rollback sonrasında PostgreSQL değeri temizler. Aşağıdaki çağrı, aynı DataSource ve aynı @Transactional sınırı içinde repository erişiminden önce yapılmalıdır.
@Service
@RequiredArgsConstructor
public class InvoiceQueryService {
private final JdbcTemplate jdbc;
private final InvoiceRepository invoices;
@Transactional(readOnly = true)
public List<Invoice> recentInvoices() {
UUID tenant = TenantContext.requireTenantId();
jdbc.queryForObject(
"select set_config('app.tenant_id', ?, true)",
String.class,
tenant.toString());
return invoices.findTop100ByOrderByCreatedAtDesc();
}
}Bu desende ilk SQL komutunun set_config olması gerekir. Transaction açılmadan çalışan bir interceptor veya autocommit bağlantısındaki SET LOCAL beklenen korumayı sağlamaz. Ayrıca connection pool reset davranışını doğrulamak için iki farklı tenant ile ardışık test çalıştırın. Spring Data JPA metodu doğru sonuç verse bile, aynı testte JdbcTemplate ile filtresiz bir SELECT deneyin; RLS etkinse yalnızca aktif tenant satırları gelmelidir.
Java microservices ortamında tenant filtreli sorguları profil etmek ve indekslemek
Tenant koşulu eklendikten sonra sorgu planını yeniden ölçmeden indeks tasarımı tamamlanmış sayılmaz. PostgreSQL'de pg_stat_statements ile önce en çok çağrılan invoice listeleme sorgusunun calls, mean_exec_time ve shared_blks_read değerlerini kaydedin. Ardından üretime benzer tenant dağılımı ve veri hacmiyle aynı yükü çalıştırın. Karşılaştırmada sadece ortalama süreye değil, p95 uygulama gecikmesine ve EXPLAIN (ANALYZE, BUFFERS) içindeki heap fetch ile buffer read sayılarına bakın.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total
FROM invoice
WHERE tenant_id = '6d68baf0-7b7f-4c34-a3e5-1ea0a5e0f152'
AND (created_at, id) < ('2026-09-01T00:00:00Z', '00000000-0000-0000-0000-000000000000')
ORDER BY created_at DESC, id DESC
LIMIT 100;
CREATE INDEX CONCURRENTLY ix_invoice_tenant_created_id
ON invoice (tenant_id, created_at DESC, id DESC)
INCLUDE (total);Önceki planda tenant_id tek başına indekslenmişse PostgreSQL çok sayıda tenant satırını okuyup created_at için sort yapabilir. Bileşik indeksin sırası WHERE eşitlik koşulu olan tenant_id, ardından sıralama anahtarları olmalıdır. INCLUDE(total), PostgreSQL visibility map yeterince güncelse index-only scan ile heap erişimini azaltır. Ancak yoğun UPDATE yapılan tabloda visibility map sık bozulacağı için index-only scan'i varsaymayın; EXPLAIN çıktısındaki Heap Fetches değerini önce-sonra karşılaştırmasının parçası yapın.
Offset pagination yerine keyset pagination kullanın; OFFSET 50000 motorun ilk 50000 satırı bulup atmasını gerektirir. Aşağıdaki Spring Data JPA sorgusu createdAt eşitliğinde id ile bağ bozumu yapar. UUID v4 sıralaması iş gereksinimi değilse, zaman sıralı bir kimlik veya ayrı monoton kayıt anahtarı daha iyi indeks yerelliği sağlayabilir.
@Query("""
select i from Invoice i
where (i.createdAt < :cursorTime)
or (i.createdAt = :cursorTime and i.id < :cursorId)
order by i.createdAt desc, i.id desc
""")
List<Invoice> findNext(
@Param("cursorTime") Instant cursorTime,
@Param("cursorId") UUID cursorId,
Pageable pageable);Spring Boot eğitimi projelerinde izolasyonu Testcontainers ile kanıtlamak
RLS ve @TenantId için yalnızca repository unit testi yeterli değildir; gerçek PostgreSQL davranışını doğrulamak için Testcontainers kullanın. Test veritabanı migration kullanıcısı ile uygulama runtime kullanıcısının farklı olmasına dikkat edin. Aksi halde tablo sahibi RLS'yi bypass ettiği için testler yeşil görünürken production'da farklı sonuç alınabilir. Flyway migration'larını yönetici kullanıcıyla, uygulama sorgularını app_runtime rolüyle çalıştırın.
@Test
void nativeSql_cannot_read_another_tenants_row() {
UUID tenantA = UUID.randomUUID();
UUID tenantB = UUID.randomUUID();
insertInvoice(tenantA, new BigDecimal("10.00"));
insertInvoice(tenantB, new BigDecimal("20.00"));
TenantContext.set(tenantA);
transactionTemplate.executeWithoutResult(status -> {
jdbc.queryForObject(
"select set_config('app.tenant_id', ?, true)",
String.class, tenantA.toString());
Integer count = jdbc.queryForObject(
"select count(*) from invoice", Integer.class);
assertThat(count).isEqualTo(1);
});
}Bir diğer yaygın hata, tenant_id alanını istemcinin POST gövdesinden kabul etmektir. DTO'da tenantId bulunmasın; entity değerini resolver ve Hibernate belirlesin. Java eğitimi veya java programlama eğitimi içeriklerinde bu detay genellikle atlanır: @TenantId alanına setter koymak, uygulama katmanında yanlış tenant atamasına imkan verebilir. Entity'yi aggregate kökü dışında doğrudan deserialize etmeyin ve MapStruct eşlemesinde tenantId'yi ignore edin.
Bu konu bir java kursu müfredatında sadece Spring Data JPA anotasyonları olarak işlenmemelidir. java fullstack eğitimi alan bir ekipte frontend'in tenant bilgisini route veya header ile taşıması normal olabilir, fakat backend bunu yetki kaynağı saymamalıdır. Kimlik doğrulama token'ındaki claim, gateway imzası ve veritabanı RLS politikası birbirinden bağımsız üç kontrol noktası oluşturur.
Spring AI, Spring MCP ve Model Context Protocol araçlarında tenant yetkisi
Spring AI veya Spring MCP ile bir arama, fatura özeti ya da rapor aracı modele açıldığında model context protocol istemcisinden gelen tenantId argümanını güvenilir kabul etmeyin. Model tarafından üretilen argümanlar kullanıcı girdisinin uzantısıdır ve araç çağrısı sırasında değiştirilebilir. Tool metodu tenant bilgisini yalnızca Spring Security SecurityContext içindeki doğrulanmış principal'dan almalı, repository veya JdbcTemplate tarafında yukarıdaki TenantContext ve RLS akışı aynen çalışmalıdır.
@Tool(description = "Aktif kullanıcının son faturalarını getirir")
public List<InvoiceSummary> recentInvoicesForCurrentTenant() {
UUID tenant = tenantFromAuthenticatedJwt();
TenantContext.set(tenant);
try {
return invoiceQueryService.recentInvoices().stream()
.map(InvoiceSummary::from)
.toList();
} finally {
TenantContext.clear();
}
}Model Context Protocol sunucusu ayrı bir process ise HTTP veya stdio sınırından gelen tenant bağlamını körlemesine iletmeyin. Kısa ömürlü, audience'i MCP servisi olan imzalı servis tokenı üretin; MCP sunucusu token claim'ini doğruladıktan sonra kendi TenantContext'ini kurmalıdır. Bu tasarım, spring framework uygulamasındaki web oturumunun bir araç çağrısına yanlışlıkla taşınmasını önler ve denetim kayıtlarında principal, tool adı, tenant ve sorgu kimliğini birlikte saklamayı mümkün kılar.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
Spring Data JPA ile tenant_id filtresi koymak çok kiracılı izolasyon için yeterli mi?
Hayır. Repository sorgularındaki tenant filtresi native SQL, JdbcTemplate, yönetim scriptleri ve unutulmuş yeni metotlar tarafından atlanabilir. Hibernate ORM @TenantId ile ORM sorgularını, PostgreSQL RLS ile tüm SQL erişimlerini sınırlayın. RLS testi uygulama rolüyle ve gerçek PostgreSQL Testcontainers örneğinde çalışmalıdır.
Java microservices uygulamasında HikariCP tenant verisini başka isteğe taşır mı?
Connection üzerinde SET app.tenant_id kullanılır ve temizlenmezse taşıyabilir. Transaction içinde SELECT set_config('app.tenant_id', ?, true) çağırın; true değeri ayarı transaction-local yapar. Aynı fiziksel bağlantının iki farklı tenant isteğinde tekrar kullanıldığı entegrasyon testi bu riski yakalar.
Spring Boot eğitimi sırasında @TenantId kullanılan sorgu nasıl performans testi yapılır?
Önce pg_stat_statements ile çağrı sayısı, mean_exec_time ve blok okumalarını kaydedin. Sonra EXPLAIN (ANALYZE, BUFFERS) ile tenant_id, created_at, id bileşik indeksi öncesi ve sonrası planı karşılaştırın. Offset yerine keyset pagination kullanın ve Heap Fetches ile p95 gecikmeyi aynı yük altında ölçün.
Spring AI ve Spring MCP araçlarında model context protocol tenantId parametresi güvenilir mi?
Güvenilir değildir. Tool argümanındaki tenantId model veya kullanıcı girdisiyle değiştirilebilir. Tenant kimliğini doğrulanmış JWT principal'ından çıkarın, araç çağrısında TenantContext kurun ve veritabanında RLS ile tekrar doğrulayın. MCP ayrı servis ise audience'i MCP olan imzalı servis tokenı kullanın.
AI / LLM Discovery
Bu makale Opendart Akademi Java eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.


