İçeriğe geç

Transaction Propagation ve Rollback Yayılımı

Orta 10 dk Çok sık karşılaşılır

30 saniyede özet

Bir metot patladı, sen hatayı yakalayıp devam ettin; ama sipariş yine de kaydedilmedi. Çünkü içteki metot ayrı bir transaction açmamış, seninkine katılmış ve onu 'geri alınacak' diye işaretlemişti.

İki kişi aynı alışveriş sepetini taşıyor. Biri sepete kırık bir yumurta koyunca kasiyer bütün sepeti geri çeviriyor.

Aynı sepeti mi paylaşıyorsunuz, yoksa her birinizin kendi sepeti mi var?
Adım adım oku
  1. REQUIRED ile stok ayıran içteki metot, siparişin açtığı transaction'a, yani aynı sepete katılır.
  2. İçteki metot patlar ve paylaşılan sepete geri alınacak damgası vurulur.
  3. Dıştaki metot hatayı yakalayıp devam etse de commit anında damga görülür ve sepetin tamamı geri alınır.
  4. REQUIRES_NEW ile içteki metot kendi sepetini açar. Yalnızca o geri alınır, sipariş kaydedilir.
  1. Bayt: Stok ayırma patlarsa hatayı yakalayıp siparişi yine de kaydedeceğim. Mantıklı, değil mi?

  2. Sen: Kulağa mantıklı geliyor.

  3. Bayt: Ama sipariş hiç kaydolmadı, üstüne bir de UnexpectedRollbackException geldi!

  4. Bayt: İki metot aynı sepeti paylaşıyorsa, sepete vurulan damga ikisini de bağlar.

Kodda sipariş kaydediliyor; stok ayırma patlarsa hata yakalanıyor ve sipariş yine de kaydedilmek isteniyor. Ama bu kod tam tersini yapar:

OrderService.java
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
try {
inventoryService.reserve(order); // @Transactional, patlıyor
} catch (RuntimeException e) {
log.warn("Stok rezerve edilemedi, siparişi yine de kaydet", e);
}
}

Exception yakalandı, akış devam etti, metot sorunsuz döndü. Ama placeOrder çağıran kod UnexpectedRollbackException alır ve sipariş de kaydedilmez.

Sipariş neden kaydolmadı?

placeOrder() içinde çağrılan reserve() bir hata fırlattı. Hatayı try/catch ile yakalayıp devam ettin. placeOrder() bitince sipariş kaydolur mu? Cevabı göster

Hayır. Paylaşılan transaction geri alınmak üzere işaretlendi; commit anında UnexpectedRollbackException gelir.

reserve() metodu da @Transactional ve propagationAçık bir transaction varken yeni bir `@Transactional` metodun ne yapacağını belirleyen ayar. Varsayılan `REQUIRED` var olana katılır, yeni açmaz.Sözlükte gör → ayarı varsayılan olan REQUIRED. REQUIRED yeni bir transaction açmaz: ortada zaten bir tane varsa, ona katılır.

Yani kodda iki @Transactional var ama veritabanında tek bir transaction açık, tıpkı iki kişinin aynı alışveriş sepetini doldurması gibi. reserve() patladığında Spring bu ortak sepete “geri alınacak” (rollback-only) etiketi yapıştırır ve bu etiket bir daha sökülmez.

Dışarıda hatayı yakalasan bile, kaydetme anında Spring “bu sepet rollback-onlyTransaction'a konan "bu artık commit edilemez" bayrağı. İç bir çağrıda istisna olduğunda konur; sen istisnayı yutsan bile dıştaki commit patlar.Sözlükte gör →-only işaretli, kaydedemem” der ve UnexpectedRollbackException fırlatır.

Kafam karıştı, daha basit anlat

İki kişi aynı alışveriş sepetini dolduruyor. Biri “bu sepet iptal” derse, ötekinin koyduğu ürünler de sepetle birlikte gider.

Hızlı kontrolBaşlangıç

`REQUIRED` (varsayılan) propagation ne yapar?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

Varsayılan ayar tam olarak yukarıdaki kodu modelliyor. Adımla ve TX#1’in ne zaman rollback-only olduğuna bak.

@Transactional propagation ve rollback yayılımı

Tohum 1

Çağrı zinciri

  • OrderService.placeOrder()REQUIRED
  • InventoryService.reserve()REQUIRED

Fiziksel transaction’lar (0)

Henüz transaction açılmadı.

Kalıcı olan işler

— hiçbiri —

Hız
Adım 0

Şu an ne oldu?

Transaction açıldı

Dış metot @Transactional olduğu için Spring bir proxy üzerinden çağrıyı sarmalayıp transaction başlattı.

Aklında kalsın: Proxy detayı önemlidir: aynı sınıf içinden this.method() çağrısı proxy’den geçmez, bu yüzden @Transactional hiç çalışmaz.

Görevler0/3

  • UnexpectedRollbackException ile karşılaşaçık

    İpucu

    Varsayılan ayarlarla sonuna kadar ilerle. İstisnayı yakaladın, ama transaction yine de rollback-only işaretlendi.

  • İç metot patlasın, dış işin yine de commit olsun (REQUIRES_NEW)açık

    İpucu

    İç metodu REQUIRES_NEW yap ve istisnayı dışarıda yakala. İç iş kendi transaction’ında geri alınır, dışarısı etkilenmez.

  • Aynı sonucu tek fiziksel transaction ile al (NESTED)açık

    İpucu

    NESTED savepoint kullanır: iç iş savepoint’e geri sarılır, bağlantı ve transaction aynı kalır.

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

Üç ayarı da karşılaştır:

  • REQUIRED + yakala (varsayılan) — tek fiziksel transaction, rollback-only, sonunda UnexpectedRollbackException. Hiçbir şey kaydedilmiyor.
  • REQUIRES_NEW + yakala — iki fiziksel transaction. TX#2 geri alınıyor, TX#1 temiz kalıyor ve commit oluyor. Sipariş kaydediliyor.
  • NESTED + yakala — tek transaction ama savepoint’li. İç metodun işi geri sarılıyor, dış metodunki korunuyor.

Bir de yakalamayı kapat: exception dışarı çıkıyor, her şey geri alınıyor. Bu zaten beklenen davranış ve en az sürpriz olanı.

Simülatörün altındaki Görevler listesi üç ayarı da tek tek denetiyor. Aynı sonuca iki ayrı yoldan ulaşmak, REQUIRES_NEW ile NESTED arasındaki farkı en net gösteren şey.

Kafam karıştı, daha basit anlat

İçerideki metot hata verince ortak sepete “iptal edilecek” etiketi yapışır. Dıştaki metot hatayı yakalasa bile etiket yerinde durur.

Hızlı kontrolBaşlangıç

`@Transactional` bir metot normal biterse ve `RuntimeException` atarsa ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Üç ayar, üç farklı sonuç

Fiziksel TXBağlantıİç hata dışarıyı bozar mı
REQUIRED1 (katılır)1Evet — rollback-only
REQUIRES_NEW22Hayır
NESTED1 (savepoint)1Hayır

Bu tablodaki bağlantı sütunu en çok atlanan yerdir.

NESTED bu sorunu yaşamaz çünkü aynı bağlantıyı kullanır. Karşılığında savepoint desteği ister: JDBC’de sorunsuz, JPA/Hibernate ile her zaman güvenilir değildir ve bazı veritabanlarında desteklenmez.

Diğer propagation değerleri· istersen atla

Günlük kodda ilk üçü yeter; geri kalanlar şunlar:

  • SUPPORTS — transaction varsa katılır, yoksa transaction’sız çalışır.
  • MANDATORY — transaction yoksa exception. “Beni asla tek başıma çağırma” demek.
  • NEVER — transaction varsa exception.
  • NOT_SUPPORTED — varsa askıya alır, transaction’sız çalışır.
Kafam karıştı, daha basit anlat

REQUIRES_NEW ayrı bir sepet açar, ama ilk sepet de kasada beklemeye devam eder. Çok kişi aynı anda bunu yaparsa kasalar dolar.

Hızlı kontrolOrta

Her propagation seviyesini, açık bir transaction varken ne yaptığına göre ayır.

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

Var olana katılır

Tek fiziksel transaction

    Ayırır veya askıya alır

    Dıştakinden bağımsız davranır

      Tuzaklar

      1. Sınıfın kendi metodunu çağırması. @Transactional, sınıfın önüne konan bir aracı (proxySpring'in bean'in yerine koyduğu sarmalayıcı nesne. `@Transactional` gibi anotasyonlar burada çalışır — çağrı proxy'den geçmezse hiçbir şey olmaz.Sözlükte gör →) sayesinde çalışır. Aynı sınıfın içinden çağırdığın metot bu aracıdan geçmez:

      Çalışmayan kod
      @Service
      public class OrderService {
      public void placeOrder(Order order) {
      this.saveInTransaction(order); // proxy atlandı — @Transactional YOK SAYILDI
      }
      @Transactional
      public void saveInTransaction(Order order) { ... }
      }
      // Çözüm: çağrıyı başka bir bean'e taşı, veya self-injection yap

      Aynı sebeple private, final ve static metotlarda @Transactional çalışmaz — proxy onları sarmalayamaz.

      2. Checked exception rollback yapmaz. Spring varsayılan olarak yalnızca RuntimeException ve Error için geri alır:

      Sessizce commit eden kod
      @Transactional // checked exception'da rollback YOK — işlem commit olur
      public void transfer(Account from, Account to) throws InsufficientFundsException {
      debit(from);
      credit(to); // burada checked exception atarsa debit kalıcı olur
      }
      // Çözüm:
      @Transactional(rollbackFor = Exception.class)
      Hızlı kontrolOrta

      `disaMetot()` çağrıldığında veritabanına kaç kayıt yazılır?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.
      SelfInvocation.java
      1@Service
      2class AuditService {
      3
      4 public void disaMetot() {
      5 repo.save(new Log("dis"));
      6 icMetot();
      7 }
      8
      9 @Transactional(propagation = REQUIRES_NEW)
      10 public void icMetot() {
      11 repo.save(new Log("ic"));
      12 throw new IllegalStateException("patladi");
      13 }
      14}
      Java 21UTF-8LF

      `@Transactional` bir metot içinde yakalanan bir `RuntimeException` sonrası akış devam ediyor. Commit olur mu?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Ne zaman hangisi

      • Varsayılan REQUIRED — çağrı zincirinin tamamı tek bir atomik iş olmalıysa. Çoğu durum budur.
      • REQUIRES_NEW — işlem başarısız olsa bile kalıcı olması gereken şeyler için: denetim kaydı (audit log), başarısızlık sayacı, bildirim kuyruğuna ekleme.
      • NESTED — bir alt işlemin başarısızlığını tolere etmek istiyorsan ve ikinci bir bağlantı ödemek istemiyorsan.

      Aşağıdaki örnek bir bankanın ödeme servisinden. Üç propagation kararı bir arada: denetim izi geri alınmamalı, ücretsiz işlem hakkı kontrolü havaleyi öldürmemeli, toplu maaş dosyasında hatalı bir satır diğer maaşları geri almamalı.

      Derinleş · Bankada propagation: havale, denetim izi ve toplu maaş ödemesi 6 dosya · ~147 satır · ilk okumada atlayabilirsin
      Proje dosyaları

      src/main/java/com/bank/transfer/ TransferService.java Dış transaction: borç ve alacak kayıtları birlikte ya işlenir ya geri alınır. Ücret muafiyeti önce, yan etkisiz kontrol edilir.

      src/main/java/com/bank/transfer/TransferService.java
      @Service
      class TransferService {
      private final AccountRepository accounts;
      private final FeeWaiverService waivers;
      private final AuditTrail audit;
      TransferService(AccountRepository accounts, FeeWaiverService waivers, AuditTrail audit) {
      this.accounts = accounts;
      this.waivers = waivers;
      this.audit = audit;
      }
      @Transactional
      public TransferReceipt transfer(TransferCommand cmd) {
      audit.record("transfer.attempt", cmd.customerId(), cmd.amount()); // survives a rollback below
      // No free-transfer right left is not an error: ask first, no exception involved.
      WaiverCheck waiver = waivers.check(cmd.customerId(), YearMonth.now());
      BigDecimal fee = waiver.free() ? BigDecimal.ZERO : cmd.standardFee();
      Account from = accounts.findForUpdate(cmd.fromIban()); // SELECT ... FOR UPDATE
      Account to = accounts.findForUpdate(cmd.toIban());
      from.debit(cmd.amount().add(fee)); // throws InsufficientFundsException: whole transfer rolls back
      to.credit(cmd.amount());
      if (waiver.free()) waivers.consume(cmd.customerId(), YearMonth.now());
      return new TransferReceipt(from.lastEntryId(), fee);
      }
      }

      src/main/java/com/bank/audit/ AuditTrail.java Denetim izi REQUIRES_NEW: transfer geri alınsa da 'denendi' kaydı kalır.

      src/main/java/com/bank/audit/AuditTrail.java
      @Service
      class AuditTrail {
      private final AuditRepository repository;
      private final Clock clock;
      AuditTrail(AuditRepository repository, Clock clock) {
      this.repository = repository;
      this.clock = clock;
      }
      // Suspends the caller's transaction and commits on its own: a failed transfer
      // attempt still leaves a trace for the compliance team.
      // Costs a second connection from the pool while the outer one waits.
      @Transactional(propagation = Propagation.REQUIRES_NEW)
      public void record(String action, long customerId, BigDecimal amount) {
      repository.save(new AuditEntry(action, customerId, amount, Instant.now(clock)));
      }
      }

      src/main/java/com/bank/fee/ FeeWaiverService.java Ücretsiz işlem hakkı kontrolü exception fırlatmaz; sonuç döndürür. Böylece dış transaction rollback-only olmaz.

      src/main/java/com/bank/fee/FeeWaiverService.java
      @Service
      class FeeWaiverService {
      private final FeeWaiverRepository waivers;
      FeeWaiverService(FeeWaiverRepository waivers) {
      this.waivers = waivers;
      }
      // Joins the caller's transaction (REQUIRED). If this threw a RuntimeException,
      // the shared transaction would be marked rollback-only; catching it in the caller
      // would then end in UnexpectedRollbackException at commit — and the transfer is lost.
      @Transactional(readOnly = true)
      public WaiverCheck check(long customerId, YearMonth month) {
      return waivers.findByCustomerAndMonth(customerId, month)
      .filter(w -> w.remaining() > 0)
      .map(w -> WaiverCheck.FREE)
      .orElse(WaiverCheck.CHARGED);
      }
      @Transactional
      public void consume(long customerId, YearMonth month) {
      waivers.findByCustomerAndMonth(customerId, month).orElseThrow().useOne();
      }
      }

      src/main/java/com/bank/payroll/ PayrollFileProcessor.java Toplu maaş dosyasında satır başına REQUIRES_NEW: ayrı bir bean, yoksa proxy atlanır. Hatalı bir IBAN yalnızca kendi satırını düşürür.

      src/main/java/com/bank/payroll/PayrollFileProcessor.java
      @Service
      class PayrollFileProcessor {
      private final SalaryPayer payer; // a separate bean: calls go through its proxy
      PayrollFileProcessor(SalaryPayer payer) {
      this.payer = payer;
      }
      // A company uploads one file for all its employees. No transaction here:
      // each salary gets its own, so a closed account fails alone and the rest are paid.
      public PayrollReport process(PayrollFile file) {
      var report = new PayrollReport(file.id());
      for (PayrollLine line : file.lines()) {
      try {
      payer.pay(file.companyIban(), line);
      report.paid(line);
      } catch (RuntimeException ex) {
      report.rejected(line, ex.getMessage()); // sent back to the company
      }
      }
      return report;
      }
      }
      @Service
      class SalaryPayer {
      private final AccountRepository accounts;
      SalaryPayer(AccountRepository accounts) {
      this.accounts = accounts;
      }
      @Transactional(propagation = Propagation.REQUIRES_NEW)
      public void pay(String companyIban, PayrollLine line) {
      accounts.findForUpdate(companyIban).debit(line.amount());
      accounts.findForUpdate(line.employeeIban()).credit(line.amount());
      }
      }

      src/main/java/com/bank/billing/ AutoBillPayments.java Aynı ayrımı anotasyonsuz yapmak: otomatik fatura ödemelerinde TransactionTemplate.

      src/main/java/com/bank/billing/AutoBillPayments.java
      @Service
      class AutoBillPayments {
      private final TransactionTemplate perBill;
      private final BillPaymentRepository bills;
      AutoBillPayments(PlatformTransactionManager txManager, BillPaymentRepository bills) {
      this.perBill = new TransactionTemplate(txManager);
      this.perBill.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
      this.bills = bills;
      }
      // Same idea as SalaryPayer, without a second bean: the boundary is explicit in code.
      // Electricity, water, phone: one bill failing for lack of funds must not undo the others.
      public int payDueToday(List<DueBill> due) {
      int paid = 0;
      for (DueBill bill : due) {
      try {
      perBill.executeWithoutResult(status -> bills.payFromAccount(bill));
      paid++;
      } catch (RuntimeException ex) {
      // this bill rolled back and will be retried tomorrow; the others are unaffected
      }
      }
      return paid;
      }
      }

      src/main/resources/ application.yml Hangi transaction'ın ne zaman açıldığını log'da görmek.

      src/main/resources/application.yml
      logging:
      level:
      # "Creating new transaction", "Participating in existing transaction",
      # "Suspending current transaction" — the propagation decisions, line by line.
      org.springframework.orm.jpa.JpaTransactionManager: DEBUG
      org.springframework.transaction.interceptor: TRACE

      Kendini sına

      Şimşek turu1/5

      REQUIRED, var olan bir transaction varsa ona katılır.

      Soru 1/4Orta

      Dış metot @Transactional. İç metot da @Transactional (REQUIRED) ve RuntimeException fırlatıyor. Dış metot bu exception'ı try/catch ile yakalayıp devam ediyor. Sonuç ne olur?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Aklında kalacak üç şey

      1. 1 REQUIRED yeni bir transaction açmaz, var olana katılır. Kodda iki @Transactional görmek, veritabanında iki transaction olduğu anlamına gelmez.
      2. 2 İç metot patlayınca ortak transaction 'yalnızca geri alınabilir' diye işaretlenir; hatayı dışarıda yakalamak bu işareti silmez. Çözüm REQUIRES_NEW ya da NESTED'dir.
      3. 3 İki tuzak daha: this.metot() çağrısında @Transactional hiç çalışmaz, ve Spring varsayılan olarak yalnızca RuntimeException'da geri alır.
      Sonraki kapı Bir istek controller'a varmadan önce kaç turnikeden geçer? Spring Security Filter Chain · 10 dk

      5 kart sonraki derste seni bekliyor

      0/5 kart bu dersten toplandı

      Bu dersin üstüne kurulanlar

      Bunlar bu dersi temel alıyor; hazır olduğunda devam edebilirsin.