Obsah článku / Contents
Globálna @Transactional: Keď lenivosť zabíja performance

Globálna @Transactional: Keď lenivosť zabíja performance

"Prečo mi padá databáza? Mám len @Transactional na každej metóde!" 😰

Anti-pattern ktorý vidím na každom druhom projekte.

Prolog: The Crime Scene

java
// application.properties
spring.jpa.open-in-view=true  // ❌ Default!
// + niekto ešte pridal:
spring.aop.auto=true
// + custom config:

@Configuration
public class GlobalTransactionConfig {
    @Bean
    public TransactionInterceptor transactionInterceptor() {
        // ❌ VŠETKO je transactional!
        return new TransactionInterceptor(...);
    }
}

// Výsledok:
@RestController
public class UserController {
    
    public User getUser(Long id) {
        // Táto metóda JE v transakcii (ale nechce!)
        // Connection drží 500ms namiesto 5ms
        // Connection pool: 💀
    }
}

Real-world scenár:

Production incident:
- Load balancer: 1000 req/s
- Database connections: 20 (pool size)
- Average response time: 200ms

After "optimalizácia" (globálna @Transactional):
- Same load: 1000 req/s  
- Database connections: 20 (všetky POUŽITÉ!)
- Average response time: 5000ms
- Error rate: 60% (connection timeout)

What happened? 🤔
- Každý request drží connection
- Aj READ-ONLY requesty
- Aj requesty ktoré nevolajú DB
- Connection pool exhausted
- App padá 💀

Poďme na to!


Kapitola 1: Čo je @Transactional (a čo NIE je)

Transaction Basics

java
// Transaction = Unit of work with ACID properties

// ACID:
Atomicity:    All or nothing (commit alebo rollback)
Consistency:  Valid state → Valid state
Isolation:    Concurrent transactions don't interfere
Durability:   Committed = persisted

// V praxi:
@Transactional
public void transferMoney(Long from, Long to, BigDecimal amount) {
    accountRepo.debit(from, amount);   // Step 1
    accountRepo.credit(to, amount);    // Step 2
    
    // Ak Step 2 zlyhá → Step 1 sa ROLLBACK-ne
    // Atomicity! ✅
}

Kedy @Transactional POTREBUJEŠ

java
// ✅ 1. WRITE operations (INSERT/UPDATE/DELETE)
@Transactional
public void createUser(User user) {
    userRepo.save(user);  // Needs transaction!
}

// ✅ 2. Multiple operations (atomicity needed)
@Transactional
public void createOrder(Order order) {
    orderRepo.save(order);
    inventoryService.decreaseStock(order.getItems());
    paymentService.charge(order.getTotal());
    
    // Všetko alebo nič!
}

// ✅ 3. Lazy loading (access entities outside repo)
@Transactional(readOnly = true)
public OrderDTO getOrderWithItems(Long id) {
    Order order = orderRepo.findById(id).orElseThrow();
    // Access lazy collection inside transaction
    order.getItems().size();  // Needs open session!
    return toDTO(order);
}

Kedy @Transactional NEPOTREBUJEŠ

java
// ❌ 1. Simple READ operations (Spring Data handles it)
// WRONG:
@Transactional(readOnly = true)
public User findById(Long id) {
    return userRepo.findById(id).orElse(null);
    // Repository už má vlastnú transactional!
}

// RIGHT:
public User findById(Long id) {
    return userRepo.findById(id).orElse(null);
    // Clean! Spring Data repo má internú transaction
}

// ❌ 2. Controller methods (should be in service!)
@RestController
public class UserController {
    
    // WRONG:
    @Transactional  // ❌ Never on controller!
    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.getUser(id);
    }
}

// ❌ 3. Methods that don't touch database
// WRONG:
@Transactional
public String formatUserName(User user) {
    return user.getFirstName() + " " + user.getLastName();
    // Pure logic, no DB! Prečo transaction?!
}

// ❌ 4. Async methods
@Async
@Transactional  // ❌ WON'T WORK!
public void sendEmail(User user) {
    // Transaction nie je propagovaná do async!
}

Kapitola 2: Propagation Strategies

REQUIRED (Default)

java
@Transactional(propagation = Propagation.REQUIRED)
public void methodA() {
    // Behavior:
    // - If transaction exists → join it
    // - If NO transaction → create new
}

// Example:
@Transactional  // Creates TX1
public void outer() {
    inner();  // Joins TX1
}

@Transactional  // Joins parent transaction
public void inner() {
    // Same transaction as outer()!
}

// Rollback behavior:
@Transactional
public void outer() {
    inner();  // If inner() throws → whole TX1 rollbacks
}

@Transactional
public void inner() {
    throw new RuntimeException();  // Rollbacks TX1!
}

Use case:

java
// ✅ Standard business operations
@Transactional
public void createOrder(OrderDTO dto) {
    Order order = orderRepo.save(new Order(dto));
    
    // This joins parent transaction
    inventoryService.reserveItems(order.getItems());
    
    // Both operations in SAME transaction
    // If inventory fails → order rollbacks too ✅
}

@Transactional
public void reserveItems(List<Item> items) {
    items.forEach(item -> {
        item.setReserved(true);
        itemRepo.save(item);
    });
}

REQUIRES_NEW

java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB() {
    // ALWAYS creates NEW transaction
    // Suspends parent transaction (if exists)
}

// Example:
@Transactional  // TX1
public void outer() {
    auditService.log("Started");  // TX2 (new!)
    
    businessLogic();  // Still in TX1
    
    auditService.log("Finished");  // TX3 (new!)
    
    // TX2 and TX3 are INDEPENDENT from TX1
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String message) {
    auditRepo.save(new AuditLog(message));
    // Commits IMMEDIATELY (independent transaction)
}

Use case:

java
// ✅ Logging/Audit (nezávislé na business transaction)
@Service
public class AuditService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAction(String action, String user) {
        AuditLog log = new AuditLog(action, user);
        auditRepo.save(log);
        
        // Commit hneď!
        // Aj keď parent transaction rollback-ne,
        // audit log OSTANE v DB ✅
    }
}

// Usage:
@Transactional
public void deleteUser(Long userId) {
    auditService.logAction("DELETE_USER", userId);
    
    userRepo.deleteById(userId);
    
    if (someCondition) {
        throw new RuntimeException();  // Rollback!
        // User: deleted → rolled back ❌
        // Audit: logged → COMMITTED ✅
    }
}

Connection pool warning:

java
// ⚠️ REQUIRES_NEW uses 2 connections simultaneously!

@Transactional  // Connection #1
public void outer() {
    // Holding connection #1
    
    inner();  // Requests connection #2
    
    // Now holding BOTH connections!
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
    // Uses connection #2
}

// Pool size = 10
// 5 concurrent requests with nested REQUIRES_NEW
// = 10 connections used
// Pool exhausted! 💀

NESTED

java
@Transactional(propagation = Propagation.NESTED)
public void methodC() {
    // Creates SAVEPOINT in parent transaction
    // Can rollback to savepoint without affecting parent
}

// Example:
@Transactional  // TX1
public void processOrder(Order order) {
    orderRepo.save(order);  // Point A
    
    try {
        // NESTED transaction (savepoint)
        paymentService.charge(order);  // Point B
    } catch (PaymentException e) {
        // Rollback to Point B (savepoint)
        // Point A (order save) still valid!
        order.setStatus(PAYMENT_FAILED);
        orderRepo.save(order);
    }
    
    // Commit whole TX1
}

@Transactional(propagation = Propagation.NESTED)
public void charge(Order order) {
    // Creates savepoint
    payment.process(order.getTotal());
    
    if (payment.failed()) {
        throw new PaymentException();
        // Rollback to savepoint only!
    }
}

Use case:

java
// ✅ Partial rollback scenarios
@Transactional
public void importUsers(List<UserDTO> users) {
    int imported = 0;
    
    for (UserDTO dto : users) {
        try {
            importUser(dto);  // NESTED
            imported++;
        } catch (Exception e) {
            // This user failed, continue with others
            log.error("Failed to import user: {}", dto, e);
        }
    }
    
    log.info("Imported {} out of {} users", imported, users.size());
    // Some succeed, some fail - both outcomes persisted ✅
}

@Transactional(propagation = Propagation.NESTED)
public void importUser(UserDTO dto) {
    User user = new User(dto);
    userRepo.save(user);
    
    if (dto.hasAddress()) {
        addressRepo.save(new Address(dto.getAddress()));
    }
}

Database support:

java
// ⚠️ NESTED requires SAVEPOINT support

// Supported:
// ✅ PostgreSQL
// ✅ Oracle
// ✅ SQL Server
// ✅ H2

// NOT supported:
// ❌ MySQL (depends on storage engine)
// ❌ Some NoSQL databases

// Check before using!

SUPPORTS

java
@Transactional(propagation = Propagation.SUPPORTS)
public void methodD() {
    // If transaction exists → join it
    // If NO transaction → execute NON-transactional
}

// Example:
// Caller WITH transaction:
@Transactional
public void withTx() {
    helperMethod();  // Joins transaction
}

// Caller WITHOUT transaction:
public void withoutTx() {
    helperMethod();  // NO transaction
}

@Transactional(propagation = Propagation.SUPPORTS)
public void helperMethod() {
    // Works both ways
}

Use case:

java
// ✅ Optional transactional behavior
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
public User findUser(Long id) {
    return userRepo.findById(id).orElse(null);
    
    // If called from @Transactional method → joins it
    // If called standalone → simple query (no TX overhead)
}

NOT_SUPPORTED

java
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void methodE() {
    // ALWAYS runs WITHOUT transaction
    // Suspends parent transaction if exists
}

Use case:

java
// ✅ External API calls (should not be in transaction)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendToExternalAPI(Order order) {
    // HTTP call to external service
    externalClient.sendOrder(order);
    
    // Don't hold DB connection during HTTP call!
}

// Usage:
@Transactional
public void processOrder(Order order) {
    orderRepo.save(order);
    
    // Suspend transaction for external call
    externalService.sendToExternalAPI(order);
    
    // Resume transaction
    order.setStatus(SENT);
    orderRepo.save(order);
}

MANDATORY

java
@Transactional(propagation = Propagation.MANDATORY)
public void methodF() {
    // MUST be called from existing transaction
    // Throws exception if no transaction exists
}

Use case:

java
// ✅ Internal service methods (must be in transaction)
@Transactional(propagation = Propagation.MANDATORY)
public void updateOrderStatus(Order order, Status newStatus) {
    order.setStatus(newStatus);
    orderRepo.save(order);
    
    // Must be called from transactional method!
    // Prevents accidental standalone calls
}

// Correct usage:
@Transactional
public void completeOrder(Long orderId) {
    Order order = orderRepo.findById(orderId).orElseThrow();
    updateOrderStatus(order, COMPLETED);  // ✅ OK
}

// Wrong usage:
public void somewhere() {
    updateOrderStatus(order, COMPLETED);  // ❌ Exception!
    // "No existing transaction found for transaction marked with propagation 'mandatory'"
}

NEVER

java
@Transactional(propagation = Propagation.NEVER)
public void methodG() {
    // MUST run WITHOUT transaction
    // Throws exception if transaction exists
}

Use case:

java
// ✅ Performance-critical read operations
@Transactional(propagation = Propagation.NEVER)
public List<Product> searchProducts(String query) {
    // Heavy read-only query
    // Should NOT be in transaction (no locks needed)
    return productRepo.search(query);
}

// Wrong usage:
@Transactional
public void process() {
    searchProducts("laptop");  // ❌ Exception!
    // "Existing transaction found for transaction marked with propagation 'never'"
}

Kapitola 3: The Lazy Loading Nightmare

Problem: Nekonečný cyklus

java
// User entity
@Entity
public class User {
    @Id
    private Long id;
    
    @OneToOne(fetch = FetchType.EAGER)  // ❌ EAGER!
    private Address address;
}

// Address entity
@Entity
public class Address {
    @Id
    private Long id;
    
    @OneToOne(mappedBy = "address", fetch = FetchType.EAGER)  // ❌ EAGER!
    private User user;
}

// Controller:
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepo.findById(id).orElse(null);
}

// Čo sa stane:
User → load Address (eager)
  Address → load User (eager)
    User → load Address (eager)
      Address → load User (eager)
        ... STACK OVERFLOW! 💀

Solution 1: Lazy Loading

java
// User entity
@Entity
public class User {
    @Id
    private Long id;
    
    @OneToOne(fetch = FetchType.LAZY)  // ✅ LAZY!
    private Address address;
}

// Address entity
@Entity
public class Address {
    @Id
    private Long id;
    
    @OneToOne(mappedBy = "address", fetch = FetchType.LAZY)
    private User user;
}

// Service:
@Transactional(readOnly = true)
public UserDTO getUser(Long id) {
    User user = userRepo.findById(id).orElseThrow();
    
    // Load address only if needed
    if (includeAddress) {
        Hibernate.initialize(user.getAddress());
    }
    
    return toDTO(user);
}

Solution 2: @JsonIgnore

java
@Entity
public class User {
    @OneToOne(fetch = FetchType.LAZY)
    private Address address;
}

@Entity
public class Address {
    @OneToOne(mappedBy = "address")
    @JsonIgnore  // ✅ Break circular reference
    private User user;
}

Solution 3: DTOs (BEST!)

java
// DTO without circular reference
public class UserDTO {
    private Long id;
    private String name;
    private AddressDTO address;  // Only needed fields
}

public class AddressDTO {
    private String street;
    private String city;
    // NO user reference! ✅
}

// Service:
@Transactional(readOnly = true)
public UserDTO getUser(Long id) {
    User user = userRepo.findById(id).orElseThrow();
    return UserDTO.from(user);  // Safe conversion
}

Solution 4: EntityGraph

java
// Define what to fetch
@EntityGraph(attributePaths = {"address"})
Optional<User> findById(Long id);

// Usage:
@Transactional(readOnly = true)
public UserDTO getUser(Long id) {
    User user = userRepo.findById(id).orElseThrow();
    // Address is fetched (no N+1)
    // But no circular reference! ✅
    return toDTO(user);
}

Kapitola 4: Connection Pool Exhaustion

Problem Scenario

java
// Configuration
spring.datasource.hikari.maximum-pool-size=10  // 10 connections

// Service:
@Transactional  // Holds connection for 500ms!
public List<UserDTO> getAllUsers() {
    List<User> users = userRepo.findAll();  // 5ms query
    
    // Business logic without DB (but in transaction!)
    return users.stream()
        .map(this::enrichWithExternalData)  // 495ms HTTP calls!
        .collect(Collectors.toList());
    
    // Connection held for entire 500ms! 💀
}

private UserDTO enrichWithExternalData(User user) {
    // HTTP call to external API
    ExternalProfile profile = externalApi.getProfile(user.getId());
    // 100ms per call, 5 users = 500ms total
    
    return new UserDTO(user, profile);
}

// Load:
// 20 concurrent requests → 10 succeed, 10 timeout
// Connection pool exhausted!

Solution 1: Narrow Transaction Scope

java
// WRONG:
@Transactional
public List<UserDTO> getAllUsers() {
    List<User> users = userRepo.findAll();  // TX start
    
    // Long processing...
    return process(users);  // TX still open! 💀
}

// RIGHT:
public List<UserDTO> getAllUsers() {
    // Get data in transaction
    List<User> users = getUsersFromDb();  // TX closed after this!
    
    // Process outside transaction
    return users.stream()
        .map(this::enrichWithExternalData)
        .collect(Collectors.toList());
}

@Transactional(readOnly = true)
private List<User> getUsersFromDb() {
    return userRepo.findAll();
    // Connection released immediately after query! ✅
}

Solution 2: Read-Only Transactions

java
// READ operations:
@Transactional(readOnly = true)  // ✅ Hint to DB
public User getUser(Long id) {
    return userRepo.findById(id).orElse(null);
    
    // Database optimizations:
    // - No flush needed
    // - Read-only replica can be used
    // - Some DBs skip locking
}

// WRITE operations:
@Transactional  // Full transaction
public void updateUser(User user) {
    userRepo.save(user);
}

Solution 3: Timeout Configuration

java
@Transactional(timeout = 5)  // 5 seconds max
public void riskyOperation() {
    // If takes > 5s → rollback automatically
}

// Global configuration:
spring.jpa.properties.hibernate.transaction.timeout=5

Solution 4: Connection Pool Tuning

java
// application.properties

// Pool size formula:
// connections = ((core_count * 2) + effective_spindle_count)

// For 4-core CPU with SSD:
spring.datasource.hikari.maximum-pool-size=10

// Connection timeout:
spring.datasource.hikari.connection-timeout=20000  // 20s

// Max lifetime (prevent stale connections):
spring.datasource.hikari.max-lifetime=1800000  // 30min

// Idle timeout:
spring.datasource.hikari.idle-timeout=600000  // 10min

// Leak detection:
spring.datasource.hikari.leak-detection-threshold=60000  // 60s

Kapitola 5: Vnorené Transakcie

Scenario 1: Service volá Service

java
@Service
public class OrderService {
    
    @Autowired
    private InventoryService inventoryService;
    
    @Transactional  // TX1 starts
    public void createOrder(OrderDTO dto) {
        Order order = orderRepo.save(new Order(dto));
        
        // Calls another @Transactional method
        inventoryService.reserveItems(order.getItems());  // Joins TX1
        
        // Both in SAME transaction
        // If inventory fails → order also rolled back
    }
}

@Service
public class InventoryService {
    
    @Transactional  // Joins parent TX
    public void reserveItems(List<Item> items) {
        items.forEach(item -> {
            if (item.getStock() < item.getQuantity()) {
                throw new InsufficientStockException();
                // Rolls back ENTIRE TX1! (order + inventory)
            }
            item.setReserved(true);
            itemRepo.save(item);
        });
    }
}

Scenario 2: Independent Operations

java
@Service
public class OrderService {
    
    @Transactional  // TX1
    public void createOrder(OrderDTO dto) {
        Order order = orderRepo.save(new Order(dto));
        
        // Log to audit (independent transaction)
        auditService.log("ORDER_CREATED", order.getId());  // TX2
        
        // Process payment
        try {
            paymentService.charge(order);  // Still TX1
        } catch (PaymentException e) {
            // TX1 rolled back
            // But audit log REMAINS (TX2 was independent) ✅
            throw e;
        }
    }
}

@Service
public class AuditService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)  // TX2 (independent)
    public void log(String action, Long entityId) {
        AuditLog log = new AuditLog(action, entityId);
        auditRepo.save(log);
        // Commits immediately!
    }
}

Scenario 3: Self-Invocation (BROKEN!)

java
@Service
public class UserService {
    
    // NO @Transactional
    public void updateUser(User user) {
        validateUser(user);
        
        // Self-invocation → proxy bypassed!
        saveUser(user);  // ❌ @Transactional NOT applied!
    }
    
    @Transactional
    private void saveUser(User user) {
        userRepo.save(user);
        // NO transaction because called directly!
    }
}

// Why?
// Spring AOP Proxy:
// Client → Proxy (@Transactional) → Real Object
//
// Self-invocation:
// Real Object → Real Object (NO proxy!)
//
// @Transactional bypassed! 💀

Fix 1: Refactor to separate service

java
@Service
public class UserService {
    
    @Autowired
    private UserPersistenceService persistenceService;
    
    public void updateUser(User user) {
        validateUser(user);
        persistenceService.saveUser(user);  // ✅ Through proxy!
    }
}

@Service
public class UserPersistenceService {
    
    @Transactional
    public void saveUser(User user) {
        userRepo.save(user);
        // Transaction works! ✅
    }
}

Fix 2: Self-inject (hacky)

java
@Service
public class UserService {
    
    @Autowired
    private UserService self;  // Self-injection
    
    public void updateUser(User user) {
        validateUser(user);
        self.saveUser(user);  // ✅ Through proxy!
    }
    
    @Transactional
    public void saveUser(User user) {
        userRepo.save(user);
    }
}

Kapitola 6: Anti-Patterns

Anti-Pattern 1: Globálna @Transactional

java
// ❌ NEVER DO THIS!
@Configuration
@EnableTransactionManagement
public class GlobalTransactionConfig {
    
    @Bean
    public BeanPostProcessor transactionBeanPostProcessor() {
        return new BeanPostProcessor() {
            @Override
            public Object postProcessBeforeInitialization(Object bean, String name) {
                // Add @Transactional to EVERYTHING
                // WORST IDEA EVER! 💀
            }
        };
    }
}

// Problems:
// 1. Every method holds DB connection
// 2. Even methods that don't touch DB
// 3. Connection pool exhausted
// 4. Performance disaster
// 5. Lazy loading everywhere (N+1 queries)

Anti-Pattern 2: @Transactional na Controlleri

java
// ❌ WRONG!
@RestController
@Transactional  // Never on controller!
public class UserController {
    
    @GetMapping("/users")
    public List<User> getUsers() {
        // Holds connection during:
        // - Request parsing
        // - Business logic
        // - Response serialization
        // - Network transmission
        // Connection held for 500ms instead of 5ms! 💀
    }
}

// ✅ RIGHT!
@RestController
public class UserController {
    
    @GetMapping("/users")
    public List<UserDTO> getUsers() {
        return userService.getUsers();  // Transaction in service!
    }
}

@Service
public class UserService {
    
    @Transactional(readOnly = true)
    public List<UserDTO> getUsers() {
        List<User> users = userRepo.findAll();
        return users.stream()
            .map(UserDTO::from)
            .collect(Collectors.toList());
        // Connection released after DAO call! ✅
    }
}

Anti-Pattern 3: Dlhé Transakcie

java
// ❌ WRONG!
@Transactional
public void processLargeFile(MultipartFile file) {
    // 1. Parse file (1 minute)
    List<Record> records = parseFile(file);
    
    // 2. Validate (30 seconds)
    validateRecords(records);
    
    // 3. Process (5 minutes)
    records.forEach(this::processRecord);
    
    // Transaction open for 6.5 minutes! 💀
    // Connection held entire time
    // Other requests timeout
}

// ✅ RIGHT!
public void processLargeFile(MultipartFile file) {
    // Parse outside transaction
    List<Record> records = parseFile(file);
    
    // Validate outside transaction
    validateRecords(records);
    
    // Process in batches with separate transactions
    Lists.partition(records, 100).forEach(batch -> {
        processRecordsBatch(batch);  // Own transaction per batch
    });
}

@Transactional
private void processRecordsBatch(List<Record> batch) {
    batch.forEach(recordRepo::save);
    // Short transaction per batch! ✅
}

Anti-Pattern 4: Exception Swallowing

java
// ❌ WRONG!
@Transactional
public void updateUser(User user) {
    try {
        userRepo.save(user);
    } catch (Exception e) {
        log.error("Error", e);
        // Swallow exception → NO rollback! 💀
    }
    
    // Transaction COMMITS even though save failed!
}

// ✅ RIGHT!
@Transactional
public void updateUser(User user) {
    try {
        userRepo.save(user);
    } catch (DataAccessException e) {
        log.error("Error saving user", e);
        throw e;  // Re-throw → rollback!
    }
}

// Or:
@Transactional(rollbackFor = Exception.class)
public void updateUser(User user) {
    // Even checked exceptions trigger rollback
}

Kapitola 7: Best Practices

Practice 1: Narrow Transaction Scope

java
// ✅ Transaction only where needed
public OrderDTO processOrder(OrderDTO dto) {
    // Validation outside transaction
    validateOrder(dto);
    
    // Transaction for DB operations only
    Order order = createOrderInDb(dto);
    
    // External calls outside transaction
    sendNotification(order);
    
    return OrderDTO.from(order);
}

@Transactional
private Order createOrderInDb(OrderDTO dto) {
    Order order = orderRepo.save(new Order(dto));
    inventoryRepo.reserveItems(order.getItems());
    return order;
    // Connection released! ✅
}

Practice 2: Use Read-Only

java
@Transactional(readOnly = true)
public UserDTO getUser(Long id) {
    User user = userRepo.findById(id).orElseThrow();
    return UserDTO.from(user);
    
    // Benefits:
    // - DB can optimize (no flush, read replicas)
    // - Clear intent (documentation)
    // - Fail-fast on writes
}

Practice 3: Explicit Rollback Rules

java
@Transactional(
    rollbackFor = {BusinessException.class},  // Rollback on business errors
    noRollbackFor = {ValidationException.class}  // Don't rollback on validation
)
public void processPayment(Payment payment) {
    if (!isValid(payment)) {
        throw new ValidationException();  // No rollback
    }
    
    if (insufficientFunds(payment)) {
        throw new BusinessException();  // Rollback!
    }
    
    paymentRepo.save(payment);
}

Practice 4: Timeout Guards

java
@Transactional(timeout = 5)  // Max 5 seconds
public void riskyOperation() {
    // Long-running query
    // If exceeds 5s → automatic rollback
}

Practice 5: DTOs Instead of Entities

java
// ❌ WRONG: Return entities
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userService.getUser(id);
    // Problems:
    // - Lazy loading exceptions
    // - Circular references
    // - Over-fetching
}

// ✅ RIGHT: Return DTOs
@GetMapping("/users/{id}")
public UserDTO getUser(@PathVariable Long id) {
    return userService.getUser(id);
    // Benefits:
    // - No lazy loading issues
    // - Controlled data exposure
    // - Clean API contract
}

Kapitola 8: Monitoring & Debugging

Connection Pool Monitoring

java
// application.properties
spring.datasource.hikari.register-mbeans=true

// Metrics to watch:
// - hikaricp.connections.active (should be < max)
// - hikaricp.connections.idle
// - hikaricp.connections.pending (should be 0)
// - hikaricp.connections.timeout (should be 0)

Slow Transaction Detection

java
// Enable logging
logging.level.org.hibernate.transaction=DEBUG
logging.level.org.springframework.transaction=TRACE

// Log slow queries
spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=1000

// Transaction timeout
@Transactional(timeout = 5)

Leak Detection

java
// HikariCP leak detection
spring.datasource.hikari.leak-detection-threshold=60000  // 60s

// If connection held > 60s → Log warning:
// "Connection leak detection triggered, stack trace follows..."

Záver: Decision Tree

java
// Should I use @Transactional?

if (write_operation || multiple_operations) {
    return "@Transactional";
} else if (read_with_lazy_loading) {
    return "@Transactional(readOnly = true)";
} else if (simple_read) {
    return "NO @Transactional (repo has it)";
} else {
    return "NO @Transactional";
}

// Which propagation?

if (standard_business_logic) {
    return "REQUIRED (default)";
} else if (independent_operation_like_audit) {
    return "REQUIRES_NEW";
} else if (partial_rollback_needed) {
    return "NESTED";
} else if (must_be_in_transaction) {
    return "MANDATORY";
} else if (must_not_be_in_transaction) {
    return "NEVER";
}

Key Takeaways:

java
✅ DO:
- Use @Transactional in service layer
- Keep transactions short
- Use readOnly for reads
- Use DTOs to avoid lazy loading
- Monitor connection pool
- Set timeouts

❌ DON'T:
- Global @Transactional
- @Transactional on controllers
- Long-running transactions
- Swallow exceptions
- Rely on default settings
- Mix business logic with DB access

Connection Pool Formula:

connections = ((CPU cores * 2) + disk spindles)

Example:
- 4-core CPU
- SSD (1 spindle)
- Optimal: (4 * 2) + 1 = 9 connections

Set pool size: 10

Článok napísal developer ktorý videl tisíce connection timeout errorov. Globálna @Transactional je cesta do pekla. Don't do it. 💀

P.S.: @Transactional je nástroj, nie default. Use wisely. 🔧

P.P.S.: Connection pool exhaustion = 3AM incident. Trust me. 😴

P.P.P.S.: Lazy loading + eager fetching = nekonečný cyklus. Been there. 🔄

Všetky články