Spring @Transactional Tuzakları: 8 Yaygın Hata ve Çözümü
@Transactional bir anotasyon değil, bir proxy'dir; hataların çoğu bunu unutmaktan çıkar. Self-invocation, checked exception'da rollback olmaması, REQUIRES_NEW'un bağlantı havuzunu tüketmesi, readOnly'nin gerçek etkisi ve testlerde yanıltan transaction davranışını kod örnekleriyle anlatıyoruz.
@Transactional, Spring'de en çok yazılan ve en az anlaşılan anotasyondur. Üretimde gördüğümüz veri tutarsızlıklarının önemli bir kısmı — yarım kalan siparişler, rollback olmayan hatalar, bağlantı havuzunun tükenmesi — aynı kök nedene iner: anotasyonun bir proxy olduğunu unutmak. Bu yazıda önce mekanizmayı, sonra üretimde en sık karşılaştığımız sekiz tuzağı ve her birinin çözümünü kod örnekleriyle ele alıyoruz.
Önce mekanizma: anotasyon değil, proxy
Spring, @Transactional taşıyan bean'in etrafına bir proxy (CGLIB alt sınıfı ya da JDK dinamik proxy) sarar. Metodu çağıran kod aslında proxy'yi çağırır; proxy transaction'ı açar, gerçek metodu çalıştırır, sonuca göre commit ya da rollback yapar. Bu tek cümleden dört sonuç çıkar:
Transaction yalnızca çağrı proxy üzerinden geçtiğinde başlar.
Proxy yalnızca dışarıdan görünen metotları sarabilir;
privatemetotlar sarılmaz.Rollback kararı, metottan çıkan exception'ın tipine göre verilir.
Transaction, çağrının yapıldığı thread'e bağlıdır.
Tuzak 1: Self-invocation — aynı sınıf içinden çağrı
@Service
public class SiparisService {
public void topluIsle(List<Talep> talepler) {
for (Talep t : talepler) {
isle(t); // proxy'den değil, this üzerinden çağrı: TRANSACTION YOK
}
}
@Transactional
public void isle(Talep t) { ... }
}topluIsle içinden isle çağrısı this.isle() demektir; proxy devrede değildir ve @Transactional hiç çalışmaz. Kod hatasız çalışır, yalnızca transaction'sız çalışır. Çözüm, transaction sınırı olan metodu ayrı bir bean'e taşımaktır; hızlı çözüm ise TransactionTemplate ile programatik sınır:
@Service
public class SiparisService {
private final TransactionTemplate tx;
public void topluIsle(List<Talep> talepler) {
for (Talep t : talepler) {
tx.executeWithoutResult(status -> isle(t)); // her talep kendi transaction'ında
}
}
}Tuzak 2: Checked exception rollback yapmaz
Varsayılan kural: yalnızca RuntimeException ve Error rollback tetikler. IOException veya kendi yazdığınız checked bir OdemeHatasi fırlarsa transaction commit edilir.
@Transactional(rollbackFor = Exception.class) // checked exception'lar da geri alınsın
public void odemeAl(Siparis s) throws OdemeHatasi { ... }Ekip kuralı olarak iki seçenekten birini standartlaştırın: ya iş hatalarını RuntimeException türetin ya da her @Transactional'a rollbackFor = Exception.class yazın. İkisini karıştıran projelerde hangi hatanın geri alındığı kimse tarafından bilinmez.
Tuzak 3: Propagation seçimi ve UnexpectedRollbackException
Propagation | Davranış | Tipik kullanım |
|---|---|---|
REQUIRED (varsayılan) | Varsa katıl, yoksa yeni aç | Servis metotlarının neredeyse tamamı |
REQUIRES_NEW | Mevcut olanı askıya al, yeni bağlantıyla yeni transaction aç | Ana işlem başarısız olsa da kalması gereken kayıt: denetim logu, hata kaydı |
NESTED | Aynı transaction içinde savepoint | Kısmi geri alma; yalnızca JDBC tabanlı yöneticilerde |
SUPPORTS | Varsa katıl, yoksa transaction'sız çalış | Salt okuma yardımcıları |
MANDATORY | Yoksa hata fırlat | "Bu metot asla tek başına çağrılmasın" garantisi |
NOT_SUPPORTED / NEVER | Transaction dışında çalış / varsa hata | Uzun süren dış çağrılar |
En sık görülen senaryo, iç metodun hatasını dış metodun yakalamasıdır:
@Transactional
public void siparisOlustur(Talep t) {
repository.save(...);
try {
stokService.rezerveEt(t); // @Transactional(REQUIRED): AYNI transaction'a katılır
} catch (StokYetersiz e) {
bildirimService.uyar(t); // "hatayı yönettim" sanılır
}
} // commit denenir → UnexpectedRollbackExceptionrezerveEt aynı transaction'a katıldığı için fırlattığı exception transaction'ı rollback-only olarak işaretler; dış metot hatayı yutsa bile commit anında Spring UnexpectedRollbackException fırlatır. Hatanın gerçekten yönetilebilmesi için ya iç çağrı REQUIRES_NEW olmalı ya da iç metot exception yerine sonuç nesnesi döndürmelidir.
REQUIRES_NEW'un bedeli: askıya alınan transaction bağlantısını bırakmaz, yeni transaction ikinci bir bağlantı alır. Döngü içinde REQUIRES_NEW çağrısı yapan bir servis, havuz boyutu kadar eşzamanlı istekte kilitlenir. Havuz boyutunu artırmak çözüm değil, semptomu ötelemektir.
Tuzak 4: readOnly bir ipucudur, kilit değildir
@Transactional(readOnly = true) yazma işlemini engellemez; Hibernate'e flush modunu MANUAL yapmasını söyler (dirty checking maliyeti kalkar) ve sürücüye/veritabanına salt okuma ipucu geçer. Gerçek kazanımı iki yerde görürsünüz: büyük listeleme sorgularında bellek ve CPU, okuma kopyalarına yönlendirme (replica routing) yapan veri kaynaklarında. Salt okuma servislerine yazılması gereken bir ayar; güvenlik önlemi olarak güvenilecek bir ayar değil.
Tuzak 5: @Async, @Scheduled ve yeni thread
Transaction thread'e bağlıdır. @Transactional bir metottan @Async bir metodu çağırırsanız, asenkron metot başka thread'de, başka bir transaction'da (ya da hiç transaction olmadan) çalışır; ana transaction rollback olsa bile asenkron iş yapılmış olur. Aynı şey CompletableFuture.supplyAsync, paralel stream ve sanal thread'lerle elle başlatılan işler için de geçerlidir. Doğru desen: asenkron işi transaction commit olduktan sonra tetiklemek.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void on(SiparisOlusturuldu e) {
eposta.gonder(e); // yalnızca commit gerçekleştiyse çalışır
}Tuzak 6: Transaction içinde dış çağrı
Transaction açıkken HTTP çağrısı yapmak, kuyruğa mesaj yazmak ya da e-posta göndermek iki sorun üretir: bağlantı, dış sistemin yanıt süresi boyunca havuzda tutulur ve dış işlem yapıldıktan sonra transaction rollback olursa geri alınamaz ("e-posta gitti, sipariş yok"). Kural: transaction sınırı veritabanı işlemleriyle biter; dış etkiler AFTER_COMMIT dinleyicisine ya da outbox tablosuna bırakılır.
Tuzak 7: Proxy'nin göremediği metotlar
privatevefinalmetotlar CGLIB tarafından sarılamaz; anotasyon sessizce yok sayılır. Spring 6 ileprotectedve paket görünürlüklü metotlar sınıf tabanlı proxy'de desteklenir, ama en güvenli kural transaction sınırlarınıpublictutmaktır.Constructor ve
@PostConstructiçinde yapılan çağrılar proxy tam kurulmadan çalışır; transaction beklemeyin.Anotasyon arayüze değil sınıfa yazılmalıdır; CGLIB proxy arayüz anotasyonunu görmeyebilir.
Tuzak 8: Testte @Transactional, üretimde sürpriz
@SpringBootTest sınıfına @Transactional koymak testi kendi transaction'ında koşturur ve sonunda rollback eder. İki yan etki vardır: lazy koleksiyonlar testte yüklenir ama üretimde LazyInitializationException verir, ve Hibernate hiç flush etmediği için kısıt ihlalleri (unique, not null) testte görünmez. Entegrasyon testlerinde ya @Transactional kullanmayıp veriyi temizleyin ya da TestEntityManager.flush() ile yazmayı zorlayın.
Kod incelemesi kontrol listesi
Aynı sınıf içinden
@Transactionalmetot çağrısı var mı?Checked exception fırlatan metotta
rollbackForyazılmış mı?İç servis çağrısındaki exception yakalanıyorsa propagation ne?
Döngü içinde
REQUIRES_NEWvar mı?Transaction içinde HTTP/mesaj/e-posta çağrısı var mı?
@Asyncmetot transaction'lı metottan doğrudan çağrılıyor mu?Salt okuma servislerinde
readOnly = truevar mı?Entegrasyon testleri flush davranışını doğruluyor mu?
Sık sorulan sorular
@Transactional'ı repository'ye mi servise mi koymalıyım?
Servise. Transaction sınırı bir iş kuralını temsil eder ("sipariş oluştur" ya hep ya hiç); repository metotları bu sınırın içinde çalışır. Spring Data repository'leri zaten kendi metotlarını transaction'lı çalıştırır, ama iş kuralı sınırı servis katmanındadır.
Sınıf düzeyinde @Transactional yazmak doğru mu?
Servis sınıfları için pratik bir varsayılandır; salt okuma metotlarını readOnly = true ile ayrıca işaretleyin. Controller veya component sınıflarına yazmayın.
Transaction ne kadar sürebilir?
Milisaniyeler mertebesinde olmalıdır. Saniyeler süren transaction'lar kilit ve bağlantı tüketiminin ana nedenidir; timeout özelliğiyle üst sınır koyun ve uzun işleri parçalara bölün.
Sonuç
@Transactional'ın tuzakları anotasyonun kendisinden değil, proxy modelinin görünmez olmasından çıkar. Çağrının proxy'den geçtiğini, hangi exception'ın rollback tetiklediğini, propagation'ın bağlantı maliyetini ve thread sınırını bilen bir ekip bu hataların tamamından kaçınır.
Mevcut uygulamanızın transaction sınırlarını ve veri tutarlılığı risklerini gözden geçirmek için yazılım tasarımı ve mimari hizmetimize göz atabilirsiniz.