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
// 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
// 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Š
// ✅ 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Š
// ❌ 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)
@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:
// ✅ 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
@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:
// ✅ 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:
// ⚠️ 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
@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:
// ✅ 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:
// ⚠️ NESTED requires SAVEPOINT support
// Supported:
// ✅ PostgreSQL
// ✅ Oracle
// ✅ SQL Server
// ✅ H2
// NOT supported:
// ❌ MySQL (depends on storage engine)
// ❌ Some NoSQL databases
// Check before using!SUPPORTS
@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:
// ✅ 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
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void methodE() {
// ALWAYS runs WITHOUT transaction
// Suspends parent transaction if exists
}Use case:
// ✅ 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
@Transactional(propagation = Propagation.MANDATORY)
public void methodF() {
// MUST be called from existing transaction
// Throws exception if no transaction exists
}Use case:
// ✅ 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
@Transactional(propagation = Propagation.NEVER)
public void methodG() {
// MUST run WITHOUT transaction
// Throws exception if transaction exists
}Use case:
// ✅ 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
// 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
// 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
@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!)
// 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
// 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
// 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
// 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
// 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
@Transactional(timeout = 5) // 5 seconds max
public void riskyOperation() {
// If takes > 5s → rollback automatically
}
// Global configuration:
spring.jpa.properties.hibernate.transaction.timeout=5Solution 4: Connection Pool Tuning
// 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 // 60sKapitola 5: Vnorené Transakcie
Scenario 1: Service volá Service
@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
@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!)
@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
@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)
@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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// ✅ 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
@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
@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
@Transactional(timeout = 5) // Max 5 seconds
public void riskyOperation() {
// Long-running query
// If exceeds 5s → automatic rollback
}Practice 5: DTOs Instead of Entities
// ❌ 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
// 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
// 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
// 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
// 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:
✅ 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 accessConnection 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. 🔄