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?
Business logic service'te yaşar. Transaction da business logic'in sınırlarını takip etmeli.
Birden fazla repository çağrısı aynı transaction'da olmalı.
Controller'da transaction koyma — controller HTTP endişeleriyle ilgilenmeli, transaction ile değil.
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
SimpleJpaRepositorysınıfı zaten@Transactional(readOnly = true)ile işaretlidir.save(),delete()gibi yazma metodları da@Transactionaloverride 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@Transactionalkoymalı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'ı
@Transactionalmetodun dışında yapman gerekir — çünküOptimisticLockExceptiontransaction'ı 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?
| Durum | Optimistic | Pessimistic |
|---|---|---|
| Ç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?
| Durum | Declarative (@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 rollback | ❌ | ✅ status.setRollbackOnly() |
💡 İpucu: Çoğu durumda
@Transactionalyeterli.TransactionTemplatesadece ö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 ALLSorunları:
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
@Transactionalkoyarsan, servis metodundakiREQUIRES_NEWpropagation düzgün test edilemez — çünkü test transaction'ı dış transaction olur. Bu tür testleri@Transactionalolmadan yap ve test sonrasında DB'yi@AfterEachile 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 >= :quantitykontrolü 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@Transactionalyeterli.Test'te `@Transactional` auto-rollback sağlar ama
REQUIRES_NEWpropagation'ı doğru test etmek için test sınıfına@Transactionalkoymadan çalış ve DB'yi manuel temizle.
AI Asistan
Sorularını yanıtlamaya hazır