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.
![]()
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:
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);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=DEBUGdocker run -d --name sba-a6-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5506:5432 postgres:18Account is an ordinary entity in the account package with withdraw(BigDecimal) and deposit(BigDecimal) methods. Its repository adds three queries the later sections use:
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:
@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:
@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
@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:
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)); 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);
}./gradlew -q bootJarjava -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:
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
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:
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: afterCommitThe 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:
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:
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:
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=1The 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:
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:
@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:
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:
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());
}
});
}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:
docker exec sba-a6-pg psql -U demo -d demo -c "alter system set log_statement = 'all'" -c "select pg_reload_conf()"[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: COMMITTimestamps 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.
| Propagation | Called inside a transaction | Called with no transaction | Typical use |
|---|---|---|---|
REQUIRED (default) | joins: Participating in existing transaction, same pid | creates one | almost every service method |
REQUIRES_NEW | suspends it and creates a new one on a second connection | creates one | a write that must commit or roll back on its own, such as an audit row |
SUPPORTS | joins | runs without one, actualTx=false | reads that work either way |
MANDATORY | joins | IllegalTransactionStateException | code that must never be the transaction boundary |
NOT_SUPPORTED | suspends it, runs without one, on a second connection if it touches the database | runs without one | code that must not join the caller's transaction |
NEVER | IllegalTransactionStateException | runs without one | a guard for code that must never run in a transaction |
NESTED | NestedTransactionNotSupportedException; a savepoint with JdbcTransactionManager | creates one | partial rollback in JDBC code |

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:
@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();
}
} @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):
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=INFOab -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:
Concurrency Level: 9
Time taken for tests: 1.602 seconds
Complete requests: 2000
Failed requests: 0With 10:
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: 10One 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:
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:
[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).

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:
| Pool | Concurrent requests | Failed of 2,000 | Time | Load average |
|---|---|---|---|---|
| 10 (default) | 9 | 0 | 1.602 s | 4.18 |
| 10 (default) | 10 | 10 | 32.814 s | 4.00 |
| 11 | 10 | 0 | 1.672 s | 5.52 |
| 11 | 11 | 0 | 1.481 s | 5.52 |
| 21 | 20 | 0 | 1.674 s | 2.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_NEWwork before the outer transaction begins or after it has returned, from a caller that is not transactional. AnAFTER_COMMITlistener is not that place:OuterService.writeAfterCommitregisters a synchronization whoseafterCommitcallsrequiresNew, and the probe loggedREQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=584 hikariActive=2afterCommitting JPA transactionand before the outerClosing 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_NEWand let it join; one connection per request. - Size the pool with the formula when nesting is unavoidable, and remember that
NOT_SUPPORTEDwith database access counts as a second connection too. - Lower
connection-timeoutso 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":
docker exec sba-a6-pg psql -U demo -d demo -c 'show default_transaction_isolation' default_transaction_isolation
-------------------------------
read committed
(1 row)A method annotated @Transactional(isolation = Isolation.REPEATABLE_READ), with the PostgreSQL statement log on:
[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 COMMITTEDHibernateJpaDialect 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:
@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:
[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 commitThe 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:
@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:
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:
[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:
[tx-A] IsolationLab: second read: entity 100.00, query 100.00REPEATABLE 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:
[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 fromaccount.withdraw. - Spring translates it to
CannotAcquireLockException, through Hibernate'sLockAcquisitionException. In spring-tx 7.0.9 its hierarchy isCannotAcquireLockException→PessimisticLockingFailureException→ConcurrencyFailureException→TransientDataAccessException. The name suggests a lock timeout, and the olderCannotSerializeTransactionException, 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:
@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:
[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:
[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:
[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:
@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:
[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
| Level | Non-repeatable read (query) | Lost update | Write skew |
|---|---|---|---|
READ_COMMITTED (the default) | happened: 100.00, then 150.00 | happened: 70.00 after two withdrawals of 30.00 | not run at this level |
REPEATABLE_READ | prevented: 100.00 twice | prevented: 40001, could not serialize access due to concurrent update | happened: total −100.00 |
SERIALIZABLE | prevented: 100.00 twice | prevented: the same 40001 | prevented: 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.

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:
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 commitThe 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:
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:
@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:
[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.00The 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:
@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;
}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=falseAnd the PostgreSQL statement log for the two transactions, trimmed to the statements that begin and end them and the entity query:
[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| Layer | Read-write | readOnly = true |
|---|---|---|
| JDBC connection | isReadOnly() false | setReadOnly(true): isReadOnly() true |
| PostgreSQL | BEGIN, transaction_read_only off | BEGIN READ ONLY, transaction_read_only on: writes fail with SQLSTATE 25006 |
| Hibernate flush mode | AUTO | MANUAL: nothing is flushed automatically, not even at commit |
| Hibernate session | defaultReadOnly false | defaultReadOnly true: entities are loaded read-only |
| Loaded-state snapshot per entity | kept, for dirty checking | not 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):
@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());
}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:
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Participating transaction with definition [PROPAGATION_REQUIRED,ISOLATION_DEFAULT] is not marked as read-only but existing transaction isRollback 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:
@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:
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:
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:
@Configuration
@ConditionalOnBooleanProperty("lab.rollback-on-all")
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
class RollbackOnConfig {
} @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:
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]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:
@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 body | Returned after | Outcome |
|---|---|---|
accounts.slowReport(5), pg_sleep(5) through a repository | 2,024 ms | org.springframework.dao.QueryTimeoutException, SQLSTATE 57014 canceling statement due to user request, rolled back |
select pg_sleep(5) through JdbcClient | 2,018 ms | QueryTimeoutException: PreparedStatementCallback; SQL [select pg_sleep(5)]; ERROR: canceling statement due to user request |
findById, then Thread.sleep(3000), no SQL after it | 3,008 ms | committed |
findById, Thread.sleep(3000), then balanceOf | 3,011 ms | TransactionTimedOutException: Transaction timed out: deadline was Fri Sep 18 11:56:47 ICT 2026, before the query was sent |
findById, withdraw, Thread.sleep(3000), commit | 3,017 ms | JpaSystemException: 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
JdbcClientboth set the remaining time withStatement.setQueryTimeout, and PostgreSQL cancelledpg_sleep(5)after two seconds. The log showedSQLState: 57014andERROR: 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
TransactionTimedOutExceptionwithout 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.