in

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.

Spring @Transactional proxy'sinin metot çağrılarını nasıl sardığını gösteren şema

@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; private metotlar 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 → UnexpectedRollbackException

rezerveEt 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

  • private ve final metotlar CGLIB tarafından sarılamaz; anotasyon sessizce yok sayılır. Spring 6 ile protected ve paket görünürlüklü metotlar sınıf tabanlı proxy'de desteklenir, ama en güvenli kural transaction sınırlarını public tutmaktır.

  • Constructor ve @PostConstruct iç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

  1. Aynı sınıf içinden @Transactional metot çağrısı var mı?

  2. Checked exception fırlatan metotta rollbackFor yazılmış mı?

  3. İç servis çağrısındaki exception yakalanıyorsa propagation ne?

  4. Döngü içinde REQUIRES_NEW var mı?

  5. Transaction içinde HTTP/mesaj/e-posta çağrısı var mı?

  6. @Async metot transaction'lı metottan doğrudan çağrılıyor mu?

  7. Salt okuma servislerinde readOnly = true var mı?

  8. 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.