Introduction
This is the fourth episode of this SpringBoot series [ Part 1 ].
Spring Boot’s @Transactional abstraction makes database transaction management straightforward for developers. Under the hood, it coordinates JDBC connections, transaction boundaries, and JPA/Hibernate’s persistence context to ensure data consistency. This article covers:
How transactions are created, managed, and closed.
JDBC connection binding/unbinding.
Transaction boundaries supported by Spring Boot + JPA.
Connection pooling with HikariCP.
Whether JPA creates default transactions for service methods.
Transaction isolation levels, comparison, and anomalies explained.
Proxy-based transaction triggering.
Locking strategies and implications.
Foreign key and insert behavior.
Example with saveAsynch.
Virtual threads (JDK 21) impact.
Transaction Boundaries in Spring Boot + JPA
Spring Boot uses Spring’s PlatformTransactionManager (typically JpaTransactionManager for JPA) to manage transaction boundaries. Common propagation types:
REQUIRED (default) — Joins the existing transaction or creates a new one if none exists.
REQUIRES_NEW — Suspends the current transaction and starts a new one.
MANDATORY — Must run within an existing transaction, otherwise throws.
SUPPORTS — Runs within a transaction if one exists, otherwise non-transactional.
NOT_SUPPORTED — Suspends any existing transaction.
NEVER — Throws if a transaction exists.
NESTED — Runs within a nested transaction if supported by the database.
Why REQUIRES_NEW Can Be Problematic
While REQUIRES_NEW has valid use cases (e.g., audit logging regardless of main transaction outcome), overusing it can cause:
Connection pool exhaustion — Each REQUIRES_NEW call needs its own physical DB connection, which can quickly deplete the HikariCP pool.
Unexpected commits — Work inside REQUIRES_NEW commits independently; if the main transaction rolls back, data written in REQUIRES_NEW remains.
Performance penalties — Suspending and resuming transactions incurs overhead and may lead to contention.
Complex debugging — Makes reasoning about transactional consistency harder.
Best practice: Use REQUIRES_NEW only when you explicitly need an independent transaction.
Does JPA Create a Default Transaction for Service Methods?
JPA does not automatically start a transaction for every service method. In Spring, transactions start when:
The method is annotated with @Transactional.
The method is called from outside the bean via a Spring proxy.
Without an active transaction, Spring Data JPA will start a temporary transaction for a single write, or execute reads in autocommit mode depending on the driver. Always define transaction boundaries explicitly at the service layer.
Proxy Limitations
@Transactional on private methods is ignored — proxies intercept only public/protected methods called externally. A transaction you see in logs may come from a repository method like SimpleJpaRepository.saveAll(...) instead.
How Transactions Work in Spring Boot + JPA
Transaction start — JpaTransactionManager begins or joins a transaction.
First SQL triggers borrow — Hibernate calls DataSource.getConnection(), HikariCP provides an idle connection.
Bind to transaction — The connection is bound to the current thread in TransactionSynchronizationManager.
Persistence context — Hibernate Session is linked to the transaction.
Work execution — SQL runs via the bound connection.
Commit/Rollback — Hibernate flushes changes, then commits/rolls back.
Unbind — Connection is detached from the transaction context.
Return to pool — HikariCP resets state and marks it idle.
When reusing a connection from the pool, HikariCP validates it and applies the new transaction’s settings.
Transaction Propagation from Application to Database
When a method in your application is annotated with @Transactional, Spring starts a logical transaction context within the application layer. This is managed by the PlatformTransactionManager and controls the transaction boundaries according to your propagation settings.
When the first database operation is executed, Spring:
Logical Transaction Creation (Application) — Creates the transaction context when the method starts.
First DB Call — Obtains a JDBC connection from the pool (e.g., HikariCP) and disables autocommit.
Bind Connection — Associates (binds) the JDBC connection with the current transaction context, thread-bound.
Transaction Propagation — Transaction context object propagate from application layer to DB layer. Inside the DB , operations execute within the same transaction context.
Execution Phase — All queries use this same connection until the transaction ends.
Commit/Rollback — Coordinates with the DB to commit or roll back.
Unbind and Return — Resets the connection to default state and returns it to the pool.
Logical vs Physical Transaction
Logical transaction: Exists in the application; defines the transactional scope and propagation behavior.
Physical transaction: Exists in the database; enforced via the bound JDBC connection.
Why it matters:
This binding ensures that every DB operation inside the annotated method participates in the same DB-level transaction, maintaining ACID guarantees across both the application and database.
Transaction Isolation Levels — Comparison
Isolation Anomalies Explained
Dirty Read — Transaction A reads uncommitted changes from Transaction B. If B rolls back, A has read invalid data.
Non-Repeatable Read — Transaction A reads the same row twice and gets different values because Transaction B updated it and committed in between.
Phantom Read — Transaction A runs a query twice with the same criteria but gets different row sets because Transaction B inserted/deleted matching rows.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(...) { }
Locking: Optimistic vs. Pessimistic
Optimistic Locking
Assumes conflicts are rare.
Uses a @Version column; Hibernate includes version in UPDATE/DELETE WHERE clause.
How Hibernate checks version:
No extra SELECT is run during update.
Hibernate sends SQL like:
update payment set amount=?, version=version+1 where id=? and version=?If the update count is 0, it means another transaction modified the row; Hibernate throws StaleObjectStateException.
Payment entity example:
@Version
@Column(name = COMMON_VERSION)
private Long version;
Pessimistic Locking
Acquires DB locks until commit.
Example:
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Payment> findById(Long id);
Can block and deadlock; use with lock timeouts.
Foreign Keys and Inserts Inside a Transaction
When executing inserts inside a transaction:
Referential integrity enforcement: Foreign key constraints are checked by the database, typically at flush time or commit. If the referenced parent row does not exist, a foreign key violation is raised.
Insert ordering: When using mapped relationships with cascading, Hibernate automatically orders insert statements so parent entities are inserted before children.
ID-only assignments: If you only set foreign key IDs without attaching entity references, Hibernate will not manage insert order — you must ensure the referenced row exists beforehand.
Deferred constraints: Some databases support deferring foreign key checks until commit; unless configured, expect immediate validation.
Atomicity: Within a single transaction, inserting parent and child entities together ensures both are committed or rolled back as a unit.
Example: Relationship-aware insert
@Entity
class Customer {
@Id @GeneratedValue
private Long id;
private String name;
// getters/setters
}
@Entity
class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
private String product;
// getters/setters
}
@Service
public class OrderService {
@Transactional
public void createCustomerAndOrder() {
Customer c = new Customer();
c.setName("Alice");
entityManager.persist(c);
Order o = new Order();
o.setCustomer(c); // ensures FK is valid
o.setProduct("Laptop");
entityManager.persist(o);
}
}
In the above, Hibernate will insert Customer first, then Order, satisfying the FK constraint before commit.
Example: ID-only assignment
Connection Pool Implications
One active transaction = one JDBC connection.
Long transactions or many REQUIRES_NEW calls can exhaust HikariCP.
Sample HikariCP Configuration in Spring Boot
Spring Boot uses HikariCP as the default connection pool. You can configure it in application.yml:
yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: myuser
password: secret
hikari:
maximum-pool-size: 20 # Max concurrent connections
minimum-idle: 5 # Idle connections kept ready
connection-timeout: 30000 # Wait time (ms) for a free connection
idle-timeout: 600000 # Max idle time (ms) before connection is released
max-lifetime: 1700000 # Lifetime (ms) before connection is retired
Key notes:
maximum-pool-size: Limit this based on your database’s capacity.
connection-timeout: Fail fast if the pool is exhausted.
max-lifetime: Keep slightly shorter than the database’s connection timeout to avoid abrupt disconnects.
Match pool size to expected concurrent DB transactions, not thread pool size (especially when using virtual threads).
Virtual Threads (JDK 21)
Virtual threads allow high concurrency, but DB concurrency is still capped by maximum-pool-size.
Excess tasks wait for connections.
Example: Async Transactions with saveAsynch
@Async("appTaskExecutor")
public CompletableFuture<Void> saveAsynch(...) {
contractService.findAllByContractNumbers(...); // @Transactional(readOnly = true)
paymentService.saveTrackedPayments(...); // @Transactional
}
@Async starts a new thread; no transaction is inherited.
Each service method must have its own @Transactional.
Best Practices
Service-layer boundaries.
Short transactions.
Use readOnly=true when possible.
Handle optimistic lock exceptions with retries.
Avoid @Transactional on private methods.
Use REQUIRES_NEW sparingly due to its impact on consistency and connection usage.
Key Takeaways
One transaction = one JDBC connection.
Hibernate checks optimistic lock via UPDATE row count, not an extra SELECT.
Isolation + locking define data consistency/concurrency.
Async DB work needs explicit transactions.
Virtual threads don’t remove pool limits.
Foreign key constraints are enforced within the transaction according to DB rules, affecting insert order and commit behavior.
No comments:
Post a Comment