Spring · Bölüm 8

Transaction yönetimi ve JPA: @Transactional, propagation, N+1 ve locking

@Transactional'ın proxy ve transaction manager üzerinden nasıl çalıştığını, rollback ve propagation kurallarının hangi production hatalarını ürettiğini, JPA persistence context'inin içini, N+1 ve sayfalama tuzaklarını ve optimistic/pessimistic locking seçimini öğretir.

~50 dk okumaQuiz: q5, q8, q18
Cevaplar:

Bu bölümde

Backend mülakatlarının en çok kod okuma sorusu çıkan alanı burasıdır: "Bu metot exception fırlattı, sipariş DB'de kaldı mı?", "catch'e aldık ama neden yine rollback oldu?", "bu tek satırlık repository metodunda kaç problem var?" Bu bölümde @Transactional'ın proxy'den transaction manager'a, oradan JDBC bağlantısına kadar nasıl işlediğini, rollback ve propagation kurallarının mekanizmasını, JPA'nın persistence context, dirty checking ve flush davranışını, N+1 ve sayfalama tuzaklarını ve eşzamanlı güncellemelerde hangi kilitleme stratejisinin seçileceğini öğreneceksin. Örnekler, bu kitabın eşlik eden projesi flash-sale-outbox-demo'dan (stok düşümü, sipariş, outbox) gelir.

Konu anlatımı

@Transactional içeride ne yapar?

@Transactional bir anotasyondur, tek başına hiçbir şey yapmaz. İşi yapan zincir şudur:

text
Controller
  -> CGLIB proxy (OrderService$$SpringCGLIB)
     -> TransactionInterceptor
        -> TransactionAspectSupport.invokeWithinTransaction
           1) TransactionAttribute oku (propagation, isolation, readOnly, timeout, rollback kuralları)
           2) PlatformTransactionManager.getTransaction(definition)
                -> JpaTransactionManager: EntityManager aç, JDBC bağlantısı al, autocommit=false
                -> kaynakları TransactionSynchronizationManager'a (ThreadLocal) bağla
           3) gerçek OrderService.placeOrder(...) çağrılır
           4a) normal dönüş  -> commit
           4b) exception     -> rollback kurallarını uygula -> rollback veya commit
           5) kaynakları thread'den çöz, bağlantıyı havuza geri ver

Üç kavram mülakatta sık karışır:

  • Proxy: Bean'in önüne konan nesne; sadece dışarıdan gelen çağrıyı yakalar (bkz. Bölüm 6).
  • TransactionInterceptor: Proxy'nin çağırdığı advice; transaction'ın ne zaman başlayıp biteceğine karar verir.
  • PlatformTransactionManager: Kaynağa özgü işi yapan soyutlama. JPA için JpaTransactionManager, düz JDBC için DataSourceTransactionManager (veya JdbcTransactionManager), dağıtık XA için JtaTransactionManager, Kafka için KafkaTransactionManager. Spring Boot, spring-boot-starter-data-jpa varken JpaTransactionManager'ı auto-configure eder.

Transaction thread'e bağlıdır. TransactionSynchronizationManager aktif bağlantıyı ve EntityManager'ı bir ThreadLocal'da tutar. Repository, "şu an bu thread'de açık bir transaction var mı?" sorusunu buradan cevaplar. Bunun doğrudan sonuçları:

  • @Async metot, CompletableFuture.supplyAsync, parallelStream veya kendi açtığın executor'daki iş çağıranın transaction'ını görmez. Yeni thread'de ya transaction yoktur ya kendi transaction'ı açılır.
  • Virtual thread'lerde de aynı kural geçerlidir; her virtual thread kendi ThreadLocal'ına sahiptir.
  • Reactive (WebFlux) dünyasında thread'e bağlılık çalışmaz; orada ReactiveTransactionManager ve Reactor context kullanılır.

Programatik alternatif: TransactionTemplate. Anotasyon yerine transaction sınırını kodla çizmek gerektiğinde (bir metodun sadece bir bölümü, döngü içindeki her batch için ayrı transaction, self-invocation'dan kaçınma):

java
@Service
public class PriceImportService {

    private final TransactionTemplate tx;
    private final ProductPriceRepository repository;

    public PriceImportService(PlatformTransactionManager txManager, ProductPriceRepository repository) {
        this.tx = new TransactionTemplate(txManager);
        this.repository = repository;
    }

    public void importAll(List<List<PriceRow>> batches) {
        for (List<PriceRow> batch : batches) {
            // Her batch kendi transaction'ında: biri başarısız olursa önceki batch'ler kalır.
            tx.executeWithoutResult(status -> batch.forEach(row -> repository.upsert(row.productId(), row.price())));
        }
    }
}

Rollback kuralları

Spring'in varsayılan kuralı:

  • RuntimeException ve Error → rollback.
  • Checked exception (Exception'dan türeyen ama RuntimeException olmayan) → commit.

Checked exception'da commit, EJB'den kalma bir gelenektir: "checked exception iş akışının beklenen bir sonucudur, o ana kadar yapılan iş geçerlidir" varsayımı. Pratikte bu varsayım çoğu zaman yanlıştır ve sessiz veri tutarsızlığı üretir. Demo projedeki OrderService bunu bilinçli olarak ele alır:

java
private String toJson(OrderCreatedEvent event) {
    try {
        return objectMapper.writeValueAsString(event);
    } catch (JsonProcessingException e) {
        // JsonProcessingException CHECKED. Olduğu gibi fırlatsaydık Spring COMMIT ederdi:
        // stok düşer, sipariş yazılır, ama outbox event'i yazılmazdı.
        throw new IllegalStateException("Failed to serialize OrderCreatedEvent", e);
    }
}

Kuralı değiştirmenin yolları:

java
@Transactional(rollbackFor = PaymentDeclinedException.class)       // checked olsa da rollback
@Transactional(noRollbackFor = CouponExpiredException.class)       // runtime olsa da rollback işareti koyma

Spring Framework 6.2+ ile varsayılan kural global olarak değiştirilebilir: @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) checked exception'larda da rollback yapar. Spring dokümantasyonu, EJB tarzı "commit eden iş exception'ları"na bilinçli olarak dayanmıyorsan bu ayara geçmeyi önerir.

Unutulmaması gereken nokta: rollback kuralları sadece exception proxy sınırını geçerken uygulanır. Metodun içinde yakalayıp yuttuğun exception için interceptor hiçbir şey görmez ve commit eder. Ama bir iç transactional metodun proxy'sinden geçerek gelen exception, sen onu yakalamadan önce transaction'ı işaretlemiş olabilir. Bu, q5'in konusudur.

Fiziksel ve mantıksal transaction, rollback-only ve UnexpectedRollbackException

REQUIRED propagation'da iç içe çağrılan her @Transactional metot kendi mantıksal transaction kapsamını açar, ama hepsi aynı fiziksel veritabanı transaction'ını (aynı JDBC bağlantısını) paylaşır. Commit kararını yalnızca en dıştaki kapsam (transaction'ı başlatan) verir.

İç kapsam bir rollback kararı verirse ne olur? Fiziksel transaction'ın sahibi o değildir, rollback edemez. Bunun yerine paylaşılan transaction'a rollback-only işareti koyar. Dış kapsam daha sonra commit etmeye çalıştığında işareti görür, rollback yapar ve çağırana UnexpectedRollbackException fırlatır. Spring dokümantasyonu bunun gerekçesini şöyle verir: çağıran, gerçekte yapılmamış bir commit'in yapıldığını sanarak yanıltılmamalıdır.

diyagram yükleniyor…
sequenceDiagram
    participant C as CheckoutService (dış, REQUIRED)
    participant P as CouponService proxy (iç, REQUIRED)
    participant TM as TransactionManager
    C->>TM: getTransaction -> yeni fiziksel tx
    C->>P: redeem()
    P->>TM: getTransaction -> mevcut tx'e katıl
    P-->>P: CouponExpiredException (RuntimeException)
    P->>TM: rollback kuralı: rollback -> sahibi değil -> setRollbackOnly
    P-->>C: exception yayılır
    C-->>C: catch, log.warn, return id
    C->>TM: commit
    TM-->>TM: rollback-only işaretli -> ROLLBACK
    TM-->>C: UnexpectedRollbackException

Bu davranış AbstractPlatformTransactionManager'ın globalRollbackOnParticipationFailure ayarına (varsayılan true) bağlıdır. false yapılırsa katılımcı bir transaction'daki hata global işaret koymaz; ama bu tüm uygulamayı etkileyen ve iç metodun yarım bıraktığı yazmaların commit olmasına yol açabilen bir ayardır, nadiren doğru çözümdür.

Propagation türleri

PropagationMevcut transaction varsaYoksaTipik kullanım
REQUIRED (varsayılan)KatılırYeni açarNeredeyse her şey
REQUIRES_NEWAskıya alır, yeni fiziksel transaction açarYeni açarHer durumda kalması gereken audit, bağımsız sayaç
NESTEDAynı fiziksel transaction'da savepoint açarYeni açar (REQUIRED gibi)JDBC ile kısmi geri alma
MANDATORYKatılırIllegalTransactionStateException"Beni mutlaka bir transaction içinden çağır" sözleşmesi
SUPPORTSKatılırTransaction'sız çalışırNadiren; okuma yardımcıları
NOT_SUPPORTEDAskıya alır, transaction'sız çalışırTransaction'sız çalışırUzun, transaction gerektirmeyen iş
NEVERIllegalTransactionStateExceptionTransaction'sız çalışırTransaction içinde çağrılmaması gereken kod

REQUIRES_NEW ve bağlantı havuzu tükenmesi. Dış transaction askıya alındığında bağlantısını bırakmaz; iç transaction havuzdan ikinci bir bağlantı ister. Spring dokümantasyonu bunu açıkça uyarır: birkaç thread'in aktif dış transaction'ı varken hepsi iç transaction için yeni bağlantı beklerse havuz bunları veremez ve deadlock oluşabilir; havuz, eşzamanlı thread sayısını en az 1 aşacak şekilde boyutlandırılmadıkça REQUIRES_NEW kullanılmamalıdır.

Somut senaryo: Hikari maximum-pool-size=10, aynı anda 10 sipariş isteği geliyor. Her biri dış transaction için bir bağlantı alır (10/10). Hepsi audit için REQUIRES_NEW çağırır ve ikinci bağlantıyı bekler. Havuzda boş yok, kimse elindekini bırakmıyor. Hepsi connection-timeout (demo projede 5 saniye) dolana kadar bekler ve SQLTransientConnectionException alır. Düşük trafikte hiç görünmeyen bu hata, kampanya başladığında tüm servisi kilitler. Önlemler:

  • Havuzu eşzamanlı thread × (iç içe derinlik + 1) mantığıyla boyutlandırmak (ama havuzu büyütmek veritabanının bağlantı limitini zorlar).
  • İç transaction'ı çok kısa tutmak.
  • Audit'i transaction'ın dışına taşımak: @TransactionalEventListener(phase = AFTER_COMPLETION) ile commit/rollback sonrasına veya Kafka üzerinden asenkron bir akışa.

Demo projedeki düzeltilmiş akış:

java
@Service
public class AuditService {
    private final AuditLogRepository auditRepository;
    private final Clock clock;

    public AuditService(AuditLogRepository auditRepository, Clock clock) {
        this.auditRepository = auditRepository;
        this.clock = clock;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)   // ayrı bean, public metot: proxy'den geçer
    public void recordAttempt(UUID orderId) {
        auditRepository.save(AuditLog.attempted(orderId, clock.instant()));
    }
}

@Service
public class FixedOrderService {
    private final DemoOrderRepository orderRepository;
    private final AuditService auditService;

    public FixedOrderService(DemoOrderRepository orderRepository, AuditService auditService) {
        this.orderRepository = orderRepository;
        this.auditService = auditService;
    }

    @Transactional
    public void placeOrder(DemoOrder order) {
        auditService.recordAttempt(order.getId());   // hemen commit olur; sipariş rollback olsa da kalır
        if (order.total().signum() <= 0) {
            throw new InvalidOrderException("total must be positive");   // unchecked: rollback
        }
        orderRepository.save(order);
    }
}

NESTED ve JPA. NESTED, JDBC savepoint'leriyle çalışır ve Spring dokümantasyonu bunu DataSourceTransactionManager ile ilişkilendirir. JpaTransactionManager savepoint'i teknik olarak destekler ama nestedTransactionAllowed bayrağı varsayılan olarak false'tur; javadoc'a göre sebep, savepoint'in yalnızca JDBC bağlantısına uygulanması, EntityManager'a ve önbellekteki entity nesnelerine uygulanmamasıdır: "JPA'nın kendisi nested transaction desteklemez." Yani JPA ile NESTED kullanmaya çalışmak varsayılan ayarda NestedTransactionNotSupportedException verir; bayrağı açsan bile savepoint'e dönüldüğünde persistence context'teki entity'ler eski hallerine dönmez ve sonraki flush geri alındığını sandığın değişiklikleri tekrar yazabilir.

Katılan transaction ayarları yok sayar. REQUIRED ile mevcut bir transaction'a katılan metodun isolation, timeout ve readOnly ayarları sessizce yok sayılır; dış transaction'ın özellikleri geçerlidir. Spring dokümantasyonu, bu uyumsuzlukları reddetmek için transaction manager'da validateExistingTransaction=true ayarını önerir. Örnek tuzak: @Transactional(readOnly = true) bir sorgu servisi, yazma yapan bir dış transaction'dan çağrıldığında read-only olmaz; tersine, read-only bir dış transaction içinden çağrılan yazma metodu read-only bağlamda çalışır.

Isolation ve readOnly

isolation varsayılanı Isolation.DEFAULT'tur, yani veritabanının varsayılanı. PostgreSQL için bu READ COMMITTED'dır. REPEATABLE READ veya SERIALIZABLE istenebilir:

java
@Transactional(isolation = Isolation.REPEATABLE_READ)
public SettlementReport settleDay(LocalDate day) { ... }

Daha yüksek isolation ücretsiz değildir: PostgreSQL'de REPEATABLE READ ve SERIALIZABLE'da eşzamanlı güncelleme çatışmaları could not serialize access (SQLState 40001) hatasıyla transaction'ı iptal eder; uygulamanın bu hatada tüm transaction'ı yeniden denemesi gerekir. Isolation ayarı sadece transaction'ı başlatan (REQUIRED ile yeni açan veya REQUIRES_NEW) metotta etkilidir. Isolation seviyelerinin anomali bazında karşılaştırması ayrıntılı ele alınır (bkz. Bölüm 10).

readOnly = true ne yapar?

  • Hibernate seviyesinde: Spring'in HibernateJpaDialect'i session'ı read-only yapar ve flush modunu MANUAL'a çeker. Sonuç: yüklenen entity'ler için dirty checking snapshot'ı tutulmaz (bellek ve CPU tasarrufu), commit'te flush yapılmaz. Yanlışlıkla bir entity'yi değiştirsen bile UPDATE gitmez.
  • JDBC seviyesinde: Kullanılan sürüme ve sürücüye göre bağlantıya read-only ipucu iletilebilir; bazı veritabanları bunu optimizasyon için kullanır.
  • Routing: Read-only transaction'ları okuma replikasına yönlendirmek için AbstractRoutingDataSource + LazyConnectionDataSourceProxy ile bu bayrak kullanılabilir.

readOnly bir güvenlik garantisi değildir: native bir UPDATE sorgusu veya @Modifying repository metodu çalışabilir. Spring Data JPA'nın SimpleJpaRepository'si sınıf seviyesinde @Transactional(readOnly = true) taşır, yazma metotlarını (save, delete) ayrıca @Transactional ile işaretler. Kendi servislerinde de aynı deseni kullanabilirsin: sınıf seviyesinde readOnly = true, yazan metotlarda override.

@Transactional ne zaman çalışmaz?

Hepsi aynı mekanizmadan, proxy'den gelir:

  1. Self-invocation: Aynı sınıftan this.metot() çağrısı proxy'yi atlar.
  2. private metot: CGLIB override edemez; Spring 6.0'dan beri class-based proxy'lerde protected ve package-private metotlar çalışır, private hâlâ çalışmaz.
  3. final metot / sınıf: Override edilemez.
  4. @PostConstruct: Proxy henüz oluşmamıştır; Spring dokümantasyonu başlatma kodunda transactional davranışa güvenilmemesini açıkça söyler.
  5. Bean olmayan nesne: new OrderService(...) ile oluşturulan nesnenin proxy'si yoktur.
  6. Başka thread: Transaction ThreadLocal'a bağlıdır.
  7. Checked exception: Transaction çalışır ama varsayılan kural commit eder.
  8. Yutulan exception: Metot içinde yakalanıp yutulan exception için interceptor commit eder.

Demo projedeki BrokenOrderService bunlardan üçünü birden içerir ve entegrasyon testiyle kanıtlanmıştır:

java
@Service
public class BrokenOrderService {

    public void placeOrder(DemoOrder order) throws InvalidOrderCheckedException {
        saveOrder(order);                               // (1) self-invocation: transaction açılmaz
    }

    @Transactional
    public void saveOrder(DemoOrder order) throws InvalidOrderCheckedException {
        orderRepository.save(order);                    // SimpleJpaRepository.save kendi transaction'ında commit olur
        writeAudit(order);                              // yine self-invocation
        if (order.total().signum() <= 0) {
            throw new InvalidOrderCheckedException(""); // (3) checked: transaction olsaydı bile commit ederdi
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    private void writeAudit(DemoOrder order) { ... }    // (2) private: anotasyon yok sayılır
}

TransactionalPitfallsIntegrationTest, total = 0 ile çağrıldığında exception fırladığını ama hem geçersiz siparişin hem audit kaydının DB'de kaldığını doğrular. Her save kendi mini transaction'ında commit olmuştur. Bu, mock'lu bir unit testin asla yakalayamayacağı bir hatadır.

Transaction içinde HTTP veya Kafka çağrısı

java
@Transactional
public UUID placeOrder(PlaceOrderCommand cmd) {
    stockRepository.decrease(cmd.productId(), cmd.quantity());
    Order order = orderRepository.save(Order.create(cmd));
    PaymentResult result = paymentClient.charge(order.getId(), order.getTotal());   // 2-3 saniye HTTP
    kafkaTemplate.send("order-created", order.getId().toString(), toJson(order));
    return order.getId();
}

Bu kodun dört ayrı zararı var:

  1. Bağlantı ve kilit tutma süresi uzar. Transaction açık olduğu sürece JDBC bağlantısı havuzdan alınmış ve UPDATE stock satır kilidi tutulmuş durumdadır. Ödeme servisi 3 saniye cevap verirse, o ürünü almaya çalışan diğer tüm istekler 3 saniye kilitte bekler; havuzdaki 10 bağlantının hepsi yavaş HTTP çağrılarına kilitlenir ve ödeme servisiyle hiç ilgisi olmayan istekler de bağlantı bulamaz. Flash sale'de bu tam bir kesintidir.
  2. Rollback dış dünyayı geri almaz. HTTP çağrısı başarılı olduktan sonra commit başarısız olursa (constraint ihlali, bağlantı kopması), müşteriden para çekilmiş ama sipariş yoktur.
  3. Dual write. Kafka mesajı gönderildi, sonra DB commit başarısız oldu: var olmayan bir sipariş için event yayınlandı (phantom event). Ya da DB commit oldu, Kafka gönderimi başarısız oldu veya pod öldü: sipariş var ama kimse duymadı (lost event). İki ayrı sisteme atomik yazmanın yolu yoktur. Ayrıca kafkaTemplate.send() asenkrondur; sonucu beklenmezse hata görülmez bile.
  4. Timeout'lar zincirlenir. HTTP client timeout'u, transaction timeout'u ve Hikari timeout'u birbirinden habersiz çalışır.

Doğru yapı: transaction'ı yalnızca veritabanı işine daralt. Dış çağrıları ya transaction'dan önce (doğrulama, fiyat sorgusu), ya commit'ten sonra (@TransactionalEventListener(AFTER_COMMIT)) ya da garantili teslimat gerekiyorsa Transactional Outbox ile yap: event, iş verisiyle aynı transaction'da bir outbox_event tablosuna yazılır, ayrı bir relay onu Kafka'ya taşır (outbox'ın ayrıntıları için bkz. Bölüm 13). Ödeme gibi kendisi bir transaction olan dış işlemler ise saga ile (rezervasyon → ödeme → onay veya telafi) tasarlanır.

@Transactional nerede durmalı?

  • Service katmanında, use-case'in giriş metodunda. "Sipariş ver" bir iş birimidir; stok düşümü, sipariş kaydı ve outbox kaydı ya birlikte olur ya hiç olmaz. Transaction sınırı bu iş biriminin sınırıdır.
  • Controller'da değil. Controller HTTP ile ilgilenir; transaction'ı oraya koymak serileştirme, validation ve HTTP detaylarını transaction süresine dahil eder ve aynı use-case'i başka bir giriş noktasından (Kafka listener, scheduler) çağırmayı zorlaştırır.
  • Yalnızca repository'ye güvenmek yetmez. Spring Data repository metotları kendi transaction'larını açar; servis transactional değilse her save ayrı commit olur ve iki save arasında hata olursa yarım veri kalır (BrokenOrderService'teki durum).
  • İç adımlar sözleşme koyabilir. Demo projedeki StockReservationService.reserve, Propagation.MANDATORY kullanır: stok düşümü tek başına commit olursa ve sipariş kaydı başarısız olursa stok boşa gider, bu yüzden metot transaction dışından çağrılırsa hemen IllegalTransactionStateException fırlatır. Bu, "bu metodu doğru kullan" kuralını yoruma değil koda yazar.
  • Kısa tut. Transaction içinde yalnızca veritabanı işi; hesaplama, dış çağrı ve serileştirme mümkünse dışarıda.

JPA: persistence context ve entity durumları

Persistence context, bir EntityManager'ın yönettiği entity örneklerinin kümesidir; Hibernate'te bu Session'dır. Spring'de varsayılan olarak transaction-scoped'tur: transaction başlarken açılır, bitince kapanır. Repository'lerin ve servislerin enjekte ettiği EntityManager aslında paylaşılan bir proxy'dir; her çağrıda o anki thread'in transaction'ına bağlı gerçek EntityManager'a yönlenir.

Persistence context iki şey garanti eder:

  1. Birinci seviye cache ve kimlik garantisi. Aynı transaction içinde findById(42) iki kez çağrılırsa ikinci çağrı SQL atmaz ve aynı Java nesnesini döner. Bir id için tek bir yönetilen nesne vardır.
  2. Değişiklik takibi (dirty checking). Yönetilen bir entity'nin alanını değiştirmen yeterlidir; save çağırmasan da flush sırasında UPDATE yazılır.

Entity'nin dört durumu:

DurumAnlamıNasıl girilir
Transient (new)new ile oluşturulmuş, persistence context'te değil, DB'de karşılığı yoknew Order(...)
ManagedPersistence context'te; değişiklikleri takip edilirpersist, find, sorgu sonucu, merge dönüşü
DetachedDB'de karşılığı var ama persistence context kapanmış veya detach/clear edilmişTransaction bitti, em.clear()
RemovedSilinmek üzere işaretli; flush'ta DELETEremove

persist transient bir nesneyi managed yapar. merge ise verilen nesnenin kopyasını managed yapar ve kopyayı döner; parametre olarak verdiğin nesne detached kalır. Spring Data'nın save() metodu entity'nin yeni olup olmadığına bakar: yeniyse persist, değilse merge. "Yeni mi?" kararı varsayılan olarak id'ye göredir: id null ise yeni.

Bu son kuralın gizli bir maliyeti var. Id'yi uygulama tarafında ürettiğin (UUID.randomUUID()) entity'lerde id hiçbir zaman null değildir; Spring Data her save'de entity'yi "eski" sanar ve merge çağırır. merge önce SELECT atar, satırı bulamayınca INSERT yapar. Her siparişte fazladan bir round-trip demektir. Demo projedeki AssignedIdEntity, Persistable<UUID> implemente ederek bu kararı kendisi verir:

java
@MappedSuperclass
public abstract class AssignedIdEntity implements Persistable<UUID> {

    @Transient
    private boolean isNew = true;

    @Override
    public boolean isNew() {
        return isNew;
    }

    @PostPersist
    @PostLoad
    void markNotNew() {
        this.isNew = false;
    }
}

Ayrıca save()'in dönüş değerini kullanmak önemlidir: merge yolunda dönen nesne managed olandır, parametre olarak verdiğin değil.

Dirty checking ve flush

Hibernate bir entity'yi persistence context'e aldığında alanlarının bir snapshot'ını saklar. Flush anında her managed entity'nin güncel değerlerini snapshot'la karşılaştırır ve farklı olanlar için UPDATE üretir. Yani dirty checking'in maliyeti, persistence context'teki entity sayısıyla doğru orantılıdır: 50.000 entity yüklenmiş bir transaction'da her flush 50.000 karşılaştırma demektir. Toplu işlerde periyodik flush() + clear() veya read-only sorgular bu yüzden önemlidir.

Flush commit değildir. Flush, bekleyen SQL'leri veritabanına gönderir ama transaction açık kalır; rollback hâlâ mümkündür. Commit, flush'tan sonra transaction'ı kalıcı yapar.

Flush ne zaman olur? JPA flush modları:

  • AUTO (varsayılan): Commit'ten önce ve bekleyen değişikliklerin sonucunu etkileyebilecek bir sorgudan önce. Örneğin aynı transaction'da yeni bir Order persist ettikten sonra select count(o) from Order o çalıştırırsan Hibernate önce INSERT'i gönderir ki sayım doğru olsun.
  • COMMIT: Sadece commit'te. Sorgular bekleyen değişiklikleri görmeyebilir.
  • Hibernate'e özgü MANUAL: Sadece açık flush() çağrısıyla; Spring'in readOnly = true transaction'ları bunu kullanır.

Pratik sonuçlar:

  • Hatalar geç gelir. save() çağrısı çoğu zaman SQL çalıştırmaz (write-behind). Unique constraint ihlali, commit anındaki flush'ta, yani metodun son satırından sonra, proxy'nin içinde patlar. Hatayı metodun içinde yakalamak istiyorsan saveAndFlush() veya entityManager.flush() gerekir.
  • Bulk sorgular persistence context'i atlar. @Modifying @Query("update Stock s set s.quantity = s.quantity - :q ...") doğrudan SQL çalıştırır; persistence context'teki Stock nesnesi eski değerini korur. Demo projedeki StockRepository.decrease bu yüzden @Modifying(flushAutomatically = true, clearAutomatically = true) kullanır: önce bekleyen değişiklikleri yazar, sonra context'i temizler.
  • JDBC batching. hibernate.jdbc.batch_size ile INSERT/UPDATE'ler gruplanır. Ancak GenerationType.IDENTITY ile Hibernate, id'yi öğrenmek için her INSERT'i hemen çalıştırmak zorundadır ve insert batching devre dışı kalır. Yüksek hacimli insert'lerde SEQUENCE + pooled optimizer veya uygulama tarafında üretilen id tercih edilir.

Fetch stratejileri: LAZY ve EAGER

JPA varsayılanları:

İlişkiVarsayılan
@ManyToOne, @OneToOneEAGER
@OneToMany, @ManyToManyLAZY

EAGER, "bu ilişkiyi her zaman yükle" demektir ve sorgu tarafında geri alınamaz: OrderItem → Product → Seller → Address zinciri EAGER ise bir sipariş kalemini okumak dört tabloyu çeker. JPQL sorgularında EAGER ilişkiler genellikle ayrı sorgularla yüklenir ve N+1'in en sinsi kaynağı olur. Kural: tüm ilişkileri LAZY yap (@ManyToOne(fetch = FetchType.LAZY)), ihtiyaç duyulan yerde sorgu bazında fetch et.

LAZY ilişkiye persistence context kapandıktan sonra erişmek LazyInitializationException: could not initialize proxy - no Session verir. Bunun doğru çözümü OSIV'i açmak değil, gereken veriyi transaction içinde açıkça yüklemektir.

N+1 problemi ve çözümleri

java
@Transactional(readOnly = true)
public List<OrderSummary> sellerDashboard(long sellerId) {
    List<Order> orders = orderRepository.findTop50BySellerIdOrderByCreatedAtDesc(sellerId);  // 1 sorgu
    return orders.stream()
            .map(o -> new OrderSummary(o.getId(), o.getItems().size()))   // her sipariş için 1 sorgu
            .toList();
}

1 sorgu sipariş listesini getirir, sonra her siparişin items koleksiyonuna ilk erişimde 1 sorgu daha atılır: toplam 51 sorgu. Her sorgu 2 ms ise sayfa 100 ms'de açılır; 1000 sipariş olunca 2 saniye. Lokal ortamda 5 kayıtla hiç fark edilmez.

Tespit:

  • Geliştirmede SQL log'u (logging.level.org.hibernate.SQL=debug) ve hibernate.generate_statistics=true.
  • Testte sorgu sayısını doğrulamak: datasource-proxy veya benzeri bir kütüphaneyle "bu endpoint en fazla 3 sorgu atmalı" assertion'ı. N+1'i regresyona karşı korumanın tek güvenilir yolu budur.
  • Production'da APM/tracing'de aynı SQL'in bir istekte onlarca kez tekrarlanması.

Çözümler ve ne zaman hangisi:

  1. JOIN FETCH: Tek sorguda parent ve koleksiyon.

    java
    @Query("select o from Order o join fetch o.items where o.sellerId = :sellerId")
    List<Order> findWithItems(@Param("sellerId") long sellerId);
    

    Hibernate 6+'da fetch join sonucunda tekrar eden parent'ları kendisi tekilleştirir; Hibernate 5'teki gibi distinct yazmak gerekmez.

  2. @EntityGraph: Aynı şeyi anotasyonla, derived query'lerde de yapar: @EntityGraph(attributePaths = "items").

  3. Batch fetching: hibernate.default_batch_fetch_size=50 (veya ilişki üzerinde @BatchSize). Lazy koleksiyonlar tek tek değil, IN (...) ile 50'şerli yüklenir; 51 sorgu 2 sorguya iner. Global, ucuz ve kod değişikliği gerektirmeyen bir güvenlik ağıdır.

  4. DTO projection: Okuma ekranları için en verimli yol. Sadece gereken kolonlar gelir; persistence context, snapshot ve lazy sürprizi yoktur.

    java
    public record OrderSummary(UUID id, Instant createdAt, BigDecimal total, long itemCount) {}
    
    @Query("""
           select new com.example.order.OrderSummary(o.id, o.createdAt, o.total, count(i))
             from Order o left join o.items i
            where o.sellerId = :sellerId
            group by o.id, o.createdAt, o.total
           """)
    List<OrderSummary> summaries(@Param("sellerId") long sellerId, Pageable pageable);
    
  5. İki adımlı sorgu: Sayfalama + koleksiyon gerektiğinde doğru çözüm (q8'de ayrıntılı).

Fetch join'in iki sınırı vardır ve ikisi de q8'in konusudur:

  • İki List (bag) aynı anda fetch edilemez: MultipleBagFetchException. Set'e çevirmek hatayı susturur ama SQL'de kartezyen çarpım üretir (10 kalem × 5 ödeme = sipariş başına 50 satır).
  • Koleksiyon fetch + sayfalama: JOIN sonucu satır sayısı parent sayısına eşit olmadığı için Hibernate LIMIT/OFFSET'i SQL'e koyamaz; tüm sonucu belleğe çekip Java'da sayfalar ve uyarı log'lar (Hibernate 6'da HHH90003004, Hibernate 5'te HHH000104).

Open Session in View (OSIV)

Spring Boot, web uygulamalarında varsayılan olarak OpenEntityManagerInViewInterceptor'ı kaydeder: EntityManager HTTP isteğinin başında açılır, sonunda kapanır. Böylece controller'da veya JSON serileştirme sırasında lazy ilişkilere erişmek LazyInitializationException vermez. Boot bu yüzden açılışta spring.jpa.open-in-view is enabled by default uyarısını yazar.

Neden anti-pattern sayılır?

  • N+1'i gizler. Jackson bir entity'yi serileştirirken her lazy ilişkiye dokunur ve view katmanında, transaction dışında, fark edilmeden onlarca sorgu atılır.
  • Bağlantıyı uzun tutar. İlk lazy erişimde alınan bağlantı, isteğin sonuna kadar (yavaş bir istemciye yanıt yazılırken bile) tutulabilir; havuz yük altında erken tükenir.
  • Transaction dışı okuma. Bu sorgular auto-commit modunda, her biri ayrı çalışır; tutarlı bir snapshot görmezler.
  • Katman sınırını bozar. Entity'leri doğrudan API'ye döndürmeyi teşvik eder.

Öneri: spring.jpa.open-in-view=false. Kapatınca ortaya çıkan LazyInitializationException'lar aslında gizli N+1'lerin listesidir; her birini servis katmanında açık fetch veya DTO projection ile çöz. Demo projede OSIV bu yüzden kapalıdır.

Optimistic ve pessimistic locking

İki kullanıcı aynı anda aynı satırı güncellemeye çalışıyor: aynı ürünün stoğu, aynı cüzdanın bakiyesi, aynı siparişin durumu. Üç temel yaklaşım vardır.

1) Optimistic locking (@Version). Çatışmanın nadir olduğunu varsayar; kilit tutmaz, yazma anında kontrol eder.

java
@Entity
public class Wallet {
    @Id private Long id;
    private BigDecimal balance;
    @Version private long version;

    public void debit(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) throw new InsufficientBalanceException(id, amount);
        this.balance = balance.subtract(amount);
    }
}

Hibernate güncellemeyi UPDATE wallet SET balance = ?, version = 8 WHERE id = ? AND version = 7 olarak yazar. Arada başka bir transaction satırı güncellediyse versiyon 8 olmuştur, WHERE eşleşmez, 0 satır etkilenir ve Hibernate OptimisticLockException fırlatır; Spring bunu ObjectOptimisticLockingFailureException'a çevirir. Uygulama ya kullanıcıya "veri değişti, yeniden dene" der ya da tüm transaction'ı (okuma dahil) yeniden dener. Retry, transaction'ın dışında olmalıdır; aynı transaction içinde tekrar denemek eski snapshot'la aynı hatayı üretir.

Ne zaman: çatışma nadir, okuma sık, kullanıcı düzenleme ekranları (ürün açıklamasını iki satıcı paneli aynı anda düzenliyor), uzun düşünme süresi olan akışlar (versiyon formda taşınır).

2) Pessimistic locking (SELECT ... FOR UPDATE). Çatışmanın sık olduğunu varsayar; satırı okurken kilitler.

java
public interface WalletRepository extends JpaRepository<Wallet, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)        // PostgreSQL: select ... for update
    @Query("select w from Wallet w where w.id = :id")
    Optional<Wallet> findForUpdate(@Param("id") long id);
}

İkinci transaction aynı satırı FOR UPDATE ile okumak istediğinde ilki commit veya rollback olana kadar bekler. Kilit transaction sonuna kadar tutulur; bu yüzden kilitli transaction'ın içinde dış çağrı yapmak (yukarıdaki HTTP örneği) tüm bekleyenleri durdurur. Riskleri: bekleme süresi (lock timeout belirlenmeli, PostgreSQL'de lock_timeout veya NOWAIT), birden çok satırı farklı sırada kilitleyen transaction'lar arasında deadlock (PostgreSQL birini iptal eder, SQLState 40P01; satırları hep aynı sırada, ör. id'ye göre kilitlemek önler). PESSIMISTIC_READ PostgreSQL'de FOR SHARE'e karşılık gelir.

Ne zaman: çatışma sık ve yeniden deneme pahalı; oku-karar ver-yaz akışı tek bir koşullu UPDATE'e indirgenemiyor (bakiye kontrolü + birden fazla tabloya yazma).

3) Atomik koşullu UPDATE. Oku-kontrol et-yaz döngüsünü tek bir SQL ifadesine indirger. Demo projedeki stok düşümü:

java
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
        update Stock s
           set s.quantity = s.quantity - :quantity
         where s.productId = :productId
           and s.quantity >= :quantity
        """)
int decrease(@Param("productId") long productId, @Param("quantity") int quantity);

PostgreSQL bu ifadeyi çalıştırırken satırı kilitler, koşulu kontrol eder ve yazar. Aynı satırı güncellemek isteyen ikinci transaction ilki bitene kadar bekler, sonra koşulu güncel değerle yeniden değerlendirir (READ COMMITTED'da). Stok yetmiyorsa 0 satır güncellenir ve servis InsufficientStockException fırlatır. Ayrı bir SELECT yoktur, yani "okudum, bu arada başkası değiştirdi" penceresi yoktur. Bir CHECK (quantity >= 0) constraint'i son savunma hattıdır.

Optimistic (@Version)Pessimistic (FOR UPDATE)Atomik koşullu UPDATE
KilitYok (yazmada kontrol)Okumadan commit'e kadarSadece UPDATE süresince
ÇatışmadaException, yeniden deneBekleBekle, sonra koşulu yeniden değerlendir
Yüksek çatışmadaÇoğu istek başarısız olur ve tekrar dener; boşa işSıraya girer; doğru ama throughput sınırlıSıraya girer; en kısa kilit süresi
UygunNadir çatışma, düzenleme ekranlarıKarmaşık oku-karar ver-yazSayaç, stok, bakiye gibi tek satırlı aritmetik

Flash sale gibi binlerce isteğin aynı satırı güncellediği "hot row" senaryosunda optimistic locking kötü seçimdir: 1000 istekten biri kazanır, 999'u tekrar dener, yine biri kazanır. Atomik UPDATE doğrudur ama tüm istekleri o tek satırın kilidinde sıraya sokar; throughput satır kilidinin süresiyle sınırlıdır. O noktada stok Redis'e (Lua script ile atomik düşüm) taşınır ve DB'ye asenkron yansıtılır (bu dağıtık tasarım için bkz. Bölüm 13).

Stok, sipariş ve event: tek transaction

q18'in arkasındaki tasarım, yukarıdaki parçaların birleşimidir. Demo projedeki OrderService.placeOrder:

java
@Transactional
public UUID placeOrder(PlaceOrderCommand command) {
    Instant now = clock.instant();

    // 1) Stok: MANDATORY propagation ile bu transaction'a katılır.
    //    Yetersizse InsufficientStockException (RuntimeException) -> tüm transaction rollback.
    stockReservationService.reserve(command.productId(), command.quantity());

    // 2) Sipariş.
    Order order = orderRepository.save(Order.create(command, now));

    // 3) Event: Kafka'ya değil, AYNI transaction'da outbox tablosuna.
    UUID eventId = UUID.randomUUID();
    OrderCreatedEvent event = new OrderCreatedEvent(eventId, order.getId(), order.getProductId(), order.getQuantity());
    outboxEventRepository.save(OutboxEvent.of(eventId, "Order", order.getId().toString(),
            "OrderCreated", toJson(event), now));

    return order.getId();
}

Atomiklik buradan gelir: üç yazma (stok, sipariş, outbox satırı) aynı fiziksel transaction'dadır. Stok yetersizse exception ilk adımda fırlar; sipariş ve outbox satırları hiç yazılmaz, yazılmış olsalar bile rollback edilirdi. Commit olursa üçü birlikte kalıcı olur. Böylece "sipariş var ama event yok" (lost event) veya "event var ama sipariş yok" (phantom event) durumu oluşamaz. Kafka'ya taşıma işi ayrı bir relay'in sorumluluğudur ve at-least-once teslimat sağlar (bkz. Bölüm 13).

Bu tasarımı ayakta tutan detaylar: giriş metodunda @Transactional; iç adımın MANDATORY ile transaction dışında çağrılamaması; checked JsonProcessingException'ın unchecked'e sarılması (yoksa varsayılan kural commit ederdi); stok kontrolünün atomik UPDATE ile yapılması.

Quiz'deki sorular: çözüm ve nedenleri

q5: Kupon hatasını yuttuk ama sipariş de gitti

Senaryo: CheckoutService.checkout @Transactional. İçinde orderService.create(cmd) ve try/catch içinde couponService.redeem(...) çağrılıyor. CouponService ayrı bir bean, redeem @Transactional (REQUIRED). CouponExpiredException bir RuntimeException. Kupon geçersizse siparişe kuponsuz devam etmek isteniyor.

Ne olur: Catch bloğu çalışır, log yazılır, metot normal döner gibi görünür. Ama commit anında Spring UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only fırlatır. Sipariş de geri alınır ve istemci 500 alır.

Neden:

  1. redeem başka bir bean'de olduğu için çağrı CouponService'in proxy'sinden geçer; iç TransactionInterceptor çalışır.
  2. REQUIRED nedeniyle yeni bir fiziksel transaction açılmaz; mevcut olana katılır. Ayrı bir mantıksal kapsam oluşur.
  3. RuntimeException iç proxy sınırından çıkarken varsayılan rollback kuralı "rollback" der. İç kapsam fiziksel transaction'ın sahibi olmadığı için rollback edemez, setRollbackOnly() ile işaret koyar.
  4. Dış metot exception'ı yakalar ama işaret transaction nesnesinde durmaktadır; catch onu silmez.
  5. Dış proxy commit etmeye çalışır, işareti görür, rollback yapar ve çağıranın yanılmaması için UnexpectedRollbackException fırlatır.

Önemli bir gözlem: sorun, iç metodun transactional olmasından kaynaklanır. redeem @Transactional olmasaydı (sadece dış transaction'a dahil düz bir metot olsaydı) exception hiçbir interceptor'dan geçmez, işaret konmaz ve catch beklendiği gibi çalışırdı.

Düzeltmeler ve bedelleri:

ÇözümArtıEksi
Kuponu önce doğrula; iş kuralı sonucunu exception yerine bir sonuç tipiyle dön (RedeemResult)Akış kontrolü exception'a ve transaction mekaniğine bağlı olmaz; en okunurAPI değişikliği; doğrulama ile kullanma arasındaki yarışı kullanma adımı da kontrol etmeli
redeem üzerinde @Transactional(noRollbackFor = CouponExpiredException.class)Tek satırredeem exception'dan önce bir şey yazdıysa o yarım yazma da commit olur; kural iç metotta olmalı, dıştaki bir noRollbackFor işe yaramaz çünkü exception orada zaten yakalanıyor
REQUIRES_NEWKupon işlemi bağımsız commit/rollback olur, dış transaction etkilenmezİkinci bağlantı (havuz riski); kupon commit olup sipariş rollback olursa kupon boşa harcanır
NESTED (savepoint)Aynı bağlantıda kısmi geri almaJpaTransactionManager varsayılan olarak izin vermez; açılsa bile JPA persistence context'i savepoint'e dönmez. JPA ile pratikte kullanılamaz
Transaction manager'da globalRollbackOnParticipationFailure=falseKod değişmezTüm uygulamayı etkiler; iç metotların yarım işleri commit olur. Önerilmez

Önerilen çözüm:

java
public sealed interface RedeemResult {
    record Applied(BigDecimal discount) implements RedeemResult {}
    record Rejected(String reason) implements RedeemResult {}
}

@Service
public class CouponService {

    private final CouponRepository couponRepository;
    private final Clock clock;

    public CouponService(CouponRepository couponRepository, Clock clock) {
        this.couponRepository = couponRepository;
        this.clock = clock;
    }

    @Transactional
    public RedeemResult redeem(String code, OrderId orderId) {
        Coupon coupon = couponRepository.findByCode(code).orElse(null);
        if (coupon == null) return new RedeemResult.Rejected("NOT_FOUND");
        if (coupon.isExpired(clock.instant())) return new RedeemResult.Rejected("EXPIRED");
        // Atomik kullanım: aynı kuponu iki sipariş aynı anda kullanmasın.
        int updated = couponRepository.markUsed(coupon.getId(), orderId.value());   // ... where used_by is null
        return updated == 1
                ? new RedeemResult.Applied(coupon.discount())
                : new RedeemResult.Rejected("ALREADY_USED");
    }
}

Beklenen iş sonuçları (kupon süresi dolmuş) değer olarak döner; exception gerçekten beklenmeyen durumlar (DB erişilemez) için kalır ve o durumda tüm checkout'un geri alınması zaten doğru davranıştır.

Quiz'deki tabloya düzeltme: Quiz NESTED için "JPA ile her ortamda çalışmaz" diyor. Daha kesin ifade: JpaTransactionManager'ın nestedTransactionAllowed bayrağı varsayılan false'tur, yani Spring Boot + JPA ile NESTED varsayılan olarak hata verir; Spring'in javadoc'u JPA'nın nested transaction desteklemediğini ve savepoint'in entity durumunu geri almadığını açıkça belirtir. Bu yüzden JPA projesinde NESTED bir çözüm seçeneği sayılmamalıdır.

Production karşılığı: Bu hata log'da "kupon kullanılamadı" uyarısının hemen ardından bir 500 olarak görünür; ekip genelde uyarıya bakıp kupon tarafını arar. UnexpectedRollbackException stack trace'i gördüğünde ilk soru şu olmalı: "Hangi iç transactional çağrının exception'ı yakalandı?"

q8: Tek satırlık repository metodunda kaç problem var?

Senaryo:

java
public interface OrderRepository extends JpaRepository<Order, UUID> {

    @EntityGraph(attributePaths = {"items", "payments"})
    Page<Order> findBySellerId(long sellerId, Pageable pageable);
}

items ve payments iki ayrı @OneToMany List. Bazı satıcıların 2 milyon siparişi var; satıcı paneli 20'şerli sayfalarla çağırıyor.

Problemler:

  1. İki bag'in birlikte fetch'i. Index kolonu olmayan List, Hibernate için bir "bag"dir (sırası ve tekilliği garanti olmayan koleksiyon). İki bag'i aynı sorguda fetch etmek MultipleBagFetchException verir, çünkü Hibernate JOIN sonucundaki satırları iki koleksiyona doğru ayrıştıramaz. Koleksiyonları Set yapmak hatayı susturur ama SQL'de kartezyen çarpım oluşur: 10 kalem × 5 ödeme = sipariş başına 50 satır; ağ ve bellek maliyeti katlanır.
  2. Koleksiyon fetch + sayfalama. JOIN sonucu satır sayısı sipariş sayısına eşit olmadığından Hibernate LIMIT 20'yi SQL'e koyamaz. Satıcının 2 milyon siparişini ve tüm kalemlerini belleğe çeker, sayfalamayı Java'da yapar ve HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory uyarısı log'lar. Sonuç: dakikalar süren istekler veya OutOfMemoryError. Uyarı log'u production'da kolayca kaçar.
  3. Page dönüşü. Page, toplam sayfa sayısını hesaplamak için her istekte ek bir SELECT count(*) atar. 2 milyon satırlık bir satıcıda bu sorgu pahalıdır ve çoğu panelde "toplam 100.000 sayfa" bilgisine ihtiyaç yoktur. Slice (bir sonraki sayfa var mı?) veya Window (keyset) yeterlidir.
  4. Derin OFFSET. 5000. sayfa için veritabanı 100.000 satırı okuyup atar. Keyset (seek) pagination, WHERE (created_at, id) < (:lastCreatedAt, :lastId) ile index'ten doğrudan konumlanır.

Doğru yapı: iki adımlı sorgu.

java
public interface OrderRepository extends JpaRepository<Order, UUID> {

    // 1) Sadece id'leri sayfala: LIMIT SQL'de, (seller_id, created_at, id) index'i kullanılır.
    @Query("""
           select o.id from Order o
            where o.sellerId = :sellerId
            order by o.createdAt desc, o.id desc
           """)
    Slice<UUID> findPageIds(@Param("sellerId") long sellerId, Pageable pageable);

    // 2) O id'ler için TEK koleksiyonu fetch et. Sıralamayı burada da belirt.
    @Query("""
           select o from Order o left join fetch o.items
            where o.id in :ids
            order by o.createdAt desc, o.id desc
           """)
    List<Order> findWithItems(@Param("ids") List<UUID> ids);
}
java
@Transactional(readOnly = true)
public Slice<OrderView> sellerOrders(long sellerId, Pageable pageable) {
    Slice<UUID> ids = orderRepository.findPageIds(sellerId, pageable);
    if (ids.isEmpty()) return new SliceImpl<>(List.of(), pageable, false);

    List<Order> orders = orderRepository.findWithItems(ids.getContent());
    // payments: hibernate.default_batch_fetch_size=50 ile 20 siparişin ödemeleri tek IN sorgusuyla gelir.
    List<OrderView> views = orders.stream().map(OrderView::from).toList();
    return new SliceImpl<>(views, pageable, ids.hasNext());
}

Quiz'deki çözüme bir düzeltme: Quiz'in model cevabındaki ikinci sorgu (select distinct o from Order o left join fetch o.items where o.id in :ids) sıralama içermiyor. IN listesi sonuçların sırasını garanti etmez; veritabanı 20 siparişi herhangi bir sırada dönebilir ve sayfa içinde tarih sırası bozulur. İkinci sorguda da order by yazılmalı (yukarıdaki gibi) veya sonuç Java'da ilk sorgunun id sırasına göre yeniden dizilmelidir. Ayrıca Hibernate 6+'da fetch join'deki parent tekrarları otomatik tekilleştirildiği için distinct gereksizdir.

Güvenlik ağları:

  • spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true: uyarı yerine exception fırlatır; hatayı sessizce production'a çıkmaktan kurtarır.
  • hibernate.default_batch_fetch_size: tüm lazy koleksiyonlar için ucuz N+1 koruması.
  • Sorgu sayısı assertion'lı testler.

Salt okunur panel için en iyi seçenek çoğu zaman DTO projection'dır: kalemleri ayrı bir select ... where order_id in (...) ile DTO olarak çekmek, entity yönetimi maliyeti olmadan aynı sonucu verir. Derin sayfalama için Spring Data 3.1+'ın keyset scrolling API'si (Window<T> + ScrollPosition) kullanılabilir.

Ana kavram: ORM'in ürettiği SQL'i görmeden performans hakkında konuşulamaz. "Tek satırlık metot" dört ayrı ölçek problemi içeriyor ve hiçbiri küçük veriyle yapılan testte görünmüyor.

q18: Stok yetersizken ne sipariş ne event yazılmalı (transaction kısmı)

Senaryo: OrderService.placeOrder sırasıyla stockReservationService.reserve(...), orderRepository.save(...), outboxEventRepository.save(...) çağırıyor. reserve void döner, stok yetmezse InsufficientStockException fırlatır. Soru iki parçalı: (1) Mockito ile unit test, (2) hangi davranış Testcontainers ile entegrasyon testi olmalı?

Unit test kısmı test bölümünün konusudur (bkz. Bölüm 16); kısaca: reserve void olduğu için doThrow(...).when(stockReservationService).reserve(42L, 2), assertThatThrownBy ile exception doğrulaması ve verifyNoInteractions(orderRepository, outboxEventRepository). Bu test sıralamayı doğrular: stok başarısız olursa sonraki adımlar çağrılmaz.

Transaction kısmının özü: Unit test, "ne sipariş ne event yazılmalı" gereksiniminin yalnızca bir yarısını kanıtlar. Gerçek garanti, üç yazmanın aynı transaction'da olmasından gelir; bu da mock'ların hiç çalıştırmadığı şeylere dayanır:

  • @Transactional proxy'sinin gerçekten devrede olması (self-invocation, private metot gibi hatalar unit testte görünmez),
  • InsufficientStockException'ın RuntimeException olması ve varsayılan kuralın rollback yapması,
  • reserve'ün MANDATORY ile aynı transaction'a katılması,
  • PostgreSQL'in satır kilidi ve WHERE quantity >= :qty koşulunun eşzamanlı isteklerde doğru çalışması,
  • outbox satırının siparişle birlikte commit/rollback olması.

Bu yüzden entegrasyon testinde iki davranış doğrulanır:

  1. Atomiklik: Bir adım başarısız olursa (ör. outbox kaydında constraint ihlali veya stok yetersizliği) orders ve outbox_event tablolarında satır kalmaz, stok değişmez.
  2. Eşzamanlılık: 10 stoklu bir ürüne 50 eşzamanlı istekte tam 10 sipariş ve 10 outbox satırı oluşur, stok 0 olur, eksiye düşmez.
java
class OrderServiceIntegrationTest extends AbstractIntegrationTest {

    @Autowired OrderService orderService;
    @Autowired StockRepository stockRepository;
    @Autowired OrderRepository orderRepository;
    @Autowired OutboxEventRepository outboxEventRepository;

    @Test
    void neitherOrderNorEventIsWrittenWhenStockIsInsufficient() {
        long productId = givenStock(1);

        assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderCommand(productId, 2)))
                .isInstanceOf(InsufficientStockException.class);

        assertThat(orderRepository.count()).isZero();
        assertThat(outboxEventRepository.count()).isZero();
        assertThat(stockRepository.findById(productId)).get()
                .extracting(Stock::getQuantity).isEqualTo(1);
    }
}

H2 yerine gerçek PostgreSQL kullanılmasının nedeni, kilit ve isolation davranışının ve PostgreSQL'e özgü SQL'in (FOR UPDATE SKIP LOCKED, ON CONFLICT) H2'de farklı çalışmasıdır. Outbox relay'in ayrıntıları için bkz. Bölüm 13.

Üç seviyede cevap

Soru: "@Transactional nasıl çalışır? Hangi durumlarda çalışmaz?"

@Transactional koyduğum metotta işlem başlar, metot bitince commit olur, hata olursa rollback olur. Private metotlarda ve aynı sınıftan çağırınca çalışmadığını biliyorum.

Spring bean'in proxy'sini oluşturur; proxy'deki TransactionInterceptor metot öncesinde PlatformTransactionManager ile transaction açar, sonra commit veya rollback eder. Varsayılan olarak RuntimeException ve Error'da rollback, checked exception'da commit yapar; rollbackFor ile değiştirilir. Çağrı proxy'den geçmezse çalışmaz: self-invocation, private metot, @PostConstruct. Başka thread'de de transaction taşınmaz, çünkü thread-local'a bağlı.

Zincir: proxy → TransactionInterceptor → PlatformTransactionManager.getTransaction → bağlantı ve EntityManager TransactionSynchronizationManager üzerinden thread'e bağlanır → metot → commit veya rollback kuralı. Bu zincirin her halkası ayrı bir hata sınıfı üretir. Proxy halkası: self-invocation, private/final, @PostConstruct, new ile oluşturulan nesne. Thread halkası: @Async, CompletableFuture, paralel stream transaction görmez. Kural halkası: checked exception commit eder, yutulan exception commit eder, ama iç transactional metottan geçmiş bir exception yakalansa bile rollback-only işareti kalır ve commit'te UnexpectedRollbackException gelir. Propagation halkası: katılan transaction kendi isolation ve readOnly ayarını sessizce kaybeder. Ben bunlara karşı şunları yapıyorum: transaction'ı use-case'in giriş metoduna koyuyorum; iç adımlar MANDATORY gibi sözleşmelerle korunuyor; checked exception'ları sarıyorum veya Spring 6.2'nin rollbackOn = ALL_EXCEPTIONS ayarını kullanıyorum; iş kuralı sonuçlarını exception yerine sonuç tipiyle dönüyorum. En önemlisi, bunları mock'la değil, Testcontainers'lı entegrasyon testinde "exception sonrası satır sayısı" ile doğruluyorum; bir projede self-invocation yüzünden geçersiz siparişlerin DB'de kaldığını ancak böyle bir testle gördük.

Junior iki tuzağı ezberlemiş ama rollback kuralını "hata olursa rollback" diye genelliyor; checked exception'da commit olduğunu bilmiyor. Mid mekanizmayı ve ana tuzakları biliyor. Senior zinciri halkalara ayırıp her halkanın hangi hata sınıfını ürettiğini gösteriyor, rollback-only gibi ince bir davranışı biliyor, önlemleri tasarım seviyesinde anlatıyor ve doğrulama stratejisi veriyor.

Takip sorusu: "Bir Kafka listener'ından bu servisi çağırıyorsun ve listener'ın kendisi de @Transactional. Kafka offset commit'i ile DB commit'i arasındaki ilişki nedir?"

Soru: "REQUIRED ile REQUIRES_NEW farkı nedir? REQUIRES_NEW'i ne zaman kullanırsın?"

REQUIRED varsa mevcut transaction'ı kullanır, yoksa yeni açar. REQUIRES_NEW her zaman yeni transaction açar. Loglama gibi şeyler için REQUIRES_NEW kullanırım ki ana işlem hata alsa da log kalsın.

REQUIRED mevcut fiziksel transaction'a katılır; iç metot hata verirse tüm transaction rollback-only olur. REQUIRES_NEW dıştakini askıya alıp ayrı bir fiziksel transaction açar, bağımsız commit veya rollback olur. Audit kaydı için kullanırım. Dikkat: REQUIRES_NEW ikinci bir bağlantı ister, yük altında havuz tükenebilir. Ayrıca iç metodun ayrı bir bean'de olması gerekir, yoksa self-invocation yüzünden yeni transaction açılmaz.

REQUIRED'da tüm iç kapsamlar tek fiziksel transaction'ı paylaşır ve commit kararı en dıştadır; REQUIRES_NEW ayrı bağlantıda ayrı bir transaction açar, iç transaction'ın kilitleri de bitince hemen bırakılır. REQUIRES_NEW'in iki bedeli var. Birincisi kaynak: dış transaction bağlantısını bırakmadan ikinciyi ister; havuz 10, eşzamanlı istek 10 olduğunda herkes ikinci bağlantıyı bekler ve servis connection-timeout'larla kilitlenir. Spring dokümantasyonu da havuzun eşzamanlı thread sayısını en az bir aşmasını şart koşar. İkincisi tutarlılık: iç transaction commit olup dış rollback olursa iki kayıt birbirinden kopar; audit için istenen budur, ama kupon kullanımı veya stok için bir tutarsızlıktır. Ayrıca iki bağlantı aynı satıra dokunursa dış transaction'ın tuttuğu kilidi iç transaction bekler ve uygulama kendi kendini kilitler. Bu yüzden REQUIRES_NEW'i sadece "dış iş ne olursa olsun bu kayıt kalmalı" semantiği için kullanırım ve çoğu zaman bunu bile transaction dışına taşırım: AFTER_COMPLETION fazındaki bir transactional event listener (ki orada yazmak zaten REQUIRES_NEW ister) veya Kafka'ya giden bir audit akışı. Kullandığım yerde Hikari'nin pending ve active bağlantı metriklerini alarm'a bağlarım; havuz tükenmesi önce bu metriklerde görünür.

Junior tanımı ve doğru bir kullanım örneğini biliyor ama bedelini bilmiyor. Mid havuz riskini ve self-invocation şartını ekliyor. Senior iki ayrı bedel kategorisi (kaynak ve tutarlılık) çıkarıyor, iki bağlantının birbirini kilitlemesi gibi daha ince bir failure mode'u biliyor, alternatif tasarım öneriyor ve gözlemlenebilirlikle bitiriyor.

Takip sorusu: "Havuz tükenmesini production'da nasıl teşhis edersin? Hangi log ve metriklere bakarsın?"

Soru: "N+1 problemini nasıl tespit eder ve çözersin?"

N+1, bir listeyi çekip sonra her eleman için ayrı sorgu atılmasıdır. EAGER yaparak çözerim, böylece ilişkiler hep birlikte gelir.

Liste sorgusundan sonra her elemanın lazy ilişkisine erişince ek sorgu atılıyor. SQL log'larını açarak tespit ederim. Çözüm olarak JOIN FETCH veya @EntityGraph kullanırım, ya da default_batch_fetch_size ile lazy yüklemeleri gruplarım. Okuma ekranlarında DTO projection daha verimli. EAGER kullanmam çünkü her sorguda gereksiz veri getirir.

Önce tespit ve koruma: geliştirmede SQL log'u ve Hibernate istatistikleri, CI'da kritik endpoint'ler için sorgu sayısı assertion'ı; N+1 ancak böyle regresyona karşı korunur. Production'da tracing'de aynı SQL'in bir span altında onlarca kez tekrarlanmasına bakarım. Çözüm kullanım şekline göre değişir. Tek bir koleksiyon, sayfalamasız: JOIN FETCH veya @EntityGraph. Sayfalama varsa fetch join kullanmam, çünkü Hibernate LIMIT'i SQL'e koyamaz ve sayfalamayı bellekte yapar; önce id'leri sayfalar, sonra o id'ler için tek koleksiyonu fetch ederim, sıralamayı ikinci sorguda da koruyarak. İki koleksiyon varsa ikisini birden fetch etmem; bag'lerde MultipleBagFetchException, set'lerde kartezyen çarpım çıkar. İkincisini batch fetch ile yüklerim. Okuma ekranlarında DTO projection ilk tercihim. Global olarak tüm ilişkileri LAZY, default_batch_fetch_size açık, fail_on_pagination_over_collection_fetch açık ve OSIV kapalı tutarım; OSIV açıkken N+1'ler JSON serileştirmesinde gizlenir. EAGER bir çözüm değil, sorunu her sorguya yayar ve sorgu bazında geri alınamaz.

Junior'ın cevabı yaygın bir yanlış inanç içeriyor: EAGER N+1'i çözmez, çoğu zaman kötüleştirir. Mid doğru araçları biliyor. Senior tespit, koruma ve çözümü kullanım şekline (sayfalama, koleksiyon sayısı, okuma/yazma) göre ayırıyor; fetch join'in sınırlarını mekanizmasıyla anlatıyor ve global ayarlarla bir savunma hattı kuruyor.

Takip sorusu: "Bir endpoint'in sorgu sayısını testte nasıl doğrularsın? Bu test neden bazen kırılgan olur?"

Soru: "Optimistic ve pessimistic locking arasında nasıl seçim yaparsın? Flash sale stoğu için hangisi?"

Optimistic locking @Version ile yapılır, pessimistic locking satırı kilitler. Stok için pessimistic kullanırım çünkü daha güvenli.

Optimistic'te kilit yok; @Version kolonu UPDATE'te WHERE version = ? koşulu olur, başkası değiştirdiyse OptimisticLockException alırım ve yeniden denerim. Çatışma nadirse iyidir. Pessimistic'te SELECT FOR UPDATE ile satırı kilitlerim, diğerleri bekler; çatışma sıksa uygundur ama deadlock ve bekleme riski var. Flash sale'de çatışma çok fazla olduğu için optimistic'te çoğu istek fail olur; pessimistic veya atomik bir UPDATE daha iyi.

Seçim çatışma olasılığına ve oku-karar ver-yaz akışının şekline bağlı. Çatışma nadir ve akış uzunsa (iki satıcının aynı ürün açıklamasını düzenlemesi) optimistic: kilit yok, çatışmada kullanıcıya veya transaction dışındaki bir retry'a dönerim. Çatışma sık ve karar birden çok tabloya bağlıysa pessimistic: FOR UPDATE, kısa transaction, lock timeout ve satırları her zaman aynı sırada kilitleyerek deadlock'tan kaçınma. Stok ise ikisinin de en iyi seçenek olmadığı bir durum: işlem tek satırlı bir aritmetik, bu yüzden UPDATE stock SET quantity = quantity - :q WHERE product_id = :id AND quantity >= :q ile oku-kontrol et-yaz penceresini tamamen kaldırırım; etkilenen satır 0 ise stok yok. Kilit sadece UPDATE süresince tutulur, CHECK (quantity >= 0) son savunmadır. Optimistic burada felakettir: 1000 istekten biri kazanır, 999'u tekrar dener ve DB'yi boşa yorar. Ama atomik UPDATE de "hot row" sorununu çözmez; tüm istekler o satırın kilidinde sıraya girer ve throughput kilit süresine bağlı kalır. Gerçek bir flash sale'de stoğu Redis'te Lua script ile atomik düşer, siparişi ve outbox'ı DB'ye yazar, sapmaları bir reconciliation job ile kapatırım. Hangi yolu seçersem seçeyim, 50 eşzamanlı istek ve 10 stokla tam 10 sipariş oluştuğunu Testcontainers'ta doğrulayan bir test yazarım; eşzamanlılık hatası ancak eşzamanlı testle görünür.

Junior "daha güvenli" diyerek gerekçesiz bir seçim yapıyor. Mid iki yaklaşımı ve çatışma sıklığı kriterini doğru anlatıyor. Senior üçüncü bir yolu (atomik koşullu UPDATE) neden en uygun olduğunu mekanizmasıyla açıklıyor, onun da sınırını (hot row) biliyor ve ölçek büyüyünce mimariyi nasıl değiştireceğini, doğruluğu nasıl test edeceğini söylüyor.

Takip sorusu: "Redis'te stok düştün, sonra DB'ye sipariş yazarken uygulama çöktü. Stok tutarsızlığını nasıl yakalarsın ve düzeltirsin?"

Sorulmadı ama sorulabilir

Testlerde @Transactional'ın yanılttığı durumlar

Spring TestContext, @Transactional test metotlarını varsayılan olarak test sonunda rollback eder; @DataJpaTest her testi zaten transactional çalıştırır. Bu, veriyi temizlemek için pratiktir ama üç gerçek hatayı gizler:

  1. Flush hiç olmaz. Commit gelmediği için write-behind'daki INSERT/UPDATE'ler çalışmayabilir; unique constraint ihlali testte görünmez. Gerekirse entityManager.flush() çağrılır.
  2. Lazy loading hep çalışır. Test metodu boyunca persistence context açık olduğundan, production'da LazyInitializationException veren kod testte geçer.
  3. Propagation ve commit davranışı test edilemez. REQUIRES_NEW'in bağımsız commit'i, @TransactionalEventListener(AFTER_COMMIT) ve outbox relay'i görmek için gerçek commit gerekir.

Davranışı doğrulayan entegrasyon testlerini transactional olmadan yazıp veriyi @Sql veya tablo temizleme ile sıfırlamak daha güvenilirdir.

Mülakatta tek cümle: "Test seviyesindeki @Transactional rollback, flush'ı ve lazy loading hatalarını gizler; commit davranışını test ederken onu kullanmam."

Entity'lerde equals ve hashCode

Entity'ler HashSet içinde veya persistence context'te karşılaştırılırken equals/hashCode kritik hale gelir. Tüm alanları kullanan (Lombok @Data) bir implementasyon lazy koleksiyonları tetikler ve alan değiştiğinde hash'i değiştirir; Set'e konmuş nesne bir daha bulunamaz. Veritabanının ürettiği id'ye dayanan bir implementasyon ise persist öncesi (id null) ve sonrası farklı davranır. Yaygın güvenli desen: iş anahtarı (business key) varsa onu kullanmak; yoksa id'yi null-safe karşılaştıran equals ve sınıfa göre sabit bir hashCode. Uygulama tarafında üretilen UUID id'ler bu sorunu büyük ölçüde ortadan kaldırır.

Mülakatta tek cümle: "Entity'lerde @Data kullanmam; equals/hashCode'u ya iş anahtarına ya null-safe id'ye ve sabit hashCode'a dayandırırım."

Bulk işlemler ve persistence context

Bir kampanya bitince 2 milyon ürünün fiyatını güncellemek gerekiyor. Entity'leri tek tek yükleyip değiştirmek, persistence context'i milyonlarca nesne ve snapshot ile doldurur; her flush'ta dirty checking maliyeti büyür ve bellek tükenir. Seçenekler: tek bir JPQL/SQL UPDATE (en hızlı; ama persistence context ve ikinci seviye cache'i atlar, @Version artmaz, entity listener'lar çalışmaz); entity tabanlı işlem gerekiyorsa batch'ler halinde flush() + clear() ve hibernate.jdbc.batch_size; okuma ağırlıklı işlerde ScrollableResults veya Stream ile akış ve her batch için ayrı transaction (TransactionTemplate).

Mülakatta tek cümle: "Toplu güncellemede ya tek bir SQL UPDATE atar ya da batch'ler halinde flush/clear yaparım; milyonlarca entity'yi tek persistence context'e yüklemem."

Birden fazla veritabanı ve transaction manager

İki ayrı DataSource (sipariş DB'si ve raporlama DB'si) varsa iki ayrı PlatformTransactionManager olur ve @Transactional("reportingTransactionManager") ile hangisinin kullanılacağı seçilir. Bir metot ikisine birden yazarsa atomiklik yoktur: biri commit olur, diğeri başarısız olabilir. Spring Data'nın ChainedTransactionManager'ı commit'leri sırayla yapar ama gerçek atomiklik vermez ve deprecated edilmiştir. Gerçek iki fazlı commit için JTA/XA gerekir; operasyonel maliyeti yüzünden mikroservislerde genelde tercih edilmez, yerine outbox ve idempotent tüketim kullanılır.

Mülakatta tek cümle: "İki veritabanına tek transaction'da atomik yazmak XA ister; bunun yerine birine yazar, diğerine outbox ile event taşırım."

Hibernate ikinci seviye cache

Birinci seviye cache transaction'a özeldir. İkinci seviye cache (L2) SessionFactory seviyesindedir ve transaction'lar arasında paylaşılır (JCache/Ehcache/Infinispan ile). Neredeyse hiç değişmeyen referans veriler (kategori ağacı, il/ilçe) için faydalıdır. Riskleri: çok pod'lu bir sistemde her pod'un kendi L2 cache'i olur ve başka pod'daki güncelleme görünmez (dağıtık cache veya kısa TTL gerekir); bulk UPDATE'ler cache'i atlar; sık değişen verilerde invalidation maliyeti kazancı geçer. Çoğu mikroserviste L2 yerine uygulama seviyesinde açık bir cache (Caffeine, Redis) tercih edilir, çünkü neyin ne zaman geçersiz kılınacağı daha görünürdür.

Mülakatta tek cümle: "L2 cache'i sadece neredeyse hiç değişmeyen referans veride düşünürüm; çok pod'da tutarlılık ve invalidation yükü yüzünden çoğu zaman açık bir uygulama cache'i tercih ederim."

Transaction timeout ve uzun transaction'lar

@Transactional(timeout = 5) saniye cinsinden bir üst sınır koyar; Spring bunu JDBC statement timeout'una ve transaction süresi kontrolüne yansıtır. Uzun transaction'lar PostgreSQL'de yalnızca kilit ve bağlantı tutmakla kalmaz, eski satır versiyonlarının temizlenmesini (VACUUM) de engeller ve tablo şişmesine yol açar. Veritabanı tarafında idle_in_transaction_session_timeout ve statement_timeout ile güvenlik ağı kurulur; uygulama tarafında transaction süresi metrik olarak izlenir.

Mülakatta tek cümle: "Transaction'ları kısa tutar, hem uygulamada timeout hem veritabanında idle_in_transaction_session_timeout ile üst sınır koyarım; uzun transaction PostgreSQL'de VACUUM'u da engeller."

Sık yapılan hatalar

  • Exception'ı yakalayınca rollback'in engellendiğini sanmak → iç transactional metottan geçen exception transaction'ı rollback-only işaretler, commit'te UnexpectedRollbackException gelir → iş kuralı sonuçlarını sonuç tipiyle dön veya iç metotta noRollbackFor kullan.
  • Checked exception'da rollback beklemek → varsayılan kural commit eder → rollbackFor, unchecked'e sarma veya Spring 6.2+ rollbackOn = ALL_EXCEPTIONS.
  • @Transactional'ı private metoda veya self-invocation ile çağrılan metoda koymak → proxy devreye girmez, her repository çağrısı ayrı commit olur → giriş metodunda @Transactional, gerekirse ayrı bean.
  • REQUIRES_NEW'i havuz boyutunu düşünmeden kullanmak → yük altında bağlantı havuzu tükenir ve servis kilitlenir → havuzu boyutlandır, iç transaction'ı kısa tut veya işi transaction dışına taşı.
  • JPA ile NESTED kullanmak → varsayılan olarak desteklenmez, açılsa bile persistence context geri alınmaz → iş akışını yeniden tasarla.
  • İç metottaki readOnly veya isolation'ın katılınca uygulandığını sanmak → katılan transaction bu ayarları sessizce yok sayar → ayarı transaction'ı başlatan metotta ver, gerekirse validateExistingTransaction=true.
  • Transaction içinde HTTP veya Kafka çağrısı → bağlantı ve kilit uzun tutulur, rollback dış dünyayı geri almaz, dual write → dış çağrıyı transaction dışına al, garantili event için outbox.
  • Tüm ilişkileri EAGER yapmak → her sorguda gereksiz veri ve gizli N+1 → hepsini LAZY yap, sorgu bazında fetch et.
  • Koleksiyon fetch join'i sayfalama ile birlikte kullanmak → Hibernate tüm sonucu belleğe çekip Java'da sayfalar → iki adımlı sorgu; fail_on_pagination_over_collection_fetch=true.
  • İki List koleksiyonu aynı sorguda fetch etmek veya Set'e çevirip susturmak → MultipleBagFetchException veya kartezyen çarpım → tek koleksiyonu fetch et, diğerini batch fetch ile yükle.
  • IN (:ids) sorgusunun sırayı koruduğunu varsaymak → sonuç sırası garanti değildir → order by yaz veya Java'da yeniden sırala.
  • OSIV'i açık bırakmak → N+1 view katmanında gizlenir, bağlantı uzun tutulur → spring.jpa.open-in-view=false.
  • Yüksek çatışmalı sayaçlarda (stok) optimistic locking → isteklerin çoğu başarısız olup tekrar dener → atomik koşullu UPDATE; çok yüksek ölçekte Redis.
  • Optimistic lock retry'ını aynı transaction içinde yapmak → eski snapshot'la aynı hata tekrar eder → retry'ı transaction'ın dışına koy.
  • Uygulama tarafında üretilen id'li entity'lerde save()'in maliyetini görmemek → her save gereksiz bir SELECT atar → Persistable.isNew().
  • @Modifying bulk sorgudan sonra persistence context'teki entity'ye güvenmek → bulk UPDATE context'i atlar, nesne eski kalır → clearAutomatically = true veya yeniden yükle.

Hızlı tekrar

S: @Transactional çağrısında proxy'den veritabanına kadar hangi bileşenler çalışır?

C: Proxy → TransactionInterceptor → PlatformTransactionManager (JPA'da JpaTransactionManager) → bağlantı ve EntityManager TransactionSynchronizationManager ile thread'e bağlanır → metot → commit veya rollback.

S: Spring'in varsayılan rollback kuralı nedir?

C: RuntimeException ve Error'da rollback, checked exception'da commit. rollbackFor/noRollbackFor ile değişir; Spring 6.2+'da rollbackOn = ALL_EXCEPTIONS ile global olarak değiştirilebilir.

S: İç REQUIRED metottan gelen RuntimeException'ı dışarıda yakalarsan neden yine rollback olur?

C: İç proxy fiziksel transaction'ın sahibi olmadığı için rollback yerine rollback-only işareti koyar; catch işareti silmez, dış commit rollback'e döner ve UnexpectedRollbackException fırlar.

S: REQUIRES_NEW'in iki riski nedir?

C: Dış transaction bağlantısını bırakmadan ikinci bağlantı ister (havuz tükenmesi ve deadlock); iç ve dış ayrı commit olduğu için veriler birbirinden kopabilir.

S: JPA ile NESTED propagation neden pratikte kullanılmaz?

C: JpaTransactionManager'da nestedTransactionAllowed varsayılan false; açılsa bile savepoint sadece JDBC bağlantısına uygulanır, persistence context'teki entity'ler geri dönmez.

S: MANDATORY propagation ne işe yarar?

C: Metot ancak açık bir transaction içinden çağrılabilir, yoksa IllegalTransactionStateException. Stok düşümünün siparişten bağımsız commit olmasını engellemek gibi sözleşmeler için.

S: readOnly = true Hibernate'te ne yapar?

C: Session'ı read-only yapar ve flush modunu MANUAL'a çeker: dirty checking snapshot'ı tutulmaz, commit'te flush olmaz. Güvenlik garantisi değildir; native UPDATE yine çalışabilir.

S: Flush ile commit farkı nedir?

C: Flush bekleyen SQL'leri veritabanına gönderir, transaction açık kalır ve rollback mümkündür. Commit transaction'ı kalıcı yapar.

S: GenerationType.IDENTITY neden insert performansını düşürür?

C: Hibernate id'yi öğrenmek için her INSERT'i hemen çalıştırmak zorundadır; JDBC insert batching devre dışı kalır.

S: JPA'da hangi ilişkiler varsayılan EAGER'dır?

C: @ManyToOne ve @OneToOne. Koleksiyonlar (@OneToMany, @ManyToMany) LAZY'dir. Önerilen: hepsini LAZY yapıp sorgu bazında fetch etmek.

S: N+1'in dört çözümü ve sayfalamada hangisinin kullanılmayacağı?

C: JOIN FETCH/@EntityGraph, batch fetching, DTO projection, iki adımlı sorgu. Sayfalamada koleksiyon fetch join kullanılmaz; Hibernate sayfalamayı bellekte yapar.

S: İki List koleksiyonu aynı sorguda fetch edince ne olur?

C: MultipleBagFetchException. Set'e çevirmek hatayı susturur ama kartezyen çarpım üretir.

S: OSIV neden kapatılmalı?

C: N+1'i serileştirme katmanında gizler, bağlantıyı istek boyunca tutar, transaction dışı tutarsız okumalar yapar. spring.jpa.open-in-view=false.

S: @Version ile optimistic locking nasıl çalışır?

C: UPDATE'e WHERE version = :eski eklenir ve versiyon artırılır; 0 satır etkilenirse OptimisticLockException (Spring'de ObjectOptimisticLockingFailureException). Retry transaction dışında yapılır.

S: Flash sale stoğu için neden atomik koşullu UPDATE tercih edilir?

C: UPDATE ... SET quantity = quantity - :q WHERE ... AND quantity >= :q oku-kontrol et-yaz penceresini kaldırır, kilidi en kısa sürede tutar ve 0 satır "stok yok" demektir. Optimistic locking yüksek çatışmada boşa retry üretir.

Kaynaklar