← Kursa Dön
📄 Text · 25 min

Transaction Best Practices & Locking

Giriş — Transaction Bilmek Yetmez, Doğru Kullanmak Lazım

Önceki derste transaction'ın ne olduğunu, @Transactional'ın nasıl çalıştığını, propagation ve isolation level'ları öğrendin. Ama bilmekle yapmak arasında dağlar kadar fark var.

Production'da asıl sorunlar "transaction nedir?" sorusunu geçtikten sonra başlar: İki kullanıcı aynı anda son ürünü almaya çalışırsa ne olur? Transaction'ı nereye koymalıyım? 50.000 satırlık bir rapor hazırlarken transaction açık mı kalmalı? Optimistic locking mi, pessimistic locking mi?

Bu derste transaction'ı doğru kullanmayı, locking stratejilerini, production'da karşılaşacağın sorunları ve çözümlerini öğreneceksin.


1. Transaction Boundary Tasarımı — Nereye Koymalıyım?

Service Layer'da mı, Repository'de mi?

Kısa cevap: Service Layer'da.

// ❌ YANLIŞ: Repository'de transaction
@Repository
public class OrderRepositoryImpl {
    
    @Transactional
    public Order save(Order order) {
        return entityManager.merge(order);
    }
}

// Sorun: Her repository metodu kendi transaction'ında çalışır
// İki repository çağrısı → iki farklı transaction → atomicity yok!
// ✅ DOĞRU: Service Layer'da transaction
@Service
public class OrderService {
    
    @Transactional
    public OrderResponse createOrder(CreateOrderRequest request) {
        Order order = orderRepository.save(buildOrder(request));
        stockService.decreaseStock(request.getProductId(), request.getQuantity());
        paymentService.charge(order.getTotalAmount());
        // Hepsi aynı transaction'da — atomicity garanti
        return OrderResponse.from(order);
    }
}

Neden Service Layer?

  1. Business logic service'te yaşar. Transaction da business logic'in sınırlarını takip etmeli.

  2. Birden fazla repository çağrısı aynı transaction'da olmalı.

  3. Controller'da transaction koyma — controller HTTP endişeleriyle ilgilenmeli, transaction ile değil.

  4. Repository'de transaction koyma — her save/delete kendi transaction'ında olursa atomicity kaybolur.

Katmanlı Mimari ve Transaction

Controller Layer          → Transaction YOK (HTTP concerns)
    │
    ▼
Service Layer             → @Transactional BURADA ✅
    │
    ▼
Repository Layer          → Transaction YOK (Spring Data JPA zaten yönetir)
    │
    ▼
Database

💡 İpucu: Spring Data JPA'nın SimpleJpaRepository sınıfı zaten @Transactional(readOnly = true) ile işaretlidir. save(), delete() gibi yazma metodları da @Transactional override eder. Yani repository'ye ekstra transaction koymana gerek yok — ama service'te birden fazla repository çağrısını aynı transaction'da toplamak için service'e @Transactional koymalısın.

Facade Pattern ile Karmaşık Transaction'lar

Bazen bir servis başka servisleri çağırırken transaction boundary karmaşıklaşır. Facade pattern kullan:

@Service
public class OrderFacade {

    private final OrderService orderService;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final NotificationService notificationService;

    @Transactional(rollbackFor = Exception.class)
    public OrderResult placeOrder(PlaceOrderCommand command) {
        // 1. Stok kontrol ve rezerve et
        inventoryService.reserveStock(command.getItems());
        
        // 2. Sipariş oluştur
        Order order = orderService.createOrder(command);
        
        // 3. Ödeme al
        PaymentResult payment = paymentService.charge(
            order.getId(), command.getPaymentMethod());
        
        // 4. Stok kesinleştir
        inventoryService.confirmReservation(order.getId());
        
        return new OrderResult(order, payment);
    }
    // Hepsi tek transaction — biri fail ederse hepsi rollback
}

// Notification ayrı — commit sonrası
@Component
public class OrderEventHandler {
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPlaced(OrderPlacedEvent event) {
        notificationService.sendConfirmation(event.getOrderId());
    }
}

2. Long-Running Transaction Anti-Pattern

Problem

// ❌ KÖTÜ: Uzun süren işlem transaction içinde
@Transactional
public void generateMonthlyReport(int month, int year) {
    List<Order> orders = orderRepository.findByMonth(month, year);
    // 50.000 sipariş...
    
    for (Order order : orders) {
        ReportLine line = calculateReportLine(order);
        reportRepository.save(line);
    }
    
    // PDF oluştur (30 saniye sürebilir)
    byte[] pdf = pdfGenerator.generate(reportLines);
    
    // S3'e yükle (10 saniye sürebilir)
    s3Client.upload("reports/" + month + ".pdf", pdf);
    
    // Email gönder (5 saniye sürebilir)
    emailService.sendReport(pdf);
    
    // Bu transaction 45+ saniye açık kaldı!
    // Database connection o süre boyunca meşgul!
}

Bu neden kötü?

  • Connection pool tükenir: Her transaction bir DB connection tutar. 45 saniye boyunca tutarsan, diğer istekler connection alamaz.

  • Lock süresi uzar: Transaction boyunca tutulan lock'lar başka transaction'ları bloklar.

  • Rollback maliyeti artar: 45 saniyelik işlem rollback olursa, 45 saniyelik işi çöpe atarsın.

  • Deadlock riski artar: Uzun transaction'lar deadlock olasılığını artırır.

Çözüm: Transaction'ı Parçala

// ✅ İYİ: Transaction'ları küçük parçalara böl
@Service
public class ReportService {

    private final ReportDataService reportDataService;
    
    // Ana metod transaction'sız — orkestrasyon yapar
    public void generateMonthlyReport(int month, int year) {
        // 1. Veri topla (kısa transaction)
        List<ReportLine> lines = reportDataService.collectReportData(month, year);
        
        // 2. PDF oluştur (transaction dışında — DB gerektirmez)
        byte[] pdf = pdfGenerator.generate(lines);
        
        // 3. S3'e yükle (transaction dışında)
        String s3Key = s3Client.upload("reports/" + month + ".pdf", pdf);
        
        // 4. Rapor kaydını güncelle (kısa transaction)
        reportDataService.markReportComplete(month, year, s3Key);
        
        // 5. Email gönder (transaction dışında)
        emailService.sendReport(pdf);
    }
}

@Service
public class ReportDataService {

    @Transactional(readOnly = true)
    public List<ReportLine> collectReportData(int month, int year) {
        // Kısa transaction — sadece veri okuma
        return orderRepository.findByMonth(month, year).stream()
            .map(this::calculateReportLine)
            .collect(Collectors.toList());
    }

    @Transactional
    public void markReportComplete(int month, int year, String s3Key) {
        // Kısa transaction — sadece bir update
        Report report = reportRepository.findByMonthAndYear(month, year);
        report.setStatus(ReportStatus.COMPLETED);
        report.setS3Key(s3Key);
        reportRepository.save(report);
    }
}

Batch İşlemleri için Chunked Transaction

@Service
public class DataMigrationService {

    private final TransactionTemplate transactionTemplate;
    
    // ❌ KÖTÜ: 1 milyon kayıt tek transaction'da
    @Transactional
    public void migrateAllBad() {
        List<OldRecord> records = oldRepository.findAll(); // 1M kayıt!
        records.forEach(r -> newRepository.save(convert(r)));
        // OutOfMemoryError veya çok uzun lock süresi
    }
    
    // ✅ İYİ: 1000'er kayıt, her chunk ayrı transaction
    public void migrateAllGood() {
        int page = 0;
        int pageSize = 1000;
        boolean hasMore = true;
        
        while (hasMore) {
            final int currentPage = page;
            hasMore = transactionTemplate.execute(status -> {
                Page<OldRecord> records = oldRepository.findAll(
                    PageRequest.of(currentPage, pageSize));
                
                records.forEach(r -> newRepository.save(convert(r)));
                
                return records.hasNext();
            });
            
            page++;
            log.info("Migrated page {} ({} records)", page, page * pageSize);
        }
    }
}

💡 İpucu: Transaction süresini kısaltmak için altın kural: Transaction içinde sadece veritabanı işlemleri yap. Dosya okuma, HTTP çağrısı, PDF üretimi, email gönderimi gibi I/O işlemleri transaction dışında olmalı.


3. Optimistic Locking — "Kontrol Et, Sonra Yaz"

Problem: Race Condition

İki kullanıcı aynı anda bir ürünün stokunu güncellemeye çalışıyor:

Zaman   Kullanıcı A                    Kullanıcı B
─────   ──────────                     ──────────
T1      SELECT stock → 10
T2                                     SELECT stock → 10
T3      stock = 10 - 1 = 9
T4      UPDATE stock = 9               
T5                                     stock = 10 - 1 = 9
T6                                     UPDATE stock = 9
                                       
Sonuç: stock = 9 (ama 2 ürün satıldı — 8 olmalıydı!)

Bu lost update problemidir. İki satış yapıldı ama sadece biri stoktan düştü.

@Version ile Optimistic Locking

Optimistic locking, her entity'ye bir versiyon numarası ekler. Güncelleme yaparken "okuduğum versiyon hâlâ aynı mı?" kontrolü yapar.

@Entity
@Table(name = "products")
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private Integer stock;
    private BigDecimal price;
    
    @Version // Optimistic locking alanı
    private Long version;
    
    // getters, setters
}

@Version ekleyince Hibernate otomatik olarak şunu yapar:

-- Okuma
SELECT id, name, stock, price, version FROM products WHERE id = 1;
-- version = 5

-- Güncelleme (version kontrolü ile)
UPDATE products 
SET stock = 9, version = 6 
WHERE id = 1 AND version = 5;
-- Eğer version değişmişse → 0 row affected → OptimisticLockException!

Race Condition Çözüldü

Zaman   Kullanıcı A                    Kullanıcı B
─────   ──────────                     ──────────
T1      SELECT stock=10, version=5
T2                                     SELECT stock=10, version=5
T3      UPDATE stock=9 
        WHERE version=5 → OK (1 row)
        version → 6
T4                                     UPDATE stock=9 
                                       WHERE version=5 → FAIL (0 row)
                                       OptimisticLockException!

OptimisticLockException Handling

@Service
public class StockService {

    private static final int MAX_RETRIES = 3;

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
            try {
                Product product = productRepository.findById(productId)
                    .orElseThrow(() -> new ProductNotFoundException(productId));
                
                if (product.getStock() < quantity) {
                    throw new InsufficientStockException(productId, quantity);
                }
                
                product.setStock(product.getStock() - quantity);
                productRepository.save(product);
                return; // Başarılı — döngüden çık
                
            } catch (OptimisticLockException e) {
                if (attempt == MAX_RETRIES) {
                    throw new StockUpdateException(
                        "Stok güncellenemedi (yoğunluk). Lütfen tekrar deneyin.", e);
                }
                log.warn("Optimistic lock conflict, attempt {}/{}", 
                    attempt, MAX_RETRIES);
                // Kısa bekleme sonrası tekrar dene
            }
        }
    }
}

⚠️ Dikkat: Retry yaparken her denemede veriyi tekrar oku. Eski veriyle retry yaparsan aynı hatayı alırsın. Ayrıca retry'ı @Transactional metodun dışında yapman gerekir — çünkü OptimisticLockException transaction'ı rollback-only işaretler. En temiz çözüm retry'ı ayrı bir katmana taşımaktır:

@Service
public class StockFacade {

    private final StockService stockService;

    // Retry mantığı transaction dışında
    public void decreaseStockWithRetry(Long productId, int quantity) {
        int maxRetries = 3;
        for (int i = 0; i < maxRetries; i++) {
            try {
                stockService.decreaseStock(productId, quantity);
                return;
            } catch (OptimisticLockException e) {
                if (i == maxRetries - 1) throw e;
                log.warn("Retry {} for product {}", i + 1, productId);
            }
        }
    }
}

@Service
public class StockService {

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId).orElseThrow();
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(productId, quantity);
        }
        product.setStock(product.getStock() - quantity);
        productRepository.save(product);
    }
}

Spring Retry ile Daha Temiz Çözüm

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
@Configuration
@EnableRetry
public class RetryConfig {
}

@Service
public class StockService {

    @Retryable(
        retryFor = OptimisticLockException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100, multiplier = 2)
    )
    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId).orElseThrow();
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(productId, quantity);
        }
        product.setStock(product.getStock() - quantity);
        productRepository.save(product);
    }

    @Recover
    public void recoverStock(OptimisticLockException e, Long productId, int quantity) {
        log.error("Stok güncellenemedi: productId={}, quantity={}", productId, quantity);
        throw new StockUpdateException("Stok yoğunluğu nedeniyle güncellenemedi", e);
    }
}

4. Pessimistic Locking — "Önce Kilitle, Sonra Çalış"

Pessimistic locking, veriyi okuduğun anda kilitleme yapar. Başka transaction o veriyi okuyana/yazana kadar bekler.

Ne Zaman Pessimistic Locking?

DurumOptimisticPessimistic
Çakışma az (çoğu zaman sorun yok)✅ Tercih et❌ Gereksiz
Çakışma çok (sık conflict)❌ Çok retry✅ Tercih et
Read-heavy sistem✅ Lock yok❌ Gereksiz lock
Write-heavy, aynı kayda❌ Çok conflict✅ Sıralı erişim
Kısa transaction
Uzun transaction❌ Lock çok uzun tutulur

Repository'de Pessimistic Lock

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Pessimistic Write Lock — okurken kilitle, başka kimse okuyamaz/yazamaz
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);

    // Pessimistic Read Lock — okurken paylaşımlı kilitle
    // Başkaları okuyabilir ama yazamaz
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithReadLock(@Param("id") Long id);
}

Kullanım

@Service
public class StockService {

    @Transactional
    public void decreaseStockPessimistic(Long productId, int quantity) {
        // SELECT ... FOR UPDATE — satır kilitlenir
        Product product = productRepository.findByIdWithLock(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
        
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(productId, quantity);
        }
        
        product.setStock(product.getStock() - quantity);
        productRepository.save(product);
        // Transaction bitince lock otomatik serbest kalır
    }
}

Oluşan SQL:

SELECT p.id, p.name, p.stock, p.price, p.version 
FROM products p 
WHERE p.id = ? 
FOR UPDATE;  -- Bu satır kilitlendi!

Lock Timeout

Pessimistic lock'ta bir transaction lock'u tutarken diğeri bekler. Sonsuza kadar bekletmemek için timeout koy:

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
        // 3 saniye bekle, hâlâ lock alınamazsa → PessimisticLockException
    })
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithTimedLock(@Param("id") Long id);
}

Deadlock Senaryosu ve Çözümü

Deadlock, iki transaction'ın birbirinin lock'unu beklediği durumdur:

Transaction A                      Transaction B
─────────────                      ─────────────
LOCK product #1                    LOCK product #2
...                                ...
LOCK product #2 (BEKLİYOR)        LOCK product #1 (BEKLİYOR)
    ↓                                  ↓
    A, B'yi bekliyor                   B, A'yı bekliyor
    → DEADLOCK!

Çözüm: Kaynakları her zaman aynı sırada kilitle!

@Transactional
public void transferStock(Long fromProductId, Long toProductId, int quantity) {
    // Her zaman küçük ID'yi önce kilitle → deadlock olmaz
    Long firstId = Math.min(fromProductId, toProductId);
    Long secondId = Math.max(fromProductId, toProductId);
    
    Product first = productRepository.findByIdWithLock(firstId).orElseThrow();
    Product second = productRepository.findByIdWithLock(secondId).orElseThrow();
    
    Product from = fromProductId.equals(firstId) ? first : second;
    Product to = toProductId.equals(firstId) ? first : second;
    
    if (from.getStock() < quantity) {
        throw new InsufficientStockException(fromProductId, quantity);
    }
    
    from.setStock(from.getStock() - quantity);
    to.setStock(to.getStock() + quantity);
    
    productRepository.save(from);
    productRepository.save(to);
}

⚠️ Dikkat: Deadlock veritabanı tarafından algılanır ve bir transaction otomatik rollback edilir. Ama buna güvenmek yerine, kaynakları tutarlı bir sırada kilitlemek en iyi pratiktir.


5. Transaction Timeout

Bir transaction'ın sonsuz çalışmasını engellemek için timeout koyabilirsin:

@Transactional(timeout = 30) // 30 saniye — sonra TransactionTimedOutException
public void processLargeOrder(LargeOrderRequest request) {
    // 30 saniye içinde bitmezse timeout!
    for (OrderItem item : request.getItems()) {
        stockService.decreaseStock(item.getProductId(), item.getQuantity());
    }
    paymentService.charge(request.getTotalAmount());
}

Global Timeout

# application.yml
spring:
  transaction:
    default-timeout: 30  # Tüm transaction'lar için varsayılan timeout (saniye)

Timeout Ne Zaman Kontrol Edilir?

Timeout sadece veritabanı işlemi sırasında kontrol edilir. Yani Thread.sleep() veya harici API çağrısı yapsan, timeout tetiklenmez. Veritabanı sorgusu çalıştırıldığında kontrol edilir.

@Transactional(timeout = 5)
public void example() {
    Thread.sleep(10_000); // 10 saniye — timeout tetiklenmez!
    repository.findAll();  // Burada timeout kontrol edilir → TransactionTimedOutException
}

💡 İpucu: Her zaman bir timeout belirle. Varsayılan timeout sonsuz'dur. Timeout olmayan bir transaction, deadlock veya beklenmedik bir durumda sonsuza kadar açık kalabilir ve connection pool'u tüketir.


6. Programmatic Transaction Management

@Transactional declarative (bildirimsel) yaklaşımdır. Bazen daha fazla kontrol gerekir — o zaman programmatic (programatik) yaklaşım kullanırsın.

TransactionTemplate

@Service
public class OrderService {

    private final TransactionTemplate transactionTemplate;
    private final TransactionTemplate readOnlyTransactionTemplate;

    public OrderService(PlatformTransactionManager transactionManager) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.transactionTemplate.setTimeout(30);
        
        this.readOnlyTransactionTemplate = new TransactionTemplate(transactionManager);
        this.readOnlyTransactionTemplate.setReadOnly(true);
    }

    // Dönüş değeri olan
    public Order createOrder(CreateOrderRequest request) {
        return transactionTemplate.execute(status -> {
            Order order = orderRepository.save(buildOrder(request));
            stockService.decreaseStock(request.getProductId(), request.getQuantity());
            paymentService.charge(order.getTotalAmount());
            
            // Koşullu rollback
            if (order.getTotalAmount().compareTo(new BigDecimal("10000")) > 0) {
                status.setRollbackOnly(); // Manuel rollback
                throw new OrderLimitExceededException("10.000 TL limit aşıldı");
            }
            
            return order;
        });
    }

    // Dönüş değeri olmayan
    public void deleteExpiredOrders() {
        transactionTemplate.executeWithoutResult(status -> {
            List<Order> expired = orderRepository.findExpiredOrders();
            orderRepository.deleteAll(expired);
        });
    }

    // Read-only
    public List<Order> getOrders() {
        return readOnlyTransactionTemplate.execute(status -> {
            return orderRepository.findAll();
        });
    }
}

TransactionManager Direkt Kullanımı

Daha düşük seviye kontrol:

@Service
public class AdvancedService {

    private final PlatformTransactionManager transactionManager;

    public void complexOperation() {
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
        def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
        def.setTimeout(30);
        def.setName("complexOperation");

        TransactionStatus status = transactionManager.getTransaction(def);
        
        try {
            // İş mantığı
            orderRepository.save(order);
            stockService.decreaseStock(productId, quantity);
            
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
}

Ne Zaman Programmatic?

DurumDeclarative (@Transactional)Programmatic (TransactionTemplate)
Standart CRUD❌ Gereksiz karmaşıklık
Koşullu transaction❌ Zor✅ Kolay
Dinamik timeout/isolation❌ Annotation sabit✅ Runtime'da değiştirilebilir
Batch işlem (chunk)✅ Her chunk ayrı TX
Transaction içinde kısmi rollbackstatus.setRollbackOnly()

💡 İpucu: Çoğu durumda @Transactional yeterli. TransactionTemplate sadece özel durumlar için kullan. İkisini aynı anda karıştırma — kafa karıştırır.


7. Distributed Transaction — Mikroservis Dünyası

Monolith'te tüm tablolar aynı veritabanında. Tek bir @Transactional her şeyi çözer. Ama mikroservislerde her servisin kendi veritabanı var. Tek bir transaction ile birden fazla veritabanını kapsayamazsın.

2PC (Two-Phase Commit) / XA Transactions

Geleneksel çözüm: Bir koordinatör tüm katılımcılara "hazır mısınız?" sorar (Phase 1), hepsi "evet" derse commit komutu verir (Phase 2).

Coordinator
    │
    ├──► Service A DB: "Hazır mısın?" → "Evet"
    ├──► Service B DB: "Hazır mısın?" → "Evet"  
    └──► Service C DB: "Hazır mısın?" → "Evet"
    
    Hepsi evet → COMMIT
    Biri hayır → ROLLBACK ALL

Sorunları:

  • Performans: Her adım ağ çağrısı — çok yavaş

  • Availability: Koordinatör çökerse → tüm sistem kilitlenir

  • Ölçeklenebilirlik: Katılımcı sayısı arttıkça yavaşlar

  • Database desteği: Her DB XA desteklemeyebilir

Saga Pattern — Modern Çözüm

Saga, distributed transaction'ı ardışık local transaction'lara böler. Her adım kendi transaction'ında çalışır. Bir adım başarısız olursa önceki adımları compensating transaction ile geri alır.

Choreography (Event-driven):

Order Service         Inventory Service       Payment Service
    │                       │                       │
    ├── Order Created ──────►                       │
    │                       ├── Stock Reserved ─────►
    │                       │                       ├── Payment OK
    │                       │                       │
    │ (Ödeme başarısız olursa)                      │
    │                       ◄── Release Stock ──────┤
    ◄── Cancel Order ───────┤                       │
// Orchestration (Merkezî koordinatör):
@Service
public class OrderSagaOrchestrator {

    public void executeOrderSaga(OrderCommand command) {
        String sagaId = UUID.randomUUID().toString();
        
        try {
            // Step 1: Sipariş oluştur
            OrderResult order = orderService.createOrder(command);
            
            // Step 2: Stok rezerve et
            inventoryService.reserveStock(order.getId(), command.getItems());
            
            // Step 3: Ödeme al
            paymentService.charge(order.getId(), command.getAmount());
            
            // Step 4: Siparişi onayla
            orderService.confirmOrder(order.getId());
            
        } catch (StockReservationException e) {
            // Compensating: Siparişi iptal et
            orderService.cancelOrder(sagaId);
            
        } catch (PaymentException e) {
            // Compensating: Stok serbest bırak + siparişi iptal et
            inventoryService.releaseStock(sagaId);
            orderService.cancelOrder(sagaId);
        }
    }
}

⚠️ Dikkat: Saga pattern karmaşıktır. Compensating transaction'lar idempotent olmalıdır. Bu konu başlı başına bir mikroservis konusudur — burada sadece tanıtıyoruz. Detaylar için Microservices bölümüne bak.

Outbox Pattern

Saga'nın güvenilir çalışması için Outbox pattern kullanılır:

@Transactional
public void createOrder(OrderCommand command) {
    // 1. Siparişi kaydet (aynı transaction)
    Order order = orderRepository.save(new Order(command));
    
    // 2. Event'i outbox tablosuna yaz (aynı transaction)
    outboxRepository.save(new OutboxEvent(
        "OrderCreated",
        objectMapper.writeValueAsString(order)
    ));
    
    // Aynı DB, aynı transaction → atomik
}

// Ayrı bir process outbox tablosunu polling yaparak event'leri Kafka'ya gönderir
// (Debezium CDC ile de yapılabilir)

8. Testing Transactions

@Transactional in Tests — Auto-Rollback

Spring Boot testlerinde @Transactional koyarsan, her test sonunda otomatik rollback olur. Böylece testler birbirini etkilemez.

@SpringBootTest
@Transactional // Her test sonunda rollback
class OrderServiceTest {

    @Autowired
    private OrderService orderService;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        Product product = new Product();
        product.setName("Test Ürün");
        product.setStock(100);
        product.setPrice(new BigDecimal("99.99"));
        productRepository.save(product);
    }

    @Test
    void shouldCreateOrderSuccessfully() {
        // Given
        CreateOrderRequest request = new CreateOrderRequest(1L, 1L, 2);
        
        // When
        OrderResponse response = orderService.createOrder(request);
        
        // Then
        assertThat(response.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
        assertThat(productRepository.findById(1L).get().getStock()).isEqualTo(98);
        
        // Test bittikten sonra → rollback → DB temiz kalır
    }

    @Test
    void shouldThrowWhenInsufficientStock() {
        // Given
        CreateOrderRequest request = new CreateOrderRequest(1L, 1L, 999);
        
        // When & Then
        assertThatThrownBy(() -> orderService.createOrder(request))
            .isInstanceOf(InsufficientStockException.class);
    }
}

Rollback İstemiyorsan

@Test
@Rollback(false) // Bu testin değişiklikleri commit edilir
void shouldPersistData() {
    orderService.createOrder(request);
    // Commit olur — dikkatli kullan, diğer testleri etkileyebilir
}

// Veya @Commit annotation ile
@Test
@Commit
void shouldPersistData() {
    orderService.createOrder(request);
}

Transaction Davranışını Test Etme

@SpringBootTest
class TransactionBehaviorTest {

    @Autowired
    private OrderService orderService;
    
    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldRollbackOnPaymentFailure() {
        // Given
        Product product = productRepository.save(
            new Product("Test", 100, new BigDecimal("99.99")));
        
        CreateOrderRequest request = new CreateOrderRequest(
            1L, product.getId(), 5);
        request.setPaymentMethod("INVALID"); // Ödeme başarısız olacak
        
        // When
        assertThatThrownBy(() -> orderService.createOrder(request))
            .isInstanceOf(PaymentFailedException.class);
        
        // Then — stok geri gelmiş olmalı (rollback)
        Product updated = productRepository.findById(product.getId()).get();
        assertThat(updated.getStock()).isEqualTo(100); // Rollback oldu!
    }

    @Test
    void shouldNotRollbackAuditLog() {
        // Given
        CreateOrderRequest request = /* hatalı request */;
        
        // When
        assertThatThrownBy(() -> orderService.createOrder(request));
        
        // Then — audit log kaydedilmiş olmalı (REQUIRES_NEW)
        List<AuditLog> logs = auditLogRepository.findByCustomerId(1L);
        assertThat(logs).isNotEmpty(); // Audit log kalıcı!
    }
}

⚠️ Dikkat: Test sınıfına @Transactional koyarsan, servis metodundaki REQUIRES_NEW propagation düzgün test edilemez — çünkü test transaction'ı dış transaction olur. Bu tür testleri @Transactional olmadan yap ve test sonrasında DB'yi @AfterEach ile temizle.

// REQUIRES_NEW testi — test sınıfında @Transactional YOK
@SpringBootTest
class AuditServiceIntegrationTest {

    @Autowired
    private AuditLogRepository auditLogRepository;
    
    @AfterEach
    void cleanUp() {
        auditLogRepository.deleteAll(); // Manuel temizlik
    }

    @Test
    void auditLogShouldSurviveRollback() {
        // ...
    }
}

9. Gerçek Dünya Senaryosu: Stok Yönetimi — Race Condition & Locking

Bir e-ticaret sitesinde flash sale (indirimli satış) senaryosu düşünelim. 100 adet iPhone var, 10.000 kullanıcı aynı anda almak istiyor.

Senaryo: Flash Sale

// ❌ YANLIŞ: Race condition var
@Service
public class NaiveStockService {

    @Transactional
    public void purchaseProduct(Long productId, Long userId) {
        Product product = productRepository.findById(productId).orElseThrow();
        
        if (product.getStock() > 0) {
            product.setStock(product.getStock() - 1);
            productRepository.save(product);
            
            orderRepository.save(new Order(userId, productId));
        } else {
            throw new OutOfStockException("Ürün tükendi!");
        }
    }
}
// 100 stok var ama 150 kişi satın alabilir — race condition!

Çözüm 1: Optimistic Locking

@Entity
public class Product {
    @Id
    private Long id;
    private Integer stock;
    
    @Version
    private Long version;
}

@Service
public class OptimisticStockService {

    @Retryable(
        retryFor = OptimisticLockException.class,
        maxAttempts = 5,
        backoff = @Backoff(delay = 50, multiplier = 2, random = true)
    )
    @Transactional
    public void purchaseProduct(Long productId, Long userId) {
        Product product = productRepository.findById(productId).orElseThrow();
        
        if (product.getStock() <= 0) {
            throw new OutOfStockException("Ürün tükendi!");
        }
        
        product.setStock(product.getStock() - 1);
        productRepository.save(product);
        orderRepository.save(new Order(userId, productId));
    }

    @Recover
    public void recoverPurchase(OptimisticLockException e, Long productId, Long userId) {
        throw new TooManyRequestsException("Çok yoğunluk! Lütfen tekrar deneyin.");
    }
}

Avantaj: Lock tutma yok, read heavy sistemlerde performanslı. Dezavantaj: Flash sale'de çok fazla retry → performans düşer, kullanıcı deneyimi kötüleşir.

Çözüm 2: Pessimistic Locking

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

@Service
public class PessimisticStockService {

    @Transactional(timeout = 5) // 5 saniye timeout
    public void purchaseProduct(Long productId, Long userId) {
        // SELECT ... FOR UPDATE — satır kilitlenir
        Product product = productRepository.findByIdForUpdate(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
        
        if (product.getStock() <= 0) {
            throw new OutOfStockException("Ürün tükendi!");
        }
        
        product.setStock(product.getStock() - 1);
        productRepository.save(product);
        orderRepository.save(new Order(userId, productId));
        // Transaction bitince lock serbest, sıradaki request girer
    }
}

Avantaj: Race condition kesin çözülür. Retry gerek yok. Dezavantaj: Requests kuyrukta bekler. Aşırı yoğunlukta timeout olabilir.

Çözüm 3: Database-Level Atomic Update (En Performanslı)

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // Tek SQL ile atomic güncelleme — application level lock yok
    @Modifying
    @Query("UPDATE Product p SET p.stock = p.stock - :quantity " +
           "WHERE p.id = :id AND p.stock >= :quantity")
    int decreaseStock(@Param("id") Long id, @Param("quantity") int quantity);
}

@Service
public class AtomicStockService {

    @Transactional
    public void purchaseProduct(Long productId, Long userId) {
        int updatedRows = productRepository.decreaseStock(productId, 1);
        
        if (updatedRows == 0) {
            throw new OutOfStockException("Ürün tükendi veya yetersiz stok!");
        }
        
        orderRepository.save(new Order(userId, productId));
    }
}

Bu neden en iyi?

  • Veritabanı kendi row-level lock mekanizmasını kullanır

  • Application seviyesinde lock yok — daha hızlı

  • Tek SQL çağrısı — ağ overhead minimum

  • Race condition imkansız — WHERE stock >= :quantity kontrolü atomik

💡 İpucu: Flash sale gibi yüksek yoğunluklu senaryolarda Çözüm 3 (atomic update) en iyisidir. Normal CRUD operasyonlarında Optimistic Locking yeterlidir. Pessimistic locking ise birden fazla kaynağın sıralı güncellenmesi gereken senaryolarda (banka transferi gibi) kullanışlıdır.

Çözüm 4: Redis ile Distributed Lock (Bonus)

Çok yoğun senaryolarda DB'ye bile gitmeden Redis ile ön kontrol yapabilirsin:

@Service
public class RedisStockService {

    private final StringRedisTemplate redisTemplate;
    private final AtomicStockService atomicStockService;

    public void purchaseProduct(Long productId, Long userId) {
        String key = "stock:" + productId;
        
        // Redis'te atomic decrement
        Long remaining = redisTemplate.opsForValue().decrement(key);
        
        if (remaining == null || remaining < 0) {
            // Stok tükendi — DB'ye gitmeye gerek yok
            redisTemplate.opsForValue().increment(key); // Geri al
            throw new OutOfStockException("Ürün tükendi!");
        }
        
        try {
            // DB'de gerçek işlemi yap
            atomicStockService.purchaseProduct(productId, userId);
        } catch (Exception e) {
            // DB hatası → Redis'i geri al
            redisTemplate.opsForValue().increment(key);
            throw e;
        }
    }
}

10. Transaction Best Practices Checklist

Production'a çıkmadan önce bu listeyi kontrol et:

✅ Transaction Checklist
─────────────────────────
□ @Transactional service layer'da (controller/repository'de değil)
□ readOnly = true tüm okuma metodlarında
□ rollbackFor = Exception.class (checked exception'ları da kapsasın)
□ Timeout belirlenmiş (default sonsuz — tehlikeli!)
□ Self-invocation yok (aynı class içinde this.method() çağrısı)
□ Long-running transaction yok (I/O işlemleri TX dışında)
□ Locking stratejisi belirlenmiş (optimistic/pessimistic/atomic)
□ Deadlock önlemi var (kaynaklar tutarlı sırada kilitleniyor)
□ Transaction logging açık (en azından test/staging'de)
□ Testler transaction davranışını doğruluyor
□ Connection pool boyutu hesaplanmış
□ Harici servis çağrıları TX dışına taşınmış

Özet

  • Transaction boundary'leri service layer'da olmalı. Repository'de veya controller'da transaction yönetme. Facade pattern ile karmaşık akışları orkestre et.

  • Long-running transaction'lardan kaçın: I/O işlemleri (dosya, HTTP, email) transaction dışına taşı. Batch işlemleri chunk'lara böl, her chunk ayrı transaction'da çalışsın.

  • Optimistic Locking (@Version) çakışmanın az olduğu, read-heavy sistemlerde idealdir. Retry mekanizması ile birlikte kullan. Pessimistic Locking (FOR UPDATE) çakışmanın yoğun olduğu, sıralı erişim gereken senaryolarda kullan.

  • Atomic database update (UPDATE ... WHERE stock >= :qty) en performanslı çözümdür — flash sale gibi yoğun senaryolarda tercih et.

  • Programmatic transaction (TransactionTemplate) özel durumlar için sakla — koşullu rollback, dinamik timeout, chunk processing gibi. Standart CRUD için @Transactional yeterli.

  • Test'te `@Transactional` auto-rollback sağlar ama REQUIRES_NEW propagation'ı doğru test etmek için test sınıfına @Transactional koymadan çalış ve DB'yi manuel temizle.