Command Palette

Search for a command to run...

[Advanced Spring Boot] Spring Transactions in Depth: Propagation, Isolation Levels and Rollback Rules

Basics article 30 covered one @Transactional method: what a transaction buys, the proxy that begins and ends it, the rollback rules for unchecked and checked exceptions, readOnly, self-invocation and the rollback-only trap. This article is about what starts when there are two transactional methods, or two transactions at once: what an inner method does with the transaction it is called in (propagation), what one transaction sees of another running at the same time (isolation), and how readOnly, rollback rules and timeouts behave across those boundaries.

Transaction advice is unusually easy to get wrong, because most of it was true for some database and some transaction manager. This article uses Spring Boot 4.1.1, Java 21 and PostgreSQL 18, with the web app on port 8206. Timings come from single runs, each given with the 1-minute load average at the time, and are only indicative.

Two connection lanes: an outer transaction pauses while a REQUIRES_NEW transaction runs on a second connection, then resumes

The first section builds a small lab; then come the seven propagations, the connection-pool failure REQUIRES_NEW causes under load, isolation levels on PostgreSQL, and the parts of readOnly, rollback rules and timeouts that only show up across transaction boundaries.

The lab: an outer service, an inner service and a probe

Accounts, an audit table and the logging

Two tables, created by Flyway and validated by Hibernate:

src/main/resources/db/migration/V1__accounts_and_audit.sql
create table accounts (
    id      bigint generated by default as identity primary key,
    owner   varchar(100)   not null,
    kind    varchar(20)    not null,
    balance numeric(12, 2) not null
);
 
create table audit_entries (
    id      bigint generated by default as identity primary key,
    message varchar(200) not null
);
 
insert into accounts (owner, kind, balance) values
    ('an', 'CHECKING', 100.00),
    ('an', 'SAVINGS', 100.00),
    ('binh', 'CHECKING', 100.00);
src/main/resources/application.properties
spring.application.name=demo
spring.datasource.url=jdbc:postgresql://localhost:5506/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.jdbc.support.JdbcTransactionManager=DEBUG
Bash
docker run -d --name sba-a6-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5506:5432 postgres:18

Account is an ordinary entity in the account package with withdraw(BigDecimal) and deposit(BigDecimal) methods. Its repository adds three queries the later sections use:

src/main/java/com/example/demo/account/AccountRepository.java
public interface AccountRepository extends JpaRepository<Account, Long> {
 
    @Query("select sum(a.balance) from Account a where a.owner = :owner")
    BigDecimal totalBalance(String owner);
 
    @Query("select a.balance from Account a where a.id = :id")
    BigDecimal balanceOf(Long id);
 
    // a stand-in for a slow report query
    @Query(value = "select count(*) from pg_sleep(:seconds)", nativeQuery = true)
    long slowReport(int seconds);
}

A probe that shows the transaction and the connection

A log line that says "a transaction is active" is not enough to tell propagations apart. TxProbe also asks PostgreSQL which backend process serves the current connection, pg_backend_pid(), and asks HikariCP how many connections are checked out:

src/main/java/com/example/demo/support/TxProbe.java
@Component
public class TxProbe {
 
    private final JdbcClient jdbc;
    private final HikariDataSource dataSource;
 
    TxProbe(JdbcClient jdbc, HikariDataSource dataSource) {
        this.jdbc = jdbc;
        this.dataSource = dataSource;
    }
 
    public String describe() {
        boolean active = TransactionSynchronizationManager.isActualTransactionActive();
        String name = shortName(TransactionSynchronizationManager.getCurrentTransactionName());
        Integer pid = jdbc.sql("select pg_backend_pid()").query(Integer.class).single();
        int busy = dataSource.getHikariPoolMXBean().getActiveConnections();
        return "actualTx=%s name=%s pid=%d hikariActive=%d".formatted(active, name, pid, busy);
    }
 
    // com.example.demo.audit.AuditService.requiresNew -> AuditService.requiresNew
    private static String shortName(String name) {
        return name == null ? null : name.substring(name.lastIndexOf('.', name.lastIndexOf('.') - 1) + 1);
    }
}

The same pid means the same physical connection; a different pid means a second one.

The inner service: one method per propagation

AuditService writes one audit row per call. Its methods are identical except for the propagation, and each throws when fail is true:

src/main/java/com/example/demo/audit/AuditService.java
@Service
public class AuditService {
 
    private static final Logger log = LoggerFactory.getLogger(AuditService.class);
 
    private final JdbcClient jdbc;
    private final TxProbe probe;
 
    AuditService(JdbcClient jdbc, TxProbe probe) {
        this.jdbc = jdbc;
        this.probe = probe;
    }
 
    @Transactional(propagation = Propagation.REQUIRED)
    public void required(String message, boolean fail) {
        record("REQUIRED", message, fail);
    }
 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void requiresNew(String message, boolean fail) {
        record("REQUIRES_NEW", message, fail);
    }
 
    @Transactional(propagation = Propagation.SUPPORTS)
    public void supports(String message, boolean fail) {
        record("SUPPORTS", message, fail);
    }
 
    @Transactional(propagation = Propagation.MANDATORY)
    public void mandatory(String message, boolean fail) {
        record("MANDATORY", message, fail);
    }
 
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void notSupported(String message, boolean fail) {
        record("NOT_SUPPORTED", message, fail);
    }
 
    @Transactional(propagation = Propagation.NEVER)
    public void never(String message, boolean fail) {
        record("NEVER", message, fail);
    }
 
    @Transactional(propagation = Propagation.NESTED)
    public void nested(String message, boolean fail) {
        record("NESTED", message, fail);
    }
 
    @Transactional(noRollbackFor = AuditFailedException.class)
    public void requiredNoRollback(String message, boolean fail) {
        record("REQUIRED, noRollbackFor", message, fail);
    }
 
    private void record(String propagation, String message, boolean fail) {
        log.info("{}: {}", propagation, probe.describe());
        jdbc.sql("insert into audit_entries (message) values (?)").param(message).update();
        if (fail) {
            throw new AuditFailedException(message);
        }
    }
}

AuditFailedException extends RuntimeException. The audit row goes through JdbcClient, which joins the JPA transaction on the same connection, as Basics article 30 showed; that keeps the log free of repository transactions and makes the pid comparison direct.

The outer service and the runner

src/main/java/com/example/demo/lab/OuterService.java
@Component
@Profile("lab")
public class OuterService {
 
    private static final Logger log = LoggerFactory.getLogger(OuterService.class);
 
    private final JdbcClient jdbc;
    private final TxProbe probe;
 
    OuterService(JdbcClient jdbc, TxProbe probe) {
        this.jdbc = jdbc;
        this.probe = probe;
    }
 
    @Transactional
    public void inTransaction(String label, Runnable inner) {
        jdbc.sql("insert into audit_entries (message) values (?)").param("outer " + label).update();
        log.info("outer: {}", probe.describe());
        inner.run();
        log.info("outer, after inner: {}", probe.describe());
    }
 
    // catching(...) and failingAfter(...) are shown in the rollback section
}

A lab profile runner calls each propagation twice, inside inTransaction and on its own. Before each step it empties audit_entries; after it, it prints the rows that were committed:

src/main/java/com/example/demo/lab/LabRunner.java
        step("REQUIRES_NEW, inside a transaction", () -> outer.inTransaction("requires-new", () -> audit.requiresNew("requires-new", false)));
        step("REQUIRES_NEW, no transaction", () -> audit.requiresNew("requires-new", false));
src/main/java/com/example/demo/lab/LabRunner.java
    void step(String name, Runnable body) {
        jdbc.sql("delete from audit_entries").update();
        log.info("=== {} ===", name);
        try {
            body.run();
        } catch (RuntimeException e) {
            chain(e);
        }
        List<String> rows = jdbc.sql("select message from audit_entries order by id").query(String.class).list();
        log.info("committed audit rows: {}", rows);
    }
Bash
./gradlew -q bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=lab --spring.main.web-application-type=none --lab=propagation '--logging.pattern.console=%logger{0}: %msg%n'

The single-threaded labs log with %logger{0}: %msg%n; the concurrent ones later add [%thread].

Propagation: what the inner method does with the outer transaction

Propagation is the attribute the transaction manager reads each time a transactional method is entered: is a transaction already bound to this thread, and if so, join it, suspend it, refuse it or nest inside it? JpaTransactionManager logs the branch it took, and the probe shows what that meant for the connection.

REQUIRED: join on the same connection

The default. Every excerpt in this article leaves out the Found thread-bound EntityManager [...] for JPA transaction line that JpaTransactionManager logs before each decision about an existing transaction; other removals are noted. The first step:

Text
Lab: === REQUIRED, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(659416252<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5066a895]
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Participating in existing transaction
AuditService: REQUIRED: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
OuterService: outer, after inner: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(659416252<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
Lab: committed audit rows: [outer required, required]

The inner method ran in the outer transaction, named after the outer method, on backend pid 437 with one connection checked out, and both rows committed together. Called on its own, the same method began its own transaction: Creating new transaction with name [com.example.demo.audit.AuditService.required]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT, again on pid 437.

Sharing one transaction also means sharing its fate. A RuntimeException that leaves the inner method marks the shared transaction rollback-only even when the outer method catches it, which is the UnexpectedRollbackException trap of Basics article 30; the rollback section below shows the one attribute that avoids the mark.

REQUIRES_NEW: suspend, then take a second connection

Text
Lab: === REQUIRES_NEW, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(662238161<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@30a34f07]
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
JpaTransactionManager: Opened new EntityManager [SessionImpl(1378219538<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1d3aefd]
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1378219538<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer, after inner: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(662238161<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
Lab: committed audit rows: [outer requires-new, requires-new]

A second EntityManager, a second backend (pid 438) and hikariActive=2 while the inner transaction ran; the inner one committed first, then the outer resumed on pid 437. Suspending unbinds the outer transaction's resources from the thread, it does not return its connection to the pool: during the inner transaction the outer connection sits checked out and idle. The next section turns that into a production failure. Called with no transaction, requiresNew simply began one, PROPAGATION_REQUIRES_NEW,ISOLATION_DEFAULT, on pid 437 with hikariActive=1.

Suspension covers more than the connection. The outer transaction's TransactionSynchronization callbacks, the mechanism under @TransactionalEventListener from Advanced article 4, are suspended with it. OuterService.withSynchronization registers one that logs its suspend, resume and afterCommit callbacks, then calls requiresNew; trimmed:

Text
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
OuterService: outer synchronization: suspend
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1903221272<open>)]
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer synchronization: resume
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(499755936<open>)]
OuterService: outer synchronization: afterCommit

The inner commit did not fire the outer's afterCommit; only the outer commit did.

SUPPORTS, MANDATORY and NEVER: conditions on the caller

These three never create a transaction. Inside inTransaction, SUPPORTS and MANDATORY logged Participating in existing transaction and ran on pid 437 exactly like REQUIRED. The differences are all in the other column.

SUPPORTS with no transaction produced no JpaTransactionManager line at all:

Text
Lab: === SUPPORTS, no transaction ===
AuditService: SUPPORTS: actualTx=false name=AuditService.supports pid=437 hikariActive=1
Lab: committed audit rows: [supports]

actualTx=false: the insert ran in auto-commit. The transaction name is still set, because Spring opens a synchronization scope for the method even without a transaction, and hikariActive=1 shows the connection the probe's query borrowed still checked out after the query: with synchronization active, Spring keeps it bound to the method's scope.

MANDATORY with no transaction, and NEVER inside one, trimmed:

Text
Lab: === MANDATORY, no transaction ===
Lab: threw org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'
Lab: committed audit rows: []
Lab: === NEVER, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(413705230<open>)]
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never'
Lab: committed audit rows: []

Both refused before the method body ran. With NEVER the exception is a RuntimeException leaving inTransaction, so the outer transaction rolled back its own row too. NEVER with no transaction ran like SUPPORTS without one: actualTx=false, pid 437.

NOT_SUPPORTED: suspend, and still take a second connection

The middle of the step inside inTransaction:

Text
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Suspending current transaction
AuditService: NOT_SUPPORTED: actualTx=false name=AuditService.notSupported pid=438 hikariActive=2
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer, after inner: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1

The inner method ran without a transaction, as intended, but on pid 438 with two connections checked out. The suspended outer transaction kept pid 437, so any database access in a NOT_SUPPORTED method costs a second connection exactly like REQUIRES_NEW. With no outer transaction it behaved like SUPPORTS: actualTx=false, pid 437.

NESTED: savepoints, and why JpaTransactionManager refuses them

NESTED is meant to run the inner method inside a savepoint of the outer transaction, so that a failure rolls back only the inner part. With the JpaTransactionManager Spring Boot configures for JPA:

Text
Lab: === NESTED, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction rollback
Lab: threw org.springframework.transaction.NestedTransactionNotSupportedException: Transaction manager does not allow nested transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
Lab: committed audit rows: []

Trimmed to the transaction lines and the result. The flag can be set through a TransactionManagerCustomizer, the Boot 4 hook that JpaBaseConfiguration applies to the JpaTransactionManager it creates. The lab switches it on with --lab.nested-allowed=true:

src/main/java/com/example/demo/lab/LabTransactionConfig.java
@Configuration
class LabTransactionConfig {
 
    @Bean
    @ConditionalOnBooleanProperty("lab.nested-allowed")
    TransactionManagerCustomizer<AbstractPlatformTransactionManager> allowNested() {
        return transactionManager -> transactionManager.setNestedTransactionAllowed(true);
    }
 
    @Bean
    @ConditionalOnBooleanProperty("lab.validate-existing")
    TransactionManagerCustomizer<AbstractPlatformTransactionManager> validateExisting() {
        return transactionManager -> transactionManager.setValidateExistingTransaction(true);
    }
}

The same call with the flag on, this time from OuterService.catching, which catches AuditFailedException (shown in the rollback section), trimmed:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.catching]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Creating nested transaction with name [com.example.demo.audit.AuditService.nested]
JpaTransactionManager: Initiating transaction rollback
Lab: threw org.springframework.transaction.NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities
Lab: committed audit rows: []

The manager got one step further and failed on the savepoint. JpaTransactionManager creates savepoints through a SavepointManager that the JpaDialect must supply, and HibernateJpaDialect in spring-orm 7.0.9 does not supply one. The Javadoc of JpaTransactionManager gives the reason: a savepoint would only roll back the JDBC connection, not the EntityManager and the entities it has already loaded and changed. NestedTransactionNotSupportedException is not an AuditFailedException, so catching did not catch it and the whole outer transaction rolled back. With no outer transaction, NESTED behaved like REQUIRED: Creating new transaction with name [com.example.demo.audit.AuditService.nested]: PROPAGATION_NESTED,ISOLATION_DEFAULT.

A plain JDBC transaction manager does what NESTED promises. DataSourceTransactionManager, and its Boot default subclass JdbcTransactionManager, allows nested transactions out of the box. The lab builds one by hand on the same DataSource, so that it does not replace the application's JPA manager:

src/main/java/com/example/demo/lab/NestedLab.java
    public void jdbcNested() {
        JdbcTransactionManager tm = new JdbcTransactionManager(dataSource);
        TransactionTemplate outer = new TransactionTemplate(tm);
        TransactionTemplate nested = new TransactionTemplate(tm);
        nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED);
        outer.executeWithoutResult(status -> {
            jdbc.sql("insert into audit_entries (message) values ('outer jdbc')").update();
            try {
                nested.executeWithoutResult(inner -> {
                    jdbc.sql("insert into audit_entries (message) values ('nested jdbc')").update();
                    throw new AuditFailedException("nested jdbc");
                });
            } catch (AuditFailedException e) {
                log.info("outer caught: {}", e.getMessage());
            }
        });
    }
Text
JdbcTransactionManager: Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JdbcTransactionManager: Acquired Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657] for JDBC transaction
JdbcTransactionManager: Switching JDBC Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657] to manual commit
JdbcTransactionManager: Creating nested transaction with name [null]
JdbcTransactionManager: Rolling back transaction to savepoint
NestedLab: outer caught: audit failed: nested jdbc
JdbcTransactionManager: Initiating transaction commit
JdbcTransactionManager: Committing JDBC transaction on Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657]
JdbcTransactionManager: Releasing JDBC Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657] after transaction
Lab: committed audit rows: [outer jdbc]

The outer row committed and the nested row did not. What reached PostgreSQL was read from its statement log, switched on for the run and read with docker logs:

Bash
docker exec sba-a6-pg psql -U demo -d demo -c "alter system set log_statement = 'all'" -c "select pg_reload_conf()"
Text
[418] LOG:  execute <unnamed>: insert into audit_entries (message) values ('outer jdbc')
[418] LOG:  execute <unnamed>: SAVEPOINT "SAVEPOINT_1"
[418] LOG:  execute <unnamed>: insert into audit_entries (message) values ('nested jdbc')
[418] LOG:  execute <unnamed>: ROLLBACK TO SAVEPOINT "SAVEPOINT_1"
[418] LOG:  execute <unnamed>: RELEASE SAVEPOINT "SAVEPOINT_1"
[418] LOG:  execute S_1: COMMIT

Timestamps are removed from the PostgreSQL log lines here and below. In a JPA application, a part that must fail on its own either gets its own transaction with REQUIRES_NEW, with the connection cost measured above, or is checked before it is attempted, as Basics article 30 recommended.

The seven propagations side by side

Every cell is one of the runs above, with JpaTransactionManager unless it says otherwise.

PropagationCalled inside a transactionCalled with no transactionTypical use
REQUIRED (default)joins: Participating in existing transaction, same pidcreates onealmost every service method
REQUIRES_NEWsuspends it and creates a new one on a second connectioncreates onea write that must commit or roll back on its own, such as an audit row
SUPPORTSjoinsruns without one, actualTx=falsereads that work either way
MANDATORYjoinsIllegalTransactionStateExceptioncode that must never be the transaction boundary
NOT_SUPPORTEDsuspends it, runs without one, on a second connection if it touches the databaseruns without onecode that must not join the caller's transaction
NEVERIllegalTransactionStateExceptionruns without onea guard for code that must never run in a transaction
NESTEDNestedTransactionNotSupportedException; a savepoint with JdbcTransactionManagercreates onepartial rollback in JDBC code

Seven rows, one per propagation, each drawn as connection lanes: called inside a transaction, REQUIRED, SUPPORTS and MANDATORY join on pid 437; REQUIRES_NEW and NOT_SUPPORTED suspend the outer transaction and run on pid 438 with two connections checked out; NEVER and NESTED throw; called without one, REQUIRED, REQUIRES_NEW and NESTED create a transaction, SUPPORTS, NOT_SUPPORTED and NEVER run without one and MANDATORY throws

The REQUIRES_NEW connection pool deadlock

A withdrawal that audits in its own transaction

The classic reason to use REQUIRES_NEW is an audit row that must survive when the business change rolls back. Put behind an endpoint:

src/main/java/com/example/demo/account/AccountService.java
@Service
public class AccountService {
 
    private final AccountRepository accounts;
    private final AuditService audit;
 
    AccountService(AccountRepository accounts, AuditService audit) {
        this.accounts = accounts;
        this.audit = audit;
    }
 
    @Transactional
    public BigDecimal withdraw(Long id, BigDecimal amount) {
        Account account = accounts.findById(id).orElseThrow();
        account.withdraw(amount);
        audit.requiresNew("withdraw " + amount + " from account " + id, false);
        return account.getBalance();
    }
}
src/main/java/com/example/demo/account/AccountController.java
    @PostMapping("/api/accounts/{id}/withdrawals")
    BigDecimal withdraw(@PathVariable Long id, @RequestParam BigDecimal amount) {
        return service.withdraw(id, amount);
    }

Each request holds one connection for withdraw and needs a second one for requiresNew before it can give the first back. The application ran with HikariCP's defaults, which its DEBUG log confirmed as maximumPoolSize.................10 and connectionTimeout...............30000, and with the SQL and transaction logs turned down for the load test. Apache Bench sent 2,000 withdrawals at a fixed concurrency (empty.txt is an empty request body):

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8206 --logging.level.com.zaxxer.hikari.HikariConfig=DEBUG --logging.level.org.springframework.orm.jpa.JpaTransactionManager=INFO --logging.level.org.hibernate.SQL=INFO
Bash
ab -n 2000 -c 10 -s 120 -p empty.txt -T application/x-www-form-urlencoded 'http://localhost:8206/api/accounts/3/withdrawals?amount=0.01'

With 9 concurrent clients:

Text
Concurrency Level:      9
Time taken for tests:   1.602 seconds
Complete requests:      2000
Failed requests:        0

With 10:

Text
Concurrency Level:      10
Time taken for tests:   32.814 seconds
Complete requests:      2000
Failed requests:        10
   (Connect: 0, Receive: 0, Length: 10, Exceptions: 0)
Non-2xx responses:      10

One more client turned 1.6 seconds into 32.8 (load averages 4.18 and 4.00), and 10 requests failed; the longest took 30,075 ms. How many times the pool locks up in a run is a matter of timing: the same command repeated later, at load average 1.58, deadlocked it three times, failing 26 requests in 91.1 seconds, while -c 9 again failed none. Each failed request logged the HikariCP timeout and the exception that reached the servlet, here with the log prefixes removed:

Text
HikariPool-1 - Connection is not available, request timed out after 30006ms (total=10, active=10, idle=0, waiting=1)
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30006ms (total=10, active=10, idle=0, waiting=1)

Two requests, two connections, nobody finishes

The same endpoint with --spring.datasource.hikari.maximum-pool-size=2 --spring.datasource.hikari.connection-timeout=5000 and ab -n 20 -c 2 failed 9 of 20 requests and took 25.2 seconds for 20 withdrawals (load average 2.99). The log of the first deadlock, logged with [%thread] %logger{0}: %msg%n and trimmed to the transaction lines and the pool errors:

Text
[http-nio-8206-exec-2] JpaTransactionManager: Creating new transaction with name [com.example.demo.account.AccountService.withdraw]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[http-nio-8206-exec-2] JpaTransactionManager: Opened new EntityManager [SessionImpl(1827638077<open>)] for JPA transaction
[http-nio-8206-exec-3] JpaTransactionManager: Creating new transaction with name [com.example.demo.account.AccountService.withdraw]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[http-nio-8206-exec-3] JpaTransactionManager: Opened new EntityManager [SessionImpl(230918494<open>)] for JPA transaction
[http-nio-8206-exec-2] JpaTransactionManager: Participating in existing transaction
[http-nio-8206-exec-3] JpaTransactionManager: Participating in existing transaction
[http-nio-8206-exec-3] JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
[http-nio-8206-exec-2] JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
[http-nio-8206-exec-3] JpaTransactionManager: Opened new EntityManager [SessionImpl(1111063725<open>)] for JPA transaction
[http-nio-8206-exec-2] JpaTransactionManager: Opened new EntityManager [SessionImpl(588604139<open>)] for JPA transaction
[http-nio-8206-exec-2] error: HikariPool-1 - Connection is not available, request timed out after 5006ms (total=2, active=2, idle=0, waiting=0)
[http-nio-8206-exec-3] error: HikariPool-1 - Connection is not available, request timed out after 5006ms (total=2, active=2, idle=0, waiting=0)
[http-nio-8206-exec-2] JpaTransactionManager: Initiating transaction rollback
[http-nio-8206-exec-3] JpaTransactionManager: Initiating transaction rollback
[http-nio-8206-exec-2] JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1827638077<open>)]
[http-nio-8206-exec-3] JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(230918494<open>)]

Both threads began withdraw and took one connection each: the pool of two was empty. Both then suspended their transaction for requiresNew and waited for a second connection, which could only come from the other thread, which was waiting too. Nothing moved for 5 seconds, then both inner transactions failed to begin, and the CannotCreateTransactionException rolled back both withdrawals. The SessionImpl numbers in the rollback lines are those of the outer transactions. A pool of one is the degenerate case: a single request, alone, got HTTP/1.1 500 after 2.12 seconds with connection-timeout=2000 (load average 2.78; Flyway was switched off for that run, because with one connection it could not get the second it needs at startup and the application failed to start).

Ten request threads on a 30-second timeline, each holding one pooled connection for its outer withdraw transaction and waiting for a second one for REQUIRES_NEW; the HikariCP pool shows total 10, active 10, idle 0; all ten waits end after 30 seconds with Connection is not available, request timed out after 30006ms, and CannotCreateTransactionException; below, the measured runs: 9 concurrent requests 0 failures in 1.6 s, 10 concurrent requests 10 failures in 32.8 s, pool 11 with 10 concurrent requests 0 failures

The arithmetic, and the fixes

A request whose transactions nest d deep holds up to d connections at once. With n requests in flight and a pool of m, the pool is guaranteed never to deadlock when m ≥ n × (d − 1) + 1, the formula HikariCP's "About Pool Sizing" page gives for exactly this situation: then at least one request can always get all the connections it needs, finish, and free them. Here d = 2, so 10 concurrent requests need 11 connections. Measured with maximum-pool-size=11 and -c 10, then maximum-pool-size=21 and -c 20:

PoolConcurrent requestsFailed of 2,000TimeLoad average
10 (default)901.602 s4.18
10 (default)101032.814 s4.00
111001.672 s5.52
111101.481 s5.52
212001.674 s2.94

The fourth row is the reason this failure reaches production: at n = m a deadlock needs every connection to be taken by an outer transaction at the same moment, so a run can pass, as this one did, and the next one fail. Spring Boot's Tomcat processes up to 200 requests at once (server.tomcat.threads.max, default 200), far more than 10 connections.

  • Do not hold a connection while waiting for another. Call the REQUIRES_NEW work before the outer transaction begins or after it has returned, from a caller that is not transactional. An AFTER_COMMIT listener is not that place: OuterService.writeAfterCommit registers a synchronization whose afterCommit calls requiresNew, and the probe logged REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=584 hikariActive=2 after Committing JPA transaction and before the outer Closing JPA EntityManager after transaction. The committed outer transaction still held its connection.
  • If the audit row may roll back with the business change, drop REQUIRES_NEW and let it join; one connection per request.
  • Size the pool with the formula when nesting is unavoidable, and remember that NOT_SUPPORTED with database access counts as a second connection too.
  • Lower connection-timeout so a deadlock fails in a second instead of holding every thread for 30. It does not remove the deadlock, it shortens it.

REQUIRES_NEW gives two independent local transactions, not one atomic unit: if the outer one fails after the inner one committed, nothing undoes the inner. Coordinating work across transactions or services is the subject of the Saga pattern in Chapter 11 of this course.

Isolation levels on PostgreSQL 18

How @Transactional(isolation = …) reaches PostgreSQL

ISOLATION_DEFAULT means "whatever the database does":

Bash
docker exec sba-a6-pg psql -U demo -d demo -c 'show default_transaction_isolation'
Text
 default_transaction_isolation 
-------------------------------
 read committed
(1 row)

A method annotated @Transactional(isolation = Isolation.REPEATABLE_READ), with the PostgreSQL statement log on:

Text
[156] LOG:  execute <unnamed>: SHOW TRANSACTION ISOLATION LEVEL
[156] LOG:  execute <unnamed>: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ
[156] LOG:  statement: BEGIN
[156] LOG:  execute <unnamed>: select current_setting('transaction_isolation')
[156] LOG:  execute <unnamed>: select current_setting('transaction_read_only')
[156] LOG:  execute S_1: COMMIT
[156] LOG:  execute <unnamed>: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED

HibernateJpaDialect calls Connection.setTransactionIsolation before the transaction begins, and the PostgreSQL driver turns that into SET SESSION CHARACTERISTICS, after reading the current level so that Spring can restore it. After the commit the connection was set back to READ COMMITTED before it returned to the pool. A non-default isolation level therefore costs three extra round trips per transaction, and applies only when a transaction begins: the "An inner method that asks for another isolation level" section below shows what happens when it joins one.

READ COMMITTED: a non-repeatable read, and the entity that hides it

Every anomaly below uses two threads, tx-A and tx-B, each calling a transactional method of IsolationLab, with CountDownLatches passed in to force the interleaving. tx-A reads account 1 twice; between the two reads tx-B deposits 50 and commits:

src/main/java/com/example/demo/lab/IsolationLab.java
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void readTwiceReadCommitted(Long id, CountDownLatch firstReadDone, CountDownLatch otherCommitted) {
        readTwice(id, firstReadDone, otherCommitted);
    }
 
    private void readTwice(Long id, CountDownLatch firstReadDone, CountDownLatch otherCommitted) {
        Account account = accounts.findById(id).orElseThrow();
        log.info("first read:  entity {}, query {}", account.getBalance(), accounts.balanceOf(id));
        firstReadDone.countDown();
        await(otherCommitted);
        Account again = accounts.findById(id).orElseThrow();
        log.info("second read: entity {}, query {}", again.getBalance(), accounts.balanceOf(id));
    }
 
    @Transactional
    public void deposit(Long id, BigDecimal amount) {
        Account account = accounts.findById(id).orElseThrow();
        account.deposit(amount);
        log.info("deposit {} into account {}", amount, id);
    }

The log, trimmed to the transactions, the SQL and the results:

Text
[tx-A] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.readTwiceReadCommitted]: PROPAGATION_REQUIRED,ISOLATION_READ_COMMITTED
[tx-A] SQL: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=?
[tx-A] SQL: select a1_0.balance from accounts a1_0 where a1_0.id=?
[tx-A] IsolationLab: first read:  entity 100.00, query 100.00
[tx-B] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.deposit]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[tx-B] SQL: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=?
[tx-B] IsolationLab: deposit 50.00 into account 1
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(19897183<open>)]
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] SQL: select a1_0.balance from accounts a1_0 where a1_0.id=?
[tx-A] IsolationLab: second read: entity 100.00, query 150.00
[tx-A] JpaTransactionManager: Initiating transaction commit

The query saw 100.00, then 150.00 within one transaction: a non-repeatable read, which READ COMMITTED allows because every statement takes a fresh snapshot. The entity said 100.00 both times, and the second findById sent no SQL at all: it returned the instance already in the persistence context. That is not isolation, it is the first-level cache, and it only holds for entities already loaded; a query that returns values, like balanceOf, sees committed changes.

READ COMMITTED: a lost update

Two withdrawals of 30.00 from the same account of 100.00. Both threads read the balance, then tx-A writes and commits, then tx-B writes:

src/main/java/com/example/demo/lab/IsolationLab.java
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void withdrawReadCommitted(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
        withdraw(id, amount, bothRead, mayWrite);
    }
 
    private void withdraw(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
        Account account = accounts.findById(id).orElseThrow();
        log.info("read balance {}", account.getBalance());
        arriveAndAwait(bothRead);
        await(mayWrite);
        account.withdraw(amount);
        log.info("withdraw {}, balance now {} (flushed at commit)", amount, account.getBalance());
    }

bothRead is a CountDownLatch(2) that each thread counts down and then waits on; mayWrite is already open for tx-A and is the latch tx-A opens after its commit for tx-B. The runner:

src/main/java/com/example/demo/lab/LabRunner.java
            CountDownLatch bothRead = new CountDownLatch(2);
            CountDownLatch aCommitted = new CountDownLatch(1);
            CountDownLatch go = new CountDownLatch(0);
            BigDecimal amount = new BigDecimal("30.00");
            twoThreads(
                    () -> {
                        try {
                            if (rr) iso.withdrawRepeatableRead(1L, amount, bothRead, go);
                            else iso.withdrawReadCommitted(1L, amount, bothRead, go);
                            log.info("committed");
                        } finally {
                            aCommitted.countDown();
                        }
                    },
                    () -> {
                        if (rr) iso.withdrawRepeatableRead(1L, amount, bothRead, aCommitted);
                        else iso.withdrawReadCommitted(1L, amount, bothRead, aCommitted);
                        log.info("committed");
                    });

Trimmed to the reads, the writes and the result:

Text
[tx-A] IsolationLab: read balance 100.00
[tx-B] IsolationLab: read balance 100.00
[tx-A] IsolationLab: withdraw 30.00, balance now 70.00 (flushed at commit)
[tx-A] JpaTransactionManager: Initiating transaction commit
[tx-A] JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1393197551<open>)]
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] Lab: committed
[tx-B] IsolationLab: withdraw 30.00, balance now 70.00 (flushed at commit)
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(44491633<open>)]
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] Lab: committed
[main] Lab: balances: [1=70.00, 2=100.00, 3=100.00]

Two withdrawals of 30.00 committed and the balance is 70.00: one of them was lost, with no error anywhere. Hibernate's UPDATE writes the absolute value it computed, balance=?, from a read that was stale by the time it ran. Optimistic locking with @Version and pessimistic locks are the usual fixes, and the next article covers both; the isolation level is the other, shown next.

REPEATABLE READ: one snapshot, and SQLSTATE 40001

The same two scenarios with @Transactional(isolation = Isolation.REPEATABLE_READ). The second read of tx-A:

Text
[tx-A] IsolationLab: second read: entity 100.00, query 100.00

REPEATABLE READ on PostgreSQL takes one snapshot at the first statement and keeps it, so tx-B's committed deposit stayed invisible to tx-A. The lost update, trimmed to the end of tx-B:

Text
[tx-B] IsolationLab: withdraw 30.00, balance now 70.00 (flushed at commit)
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] error: HHH000247: ErrorCode: 0, SQLState: 40001
[tx-B] error: ERROR: could not serialize access due to concurrent update
[tx-B] JpaTransactionManager: Initiating transaction rollback after commit exception
[tx-B] Lab: threw org.springframework.dao.CannotAcquireLockException: could not execute statement [ERROR: could not serialize access due to concurrent update] [update accounts set balance=?,kind=?,owner=? where id=?]; SQL [update accounts set balance=?,kind=?,owner=? where id=?]
[tx-B] Lab:   caused by org.hibernate.exception.LockAcquisitionException: could not execute statement [ERROR: could not serialize access due to concurrent update] [update accounts set balance=?,kind=?,owner=? where id=?]
[tx-B] Lab:   caused by org.postgresql.util.PSQLException: ERROR: could not serialize access due to concurrent update
[main] Lab: balances: [1=70.00, 2=100.00, 3=100.00]

PostgreSQL refused to let tx-B update a row that another transaction had changed since tx-B's snapshot: SQLSTATE 40001, could not serialize access due to concurrent update. The balance is 70.00 again, but this time it is correct: one withdrawal committed, the other failed loudly. Two details matter for the code that handles it:

  • The error came at commit. Hibernate flushes the UPDATE during Committing JPA transaction, so the exception is thrown by the proxy after the method body returned, not from account.withdraw.
  • Spring translates it to CannotAcquireLockException, through Hibernate's LockAcquisitionException. In spring-tx 7.0.9 its hierarchy is CannotAcquireLockExceptionPessimisticLockingFailureExceptionConcurrencyFailureExceptionTransientDataAccessException. The name suggests a lock timeout, and the older CannotSerializeTransactionException, which would describe it better, is deprecated since 6.0.3 and was not used.

SERIALIZABLE: write skew rejected

The bank lets either of a customer's accounts go negative as long as the total stays at or above zero. an has two accounts of 100.00. tx-A withdraws 150.00 from checking, tx-B 150.00 from savings, each after checking the total:

src/main/java/com/example/demo/lab/IsolationLab.java
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void withdrawKeepingTotalSerializable(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
        withdrawKeepingTotal(id, amount, bothRead, mayWrite);
    }
 
    private void withdrawKeepingTotal(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
        Account account = accounts.findById(id).orElseThrow();
        BigDecimal total = accounts.totalBalance(account.getOwner());
        log.info("total balance of {} is {}, withdrawing {} from account {}", account.getOwner(), total, amount, id);
        if (total.compareTo(amount) < 0) {
            throw new InsufficientFundsException(account.getOwner(), total, amount);
        }
        arriveAndAwait(bothRead);
        await(mayWrite);
        account.withdraw(amount);
    }

withdrawKeepingTotalRepeatableRead is the same with REPEATABLE_READ. Under REPEATABLE READ, trimmed:

Text
[tx-B] IsolationLab: total balance of an is 200.00, withdrawing 150.00 from account 2
[tx-A] IsolationLab: total balance of an is 200.00, withdrawing 150.00 from account 1
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] Lab: committed
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] Lab: committed
[main] Lab: balances: [1=-50.00, 2=-50.00, 3=100.00]

Both checks passed on the same snapshot, both transactions updated a different row, so no row conflict existed, and the total is −100.00. That is write skew: each transaction was consistent on its own, their combination violates the rule. Under SERIALIZABLE, trimmed:

Text
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] Lab: committed
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] error: HHH000247: ErrorCode: 0, SQLState: 40001
[tx-B] error: ERROR: could not serialize access due to read/write dependencies among transactions
  Detail: Reason code: Canceled on identification as a pivot, during write.
  Hint: The transaction might succeed if retried.
[tx-B] Lab: threw org.springframework.dao.CannotAcquireLockException: could not execute statement [ERROR: could not serialize access due to read/write dependencies among transactions
  Detail: Reason code: Canceled on identification as a pivot, during write.
  Hint: The transaction might succeed if retried.] [update accounts set balance=?,kind=?,owner=? where id=?]; SQL [update accounts set balance=?,kind=?,owner=? where id=?]
[main] Lab: balances: [1=-50.00, 2=100.00, 3=100.00]

PostgreSQL tracks what each serializable transaction read: each sum had read the row the other transaction then wrote, a read/write dependency in both directions, and PostgreSQL cancelled tx-B: SQLSTATE 40001 again, with a different message and a Hint that says what to do. A second variant flushed both UPDATEs before either transaction committed. Then both statements succeeded, and the failure moved to the commit itself:

Text
[tx-B] Lab: threw org.springframework.dao.CannotAcquireLockException: Hibernate transaction: Unable to commit against JDBC Connection; ERROR: could not serialize access due to read/write dependencies among transactions
  Detail: Reason code: Canceled on identification as a pivot, during commit attempt.
  Hint: The transaction might succeed if retried.

A different message from a different place, connection.commit() instead of a statement, and still CannotAcquireLockException, so handling ConcurrencyFailureException covers both. The same two scenarios as before under SERIALIZABLE gave second read: entity 100.00, query 100.00 and the could not serialize access due to concurrent update error for the second withdrawal, like REPEATABLE READ.

READ UNCOMMITTED behaves as READ COMMITTED

tx-A withdraws 30.00, flushes the UPDATE and waits without committing; tx-B reads with READ_UNCOMMITTED:

src/main/java/com/example/demo/lab/IsolationLab.java
    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public void readUncommitted(Long id) {
        String level = jdbc.sql("select current_setting('transaction_isolation')").query(String.class).single();
        log.info("transaction_isolation = {}, balance {}", level, accounts.balanceOf(id));
    }

Trimmed:

Text
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] IsolationLab: flushed balance 70.00, not committed
[tx-B] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.readUncommitted]: PROPAGATION_REQUIRED,ISOLATION_READ_UNCOMMITTED
[tx-B] SQL: select a1_0.balance from accounts a1_0 where a1_0.id=?
[tx-B] IsolationLab: transaction_isolation = read uncommitted, balance 100.00
[tx-A] JpaTransactionManager: Initiating transaction rollback
[main] Lab: balances: [1=100.00, 2=100.00, 3=100.00]

PostgreSQL accepted the level and reported it, read uncommitted, but did not show tx-B the uncommitted 70.00. It has no dirty reads at any level; READ UNCOMMITTED runs as READ COMMITTED.

What each level allowed on PostgreSQL 18

LevelNon-repeatable read (query)Lost updateWrite skew
READ_COMMITTED (the default)happened: 100.00, then 150.00happened: 70.00 after two withdrawals of 30.00not run at this level
REPEATABLE_READprevented: 100.00 twiceprevented: 40001, could not serialize access due to concurrent updatehappened: total −100.00
SERIALIZABLEprevented: 100.00 twiceprevented: the same 40001prevented: 40001, read/write dependencies among transactions

Every 40001 arrived in Spring as CannotAcquireLockException. The stronger levels did not prevent these anomalies by making a transaction wait: they made PostgreSQL fail one of the transactions, which then has to be retried.

Three columns, one per anomaly, each with tx-A and tx-B lanes over time: the non-repeatable read, where tx-A reads 100.00, tx-B deposits 50.00 and commits, and tx-A reads 150.00 under READ COMMITTED but 100.00 under REPEATABLE READ; the lost update, where both read 100.00 and tx-A commits 70.00, then tx-B's UPDATE commits 70.00 under READ COMMITTED but fails with 40001 could not serialize access due to concurrent update under REPEATABLE READ; the write skew, where both read a total of 200.00 and withdraw 150.00 from different accounts, committing a total of -100.00 under REPEATABLE READ while SERIALIZABLE cancels tx-B with 40001 read/write dependencies among transactions

An inner method that asks for another isolation level

Isolation is applied when a transaction begins. A method with @Transactional(isolation = Isolation.SERIALIZABLE) called from a default transaction joins it; trimmed:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.SettingsLab.defaultCallingSerializable]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Participating in existing transaction
InnerOps: inner SERIALIZABLE: postgres isolation=read committed read_only=off | jdbc readOnly=false | hibernate flushMode=AUTO defaultReadOnly=false
JpaTransactionManager: Initiating transaction commit

The method that asked for SERIALIZABLE ran under read committed, and nothing said so. TxSettings is a second probe that prints PostgreSQL's transaction_isolation and transaction_read_only, the JDBC connection's read-only flag and the Hibernate session's flush mode; the readOnly section uses the rest of it. AbstractPlatformTransactionManager has a switch for this, validateExistingTransaction, off by default and with no Spring Boot property; the lab's second customizer above sets it. The same call with --lab.validate-existing=true:

Text
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Participating transaction with definition [PROPAGATION_REQUIRED,ISOLATION_SERIALIZABLE] specifies isolation level which is incompatible with existing transaction: (unknown)

(unknown) because the outer transaction used ISOLATION_DEFAULT, which Spring does not record as a level. A method that depends on its isolation level either has to start its own transaction (REQUIRES_NEW, with its connection) or has to be called from a transaction that already runs at that level, and validateExistingTransaction turns the silent case into an error.

Retrying a serialization failure

A 40001 is PostgreSQL saying "retry", and a retry has to repeat the whole transaction: a new transaction, a new snapshot, the reads done again. So it belongs outside the @Transactional method, in a caller that is not transactional:

src/main/java/com/example/demo/support/SerializationRetry.java
@Component
public class SerializationRetry {
 
    private static final Logger log = LoggerFactory.getLogger(SerializationRetry.class);
    private static final int MAX_ATTEMPTS = 3;
 
    // transaction must call a @Transactional method on another bean: each attempt is a new transaction
    public void run(Runnable transaction) {
        for (int attempt = 1; ; attempt++) {
            try {
                transaction.run();
                return;
            } catch (ConcurrencyFailureException e) {
                if (attempt == MAX_ATTEMPTS) {
                    throw e;
                }
                log.info("attempt {} failed with {}, retrying", attempt, e.getClass().getSimpleName());
            }
        }
    }
}

The write skew again, both threads calling retry.run(() -> iso.withdrawKeepingTotalSerializable(...)); the end of tx-B, trimmed:

Text
[tx-B] error: ERROR: could not serialize access due to read/write dependencies among transactions
[tx-B] SerializationRetry: attempt 1 failed with CannotAcquireLockException, retrying
[tx-B] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.withdrawKeepingTotalSerializable]: PROPAGATION_REQUIRED,ISOLATION_SERIALIZABLE
[tx-B] SQL: select sum(a1_0.balance) from accounts a1_0 where a1_0.owner=?
[tx-B] IsolationLab: total balance of an is 50.00, withdrawing 150.00 from account 2
[tx-B] JpaTransactionManager: Initiating transaction rollback
[tx-B] Lab: threw com.example.demo.account.InsufficientFundsException: total balance of an is 50.00, cannot withdraw 150.00

The second attempt ran in a new transaction, saw the committed total of 50.00 and refused the withdrawal with the business exception, which is not a ConcurrencyFailureException and was not retried. That is the correct outcome. The same retry.run placed inside a @Transactional(isolation = Isolation.SERIALIZABLE) method, retryInsideTheTransaction, never logged attempt 1 failed: the UPDATE is flushed at commit, after the method and its loop have returned, so the CannotAcquireLockException went straight to the caller.

Keep the attempts few and the transactions short, and do not retry work with side effects outside the database, such as an email, unless it is idempotent. Spring Framework 7's own @Retryable in org.springframework.resilience.annotation, with backoff and jitter, replaces a hand-written loop like this one; it is covered in article 19 of this course.

readOnly beyond Basics 30

What readOnly = true changes at each layer

Basics article 30 showed the effects: a changed entity is not written, and PostgreSQL rejects an INSERT. SettingsLab prints the settings behind them, in a read-write transaction and in a read-only one, after loading account 1:

src/main/java/com/example/demo/lab/SettingsLab.java
    @Transactional(readOnly = true)
    public void readOnly() {
        Account account = accounts.findById(1L).orElseThrow();
        log.info("readOnly: {} | snapshot kept={}", settings.describe(), snapshotKept(account));
    }
 
    private boolean snapshotKept(Account account) {
        SessionImplementor session = em.unwrap(SessionImplementor.class);
        return session.getPersistenceContextInternal().getEntry(account).getLoadedState() != null;
    }
Text
SettingsLab: read-write: postgres isolation=read committed read_only=off | jdbc readOnly=false | hibernate flushMode=AUTO defaultReadOnly=false | snapshot kept=true
SettingsLab: readOnly: postgres isolation=read committed read_only=on | jdbc readOnly=true | hibernate flushMode=MANUAL defaultReadOnly=true | snapshot kept=false

And the PostgreSQL statement log for the two transactions, trimmed to the statements that begin and end them and the entity query:

Text
[156] LOG:  statement: BEGIN
[156] LOG:  execute <unnamed>: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=$1
[156] LOG:  execute S_1: COMMIT
[156] LOG:  execute <unnamed>: delete from audit_entries
[156] LOG:  statement: BEGIN READ ONLY
[156] LOG:  execute <unnamed>: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=$1
[156] LOG:  execute S_1: COMMIT
LayerRead-writereadOnly = true
JDBC connectionisReadOnly() falsesetReadOnly(true): isReadOnly() true
PostgreSQLBEGIN, transaction_read_only offBEGIN READ ONLY, transaction_read_only on: writes fail with SQLSTATE 25006
Hibernate flush modeAUTOMANUAL: nothing is flushed automatically, not even at commit
Hibernate sessiondefaultReadOnly falsedefaultReadOnly true: entities are loaded read-only
Loaded-state snapshot per entitykept, for dirty checkingnot kept: less memory per loaded entity

The PostgreSQL driver sent BEGIN READ ONLY itself, from the connection's read-only flag; no separate SET TRANSACTION READ ONLY statement appeared. The snapshot line is the part that makes readOnly worth using on reads that load many entities: Hibernate keeps no copy of their loaded state, because it will never compare against it.

readOnly when transactions join

Like isolation, readOnly is applied when a transaction begins. A method with @Transactional(readOnly = true) that deposits 5.00, called from a read-write transaction (the log below is trimmed):

src/main/java/com/example/demo/lab/InnerOps.java
    @Transactional(readOnly = true)
    public void readOnlyDeposit(Long id) {
        Account account = accounts.findById(id).orElseThrow();
        account.deposit(new BigDecimal("5.00"));
        log.info("inner readOnly: {}", settings.describe());
    }
Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.SettingsLab.readWriteCallingReadOnly]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Participating in existing transaction
InnerOps: inner readOnly: postgres isolation=read committed read_only=off | jdbc readOnly=false | hibernate flushMode=AUTO defaultReadOnly=false
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(323302928<open>)]
SQL: update accounts set balance=?,kind=?,owner=? where id=?
Lab: committed audit rows: []
Lab: balances: [1=105.00, 2=100.00, 3=100.00]

The readOnly flag of the joined method was ignored: flush mode AUTO, read_only=off, and the change was written. The opposite direction is the one Basics article 30 hit with save: a read-write readWriteDeposit called from a readOnly = true transaction ran with read_only=on and flushMode=MANUAL, and its deposit was silently dropped, the balance staying 105.00. With validateExistingTransaction on, that second case fails instead, and the first is still allowed:

Text
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Participating transaction with definition [PROPAGATION_REQUIRED,ISOLATION_DEFAULT] is not marked as read-only but existing transaction is

Rollback rules across transaction boundaries

An exception in REQUIRES_NEW rolls back the inner transaction only

OuterService has two more methods for this: catching inserts its own row and calls the inner method in a try that catches AuditFailedException, and failingAfter inserts its row, calls the inner method, then throws IllegalStateException:

src/main/java/com/example/demo/lab/OuterService.java
    @Transactional
    public void catching(String label, Runnable inner) {
        jdbc.sql("insert into audit_entries (message) values (?)").param("outer " + label).update();
        try {
            inner.run();
        } catch (AuditFailedException e) {
            log.info("outer caught: {}", e.getMessage());
        }
    }
 
    @Transactional
    public void failingAfter(String label, Runnable inner) {
        jdbc.sql("insert into audit_entries (message) values (?)").param("outer " + label).update();
        inner.run();
        throw new IllegalStateException("outer failed after the inner call");
    }

A failing requiresNew inside catching, then a successful one inside failingAfter, trimmed:

Text
Lab: === REQUIRES_NEW fails, outer catches ===
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1337277302<open>)]
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer caught: audit failed: requires-new
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [outer requires-new]
Lab: === outer fails after REQUIRES_NEW ===
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(837508822<open>)]
Lab: threw java.lang.IllegalStateException: outer failed after the inner call
Lab: committed audit rows: [requires-new]

Each proxy applies its method's rollback rules to the transaction it manages. The inner proxy rolled back the inner transaction, a transaction of its own, so there was nothing to mark in the outer one, and the caught exception left the outer free to commit. The outer failure rolled back the outer row and could not touch the inner row, already committed. Unlike the REQUIRED case of Basics article 30, there was no UnexpectedRollbackException.

noRollbackFor on a joined method keeps the outer transaction alive

The rollback-only mark comes from the inner proxy deciding "roll back" for a transaction it only joined. When the inner method's own rules say "do not roll back", there is nothing to mark. requiredNoRollback carries @Transactional(noRollbackFor = AuditFailedException.class) and throws inside catching; trimmed:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.catching]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Participating in existing transaction
AuditService: REQUIRED, noRollbackFor: actualTx=true name=OuterService.catching pid=437 hikariActive=1
OuterService: outer caught: audit failed: no-rollback
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [outer no-rollback, no-rollback]

No Participating transaction failed - marking existing transaction as rollback-only line, a normal commit, and both rows written, the inner one included: noRollbackFor keeps the work the inner method did before it threw. It is the right tool when the exception reports an outcome, not a broken state.

rollbackOn = ALL_EXCEPTIONS: checked exceptions roll back too

Basics article 30 showed that a checked exception commits unless rollbackFor names it. Spring Framework 6.2 added a global switch, present in 7.0.9 as EnableTransactionManagement.rollbackOn() with the values RUNTIME_EXCEPTIONS and ALL_EXCEPTIONS. Spring Boot 4.1.1 has no property for it: the spring.transaction.* metadata in spring-boot-transaction-4.1.1.jar lists only default-timeout and rollback-on-commit-failure. It takes an annotation of your own, and Boot's own @EnableTransactionManagement configuration steps aside, since it is conditional on no AbstractTransactionManagementConfiguration bean being present:

src/main/java/com/example/demo/lab/RollbackOnConfig.java
@Configuration
@ConditionalOnBooleanProperty("lab.rollback-on-all")
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
class RollbackOnConfig {
}
src/main/java/com/example/demo/lab/CheckedLab.java
    @Transactional
    public void importStatement() throws IOException {
        jdbc.sql("insert into audit_entries (message) values ('import started')").update();
        throw new IOException("statement file truncated");
    }
 
    @Transactional(noRollbackFor = IOException.class)
    public void importStatementNoRollback() throws IOException {
        jdbc.sql("insert into audit_entries (message) values ('import started')").update();
        throw new IOException("statement file truncated");
    }

Without the switch, then with --lab.rollback-on-all=true, trimmed:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatement]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [import started]
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatementNoRollback]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,+java.io.IOException
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [import started]
Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatement]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.lang.Exception
JpaTransactionManager: Initiating transaction rollback
Lab: committed audit rows: []
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatementNoRollback]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,+java.io.IOException,-java.lang.Exception
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [import started]

The switch adds the rule -java.lang.Exception to every transaction, which the log prints in each definition; the checked IOException then rolled back. An explicit noRollbackFor = IOException.class still won, because the closest matching rule decides. Beans kept their CGLIB proxies with the custom annotation in place, including a @Transactional bean that implements an interface (CsvImporter$$SpringCGLIB$$0).

Transaction timeouts: what actually enforces them

@Transactional(timeout = 2) sets a deadline two seconds after the transaction begins. TimeoutLab has five methods with that attribute; the load average was 2.92 when the run began:

src/main/java/com/example/demo/lab/TimeoutLab.java
    @Transactional(timeout = 2)
    public void slowQuery() {
        accounts.slowReport(5);
    }
 
    @Transactional(timeout = 2)
    public void slowJavaWithPendingUpdate() {
        Account account = accounts.findById(1L).orElseThrow();
        account.withdraw(new BigDecimal("10.00"));
        sleep(3000);
    }
Method bodyReturned afterOutcome
accounts.slowReport(5), pg_sleep(5) through a repository2,024 msorg.springframework.dao.QueryTimeoutException, SQLSTATE 57014 canceling statement due to user request, rolled back
select pg_sleep(5) through JdbcClient2,018 msQueryTimeoutException: PreparedStatementCallback; SQL [select pg_sleep(5)]; ERROR: canceling statement due to user request
findById, then Thread.sleep(3000), no SQL after it3,008 mscommitted
findById, Thread.sleep(3000), then balanceOf3,011 msTransactionTimedOutException: Transaction timed out: deadline was Fri Sep 18 11:56:47 ICT 2026, before the query was sent
findById, withdraw, Thread.sleep(3000), commit3,017 msJpaSystemException: Transaction timeout expired, caused by org.hibernate.TransactionException, rolled back

The timeout is not a timer that interrupts the method. It is a deadline that three different places check:

  • The JDBC query timeout on each statement. Hibernate and JdbcClient both set the remaining time with Statement.setQueryTimeout, and PostgreSQL cancelled pg_sleep(5) after two seconds. The log showed SQLState: 57014 and ERROR: canceling statement due to user request, and the method returned after about 2,020 ms instead of 5,000.
  • Spring, before a query runs. Once the deadline had passed, the next repository query failed immediately with TransactionTimedOutException without reaching the database.
  • Hibernate, before the flush at commit. The pending UPDATE was prepared and refused with Transaction timeout expired, and the withdrawal rolled back: the balance stayed 100.00.

A method that spends its time outside the database, like the Thread.sleep here, and issues no statement after the deadline commits normally, three seconds into a two-second timeout. spring.transaction.default-timeout applies the same deadline to every transaction without an explicit timeout. A plain @Transactional method calling slowReport(5) returned normally after 5,037 ms (load 2.53); with --spring.transaction.default-timeout=2 it failed after 2,037 ms with the same QueryTimeoutException (load 3.05). The definition in the log does not show it, PROPAGATION_REQUIRED,ISOLATION_DEFAULT without timeout_2, because the manager applies its default only when the definition has none.

FAQ

What is the difference between REQUIRED and REQUIRES_NEW in Spring?

REQUIRED joins the transaction already running on the thread, so both methods share one connection, one commit and one fate; an exception in the inner method marks the whole transaction rollback-only. REQUIRES_NEW suspends it and starts an independent transaction on a second connection: measured, the inner method ran on PostgreSQL backend pid 438 with 2 HikariCP connections checked out while the outer one kept pid 437, and each transaction committed or rolled back on its own.

Does NESTED propagation work with Spring Data JPA?

Not with the JpaTransactionManager Spring Boot configures. On Spring Boot 4.1.1 with Hibernate 7.4.5 it threw NestedTransactionNotSupportedException: Transaction manager does not allow nested transactions by default, and after setNestedTransactionAllowed(true), JpaDialect does not support savepoints - check your JPA provider's capabilities. JdbcTransactionManager supports it with real savepoints: SAVEPOINT "SAVEPOINT_1" and ROLLBACK TO SAVEPOINT "SAVEPOINT_1" in the PostgreSQL log.

Why does REQUIRES_NEW cause "Connection is not available, request timed out"?

Every request holds one pooled connection for its outer transaction while it waits for a second one for the REQUIRES_NEW transaction. When as many requests as the pool has connections take their first one at the same time, none can get a second. With HikariCP's default pool of 10, 10 concurrent requests failed 10 of 2,000 withdrawals, each after about 30 seconds (request timed out after 30006ms), while 9 concurrent requests failed none. A pool of n × (d − 1) + 1 connections, 11 for 10 requests nesting 2 deep, cannot deadlock.

What is the default isolation level in Spring Boot with PostgreSQL?

PostgreSQL's own: ISOLATION_DEFAULT sends nothing, and show default_transaction_isolation returned read committed on PostgreSQL 18.6. A different isolation on @Transactional makes the driver send SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL … before BEGIN and set the level back after the commit.

Which exception does Spring throw for a PostgreSQL serialization failure?

org.springframework.dao.CannotAcquireLockException, both for could not serialize access due to concurrent update under REPEATABLE READ and for could not serialize access due to read/write dependencies among transactions under SERIALIZABLE, whether PostgreSQL raised it on the UPDATE or at the commit. It is a ConcurrencyFailureException, which is the type to catch in a retry placed around the whole transaction.

Does @Transactional(timeout) stop a long-running method?

No. With JPA on PostgreSQL the deadline is checked when a statement runs: a slow query was cancelled after 2 seconds with SQLSTATE 57014 and QueryTimeoutException, and a query after the deadline failed with TransactionTimedOutException. A method that slept 3 seconds without issuing SQL afterwards committed normally despite timeout = 2.

Is readOnly = true ignored when the method joins another transaction?

Yes. A @Transactional(readOnly = true) method called inside a read-write transaction ran with flush mode AUTO and transaction_read_only = off, and its entity change was written. In the other direction, a read-write method inside a read-only transaction had its change dropped; setValidateExistingTransaction(true) turns that case into IllegalTransactionStateException.

Conclusion

Propagation decides which transaction and which connection an inner method uses: REQUIRED, SUPPORTS and MANDATORY joined on the same pid, REQUIRES_NEW and NOT_SUPPORTED suspended the outer transaction and took a second connection, and NESTED does not work with JpaTransactionManager at all. The second connection is the costly part: with HikariCP's default pool of 10, ten concurrent withdrawals deadlocked the pool for 30 seconds, and 11 connections were enough to make it impossible.

Isolation decides what fails when transactions overlap. PostgreSQL's default READ COMMITTED allowed a non-repeatable read and a lost update without any error; REPEATABLE READ and SERIALIZABLE replaced them with SQLSTATE 40001, which Spring reports as CannotAcquireLockException and which only a retry around the whole transaction can resolve. readOnly, isolation and timeouts all take effect when a transaction begins and are ignored by a method that joins one, which validateExistingTransaction can turn into an error, and a timeout is only checked when a statement runs.

The lost update is where isolation levels stop being the practical answer. The next article covers locking and concurrency: optimistic locking with @Version, pessimistic locks, and how to handle race conditions.

Related Posts

[Advanced Spring Boot] Your Own Authorization Server: Spring Authorization Server and Keycloak

Building an OAuth2 and OpenID Connect authorization server with Spring Authorization Server, now a module of Spring Security, on Spring Boot 4.1.1: the starter Initializr picks and the deprecated one, a server from properties alone, both discovery documents and the endpoints they advertise, the two filter chains Boot registers and what changes when you declare your own, client_credentials and authorization_code with PKCE hop by hop, the real error bodies, the RSA key that changes on every restart, a persistent key and key rotation with a JWK selector, the JWKS cache of the resource server, JDBC clients, authorizations and consent on PostgreSQL with the schema scripts from the jar, a roles claim from OAuth2TokenCustomizer and the Jackson allowlist trap, opaque tokens with introspection measured against JWT validation, and a measured comparison with Keycloak.

[Advanced Spring Boot] Spring AOP: JDK and CGLIB Proxies, Aspects and Self-Invocation

Spring AOP on Spring Boot 4.1.1: spring-boot-starter-aop is gone from the BOM and spring-boot-starter-aspectj replaces it, JDK dynamic proxy against CGLIB subclass with the real class names, the ClassCastException a JDK proxy causes, the final class that throws AopConfigException, the final method that quietly NPEs because Objenesis skipped the constructor, the pointcut designators that matter, the measured order of all five advice kinds on both paths, @Order between aspects, the self-invocation trap underneath @Transactional and @Async with three fixes compared, the nanosecond cost of a proxied call, and Advised#getAdvisors for debugging.

[Advanced Spring Boot] Writing Your Own Auto-configuration and Starter

Build and ship a real Spring Boot 4.1.1 starter: the three Gradle projects and the x-spring-boot-starter naming rule, an @AutoConfiguration class with @ConditionalOnMissingBean and @ConditionalOnProperty, registration in AutoConfiguration.imports, ordering with before/after against a Boot auto-configuration, a validated @ConfigurationProperties record with generated spring-configuration-metadata.json, a custom SpringBootCondition with its ConditionOutcome message in the report, five ApplicationContextRunner tests including FilteredClassLoader, publishing to mavenLocal and consuming it, and a FailureAnalyzer for the misconfiguration.

[Advanced Spring Boot] Caching in Spring Boot: the Cache Abstraction, Caffeine, Redis and Invalidation

Spring Boot 4.1.1 caching on a product lookup with PostgreSQL and Redis: why @EnableCaching is still required, @Cacheable, @CachePut, @CacheEvict with allEntries and @Caching, SimpleKey and SpEL keys, condition, unless and cached Optional nulls, the LazyInitializationException a cached entity causes, Caffeine eviction and the cache.gets metrics, why Boot picks Redis over Caffeine, JDK serialization versus GenericJacksonJsonRedisSerializer for Jackson 3 and the type validator that rejects BigDecimal, the two serializer configurations that silently drop settings, latency with no cache, Caffeine and Redis, a cache stampede that sync = true does not stop on Redis, the evict-before-commit race and transactionAware(), stale reads across two instances, and what a stopped or hung Redis does to requests with and without a CacheErrorHandler.