💳 Section 11 · Question #15

What is the Difference Between REQUIRED and REQUIRES_NEW

When an inner method annotated with REQUIRED is invoked from an active transactional context: 4. When the inner method returns, commit() is not called—the decision to commit is...


🟢 Junior Level

REQUIRED and REQUIRES_NEW are the two foundational transaction propagation modes in Spring Framework. Their defining difference lies in whether an existing physical database transaction is reused or a new, independent physical transaction is created.

The Core Concept in 30 Seconds

  • REQUIRED (Default): “If a transaction already exists, I will join it. If none exists, I will open a new one.” All operations share a single database connection and are finalized by a single unified COMMIT. If any method fails, the entire chain rolls back.
  • REQUIRES_NEW: “I do not care about any surrounding transaction. I always demand my own independent transaction.” The active transaction is temporarily suspended, a second physical connection is checked out from the pool, and the work is committed immediately upon exiting the method. Even if the surrounding transaction later aborts, data committed by REQUIRES_NEW remains permanently saved in the database.

Execution Timeline: Order Creation with Audit Logging

Scenario: Order Creation with Audit Log

[ REQUIRED (Default) ]
createOrder() ------------[ START TRANSACTION ] (Connection #1)
  ├── saveOrder()         │ (insert order)
  ├── auditLog()          │ (insert audit log on the SAME connection)
  └── throw Exception!    │
--------------------------[ ROLLBACK ] (Both order AND audit log are rolled back!)

[ REQUIRES_NEW for Audit ]
createOrder() ------------[ START TX #1 ] (Connection #1)
  ├── saveOrder()         │
  ├── auditLog() -------- │ ---[ START TX #2 ] (Connection #2, TX #1 suspended)
                          │    ├── saveAudit()
                          │ ---[ COMMIT TX #2 ] (Audit log is permanently saved!)
  └── throw Exception!    │
--------------------------[ ROLLBACK TX #1 ] (Order rolled back, but audit log remains!)

Comprehensive Comparison

Characteristic Propagation.REQUIRED Propagation.REQUIRES_NEW
New Transaction Creation Only if no transaction exists in caller Always (100% of invocations)
Physical DB Connections 1 connection from pool 2 concurrent connections per thread
Suspension of Current Tx None Yes (suspend outer, resume after inner completes)
Independent COMMIT No (single unified commit at the end) Yes (commits immediately on inner method exit)
Inner Exception Impact Marks the entire chain as rollback-only Rolls back only the inner transaction
Outer Rollback Impact Rolls back all inner and outer mutations Has zero impact on already-committed REQUIRES_NEW
Typical Production Scenario Core business logic, balance transfers, orders Security audit trails, dead-letter logs, billing attempts

Practical Spring Boot Example

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final SecurityAuditService auditService;

    public OrderService(OrderRepository orderRepository, SecurityAuditService auditService) {
        this.orderRepository = orderRepository;
        this.auditService = auditService;
    }

    @Transactional // REQUIRED by default
    public void registerOrder(Order order) {
        orderRepository.save(order);

        // Audit log must persist even if subsequent business logic fails:
        auditService.logAttempt("Attempting creation of order #" + order.getId());

        if (order.getItems().isEmpty()) {
            // Throwing an exception rolls back the order insertion,
            // but the audit record from logAttempt has ALREADY committed to the DB!
            throw new IllegalArgumentException("Order items cannot be empty");
        }
    }
}
@Service
public class SecurityAuditService {

    private final AuditRecordRepository auditRepository;

    public SecurityAuditService(AuditRecordRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 3)
    public void logAttempt(String message) {
        auditRepository.save(new AuditRecord(message));
        // Physical database COMMIT occurs at the instant this method finishes!
    }
}

🟡 Middle Level

Spring Framework Under-the-Hood Mechanics

1. How REQUIRED Joins an Existing Transaction

When an inner method annotated with REQUIRED is invoked from an active transactional context:

  1. TransactionInterceptor consults TransactionSynchronizationManager and detects an active ConnectionHolder.
  2. No new database transaction is created. Spring instantiates a TransactionStatus with isNewTransaction() == false.
  3. All repository statements execute across the existing JDBC connection.
  4. When the inner method returns, commit() is not called—the decision to commit is delegated to the outermost boundary (isNewTransaction() == true).
  5. The Rollback-Only Trap: If the inner method throws an unhandled RuntimeException, the interceptor marks the shared physical transaction as status.setRollbackOnly(). Even if the parent catches the error in try-catch, the outer commit will fail.

2. How REQUIRES_NEW Suspends and Opens a New Connection

  1. Spring unbinds the current connection from the thread: TransactionSynchronizationManager.unbindResource(dataSource).
  2. It suspends the active Hibernate session and transaction callbacks via TransactionSynchronization.suspend().
  3. It packages the active state into a SuspendedResourcesHolder.
  4. It checks out a second physical connection from the HikariCP pool and executes connection2.setAutoCommit(false).
  5. It binds the new connection to the active thread’s ThreadLocal.
  6. It executes the inner method.
  7. Upon successful return, it immediately executes connection2.commit() and closes/returns the second connection to the pool.
  8. In a finally block, Spring invokes resume(), restoring the SuspendedResourcesHolder and re-binding the original connection to the thread.

🔴 Senior Level

The Single-Thread Row Lock Self-Deadlock Hazard

A catastrophic mistake when using REQUIRES_NEW is attempting to modify the same row in both the outer and inner transactions:

The Single-Thread Self-Deadlock Scenario:
Tomcat Worker Thread T1:
1. outerMethod() [REQUIRED]:
   UPDATE accounts SET balance = balance - 100 WHERE id = 1;
   ──► Database places an exclusive row lock (X-lock) on row id = 1 in Connection #1.

2. outerMethod() invokes innerMethod() [REQUIRES_NEW]:
   ──► Spring suspends Connection #1.
   ──► Spring checks out Connection #2 in the SAME worker thread T1.

3. innerMethod() [REQUIRES_NEW] executes:
   UPDATE accounts SET status = 'VIP' WHERE id = 1;
   ──► Connection #2 requests an X-lock on row id = 1 in the database.
   ──► The database detects that row id = 1 is already locked by Connection #1,
       so it PAUSES Connection #2, waiting for the lock to release!

FATAL SINGLE-THREAD DEADLOCK:
• Connection #2 waits indefinitely for Connection #1 to commit and release the lock.
• Connection #1 can NEVER commit because its thread T1 is blocked waiting for Connection #2 to return!
• The thread hangs until lock_timeout (PostgreSQL) or innodb_lock_wait_timeout (MySQL) aborts the statement!

HikariCP Connection Pool Exhaustion Deadlock

Because REQUIRES_NEW holds the parent connection in suspended state while checking out a second connection:

HikariCP maximum-pool-size = 10
10 concurrent HTTP worker threads arrive simultaneously:

Each thread (1..10) executes outerMethod() [REQUIRED]:
──► All 10 threads acquire Connection #1 from the pool.
──► Available connections in HikariCP: 0!

Each thread (1..10) then executes innerMethod() [REQUIRES_NEW]:
──► All 10 threads simultaneously request a second connection from HikariCP.
──► The pool is empty! All 10 threads block in the HikariCP queue.
──► No thread can finish outerMethod() to return its first connection because
    all threads are waiting for a second connection.
──► RESULT: Complete application deadlock until HikariCP throws:
    SQLTransientConnectionException: Connection is not available, request timed out after 30000ms.

Production Architectural Countermeasures:

  1. Capacity Sizing: $\text{PoolSize} \ge \text{ConcurrentThreads} \times 2 + \text{SafetyMargin}$.
  2. Dedicated Audit Pool (Multi-DataSource): Route audit repositories with REQUIRES_NEW to an isolated secondary connection pool.
  3. Mandatory Timeouts: Always declare a short timeout: @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 3).
  4. Transition to Transactional Events (Decoupling): Replace synchronous REQUIRES_NEW with @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) so the audit executes strictly after the parent connection has returned to the pool.

Hibernate L1 Cache Visibility Gap

Because REQUIRES_NEW executes in a completely separate physical database connection:

  • If the outer transaction modifies an entity in memory, but Hibernate has not executed a flush(), the inner method cannot see those mutations.
  • Even after entityManager.flush(), the inner method running under Read Committed cannot read uncommitted data written by the outer connection (Dirty Read prevention). findById() in the inner method will return the pre-transaction database state.
  • Conversely, mutations committed by the inner method do not automatically reflect in the outer transaction’s Hibernate L1 cache, leading to potential stale reads in the parent method.

4 Tricky Questions

1. What happens if a method annotated with REQUIRES_NEW attempts to UPDATE the same row updated by the outer REQUIRED method?

Answer: The thread hangs and ultimately crashes with a database lock wait timeout exception:

  • In PostgreSQL: ERROR: canceling statement due to lock timeout.
  • In MySQL InnoDB: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction. Underlying Cause: The outer transaction acquired an exclusive row lock (X-lock) on the row and was suspended. The inner REQUIRES_NEW transaction runs on a separate physical database connection on the same JVM thread and requests an X-lock on that identical row. Because Connection #2 waits for Connection #1 to release the lock, and Connection #1 cannot resume until Connection #2 finishes, the thread deadlocks against itself.

2. Why does catching an exception from REQUIRES_NEW allow the outer transaction to commit, whereas catching an exception from REQUIRED causes an UnexpectedRollbackException?

Answer:

  • Under REQUIRED: Both methods share a single TransactionStatus. When an exception escapes the inner method, the inner TransactionInterceptor unconditionally marks the shared status as setRollbackOnly(). When the parent catches the exception and attempts to commit, Spring inspects the rollback-only flag, aborts the commit, and throws UnexpectedRollbackException.
  • Under REQUIRES_NEW: The inner method has its own independent TransactionStatus (isNewTransaction() == true). When it throws an exception, the interceptor rolls back only that inner physical transaction. The parent transaction’s status remains clean. The parent method can catch the exception in try-catch and commit its own work successfully.

3. Can a method with Propagation.REQUIRES_NEW read an entity saved via repository.save() in the parent REQUIRED method immediately prior to the call?

Answer: No, by default it cannot! Two barriers prevent this:

  1. Delayed Flush in Hibernate: Calling repository.save() simply associates the entity with the current PersistenceContext in JVM memory. Unless entityManager.flush() is explicitly invoked, no SQL INSERT is sent to the database.
  2. Transaction Isolation: Even if flush() is executed, the newly inserted row is uncommitted inside Connection #1. Because Connection #2 (REQUIRES_NEW) operates under Read Committed (or higher), it cannot read uncommitted data from Connection #1. findById() in the inner method will return Optional.empty().

4. How can synchronous audit logging with REQUIRES_NEW be refactored using Spring Events to eliminate connection pool deadlocks?

Answer: By replacing direct method calls with Spring’s @TransactionalEventListener configured with TransactionPhase.AFTER_COMPLETION:

// Inside the business service:
@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);
    // Publish a domain event instead of calling an audit service directly:
    eventPublisher.publishEvent(new OrderProcessedEvent(order.getId()));
}

// Inside the audit listener component:
@Component
public class AuditEventListener {

    private final AuditRepository auditRepository;

    public AuditEventListener(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    // Fires strictly AFTER the outer transaction has committed or rolled back,
    // when Connection #1 has ALREADY been released back to the HikariCP pool!
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void onOrderProcessed(OrderProcessedEvent event) {
        auditRepository.save(new AuditLog("Order #" + event.orderId() + " processed"));
    }
}

This guarantees that worker threads never hold two connection pool connections at the same time.


🎯 Interview Cheat Sheet

  • Core Difference: REQUIRED joins an existing transaction (1 connection, unified commit); REQUIRES_NEW always creates a new physical transaction (2 concurrent connections, independent commit).
  • Rollback Behavior:
    • REQUIRED: Inner failure marks the entire transaction rollback-only (UnexpectedRollbackException on outer commit).
    • REQUIRES_NEW: Inner failure rolls back only the inner transaction; outer method can catch and commit cleanly.
  • Connection Pool Exhaustion: REQUIRES_NEW checks out 2 connections per thread simultaneously; insufficient pool sizing causes complete JVM connection deadlocks.
  • Self-Deadlock Hazard: Updating the same row in REQUIRED and an inner REQUIRES_NEW causes an unresolvable single-thread lock timeout.
  • Best Practice for Audit: Decouple audit writes using @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) instead of synchronous REQUIRES_NEW.