Transactions Internals: How @Transactional Works and Propagates
What you fear most about a money transfer is not failure — it is "my balance went down but theirs never went up". One business operation usually writes several rows (debit one row, credit another row, plus a ledger entry). If the program crashes on the second row, or the power dies, or the balance check throws — can the first row be taken back? That is exactly what a transaction is for: it bundles those SQL statements into one parcel that either lands whole (COMMIT) or is cancelled whole (ROLLBACK), leaving no half-written state behind. Spring's @Transactional does precisely this — you write one annotation and the framework takes over "begin → run → commit or roll back → return the connection". This article covers three things: who presses COMMIT for you (the proxy plus the transaction manager), what happens to the transaction when one transactional method calls another (the seven propagation behaviours), and why your annotation sometimes dies in silence.
Five words, one line each (used throughout):
- Transaction: a "live-and-die-together contract" over a group of SQL statements — either all of them land, or it is as if none ever ran
- Rollback: undo every change already written on this connection, as if it had never been written
- Proxy: a wrapper shell the framework quietly puts in front of your object. Callers from outside hit the shell first, which is what gives the shell a chance to open and commit the transaction
- Connection pool: a set of pre-opened database channels that are borrowed and returned repeatedly. Borrow one, use one, give it back — and if none is free you queue
- Propagation: the rule that decides whether an inner method joins the meeting already in progress or books its own room
a transaction is like an ATM transfer. The machine debits account A, then credits account B. If the power cuts right after the debit, the money must not simply vanish — so the bank binds both moves into one action that is either fully recorded or fully voided, and your balance returns to where it started. Rollback means "this transfer never happened"; commit means "both ledgers changed at once".

That picture measures all seven propagations with two rulers: horizontally, "does the inner method merge into the outer transaction or run independently?"; vertically, "does this method require a transaction at all?". Next time a requirement says "the audit row must survive" or "this bulk import must not drag the main flow down", find your cell first and only then pick the enum constant. Section 5 turns it into code, and Section 10 lets you click through all seven yourself.
After this article you should be able to answer three questions:
- An annotation cannot commit anything by itself — so who presses COMMIT, and at what moment?
- When an outer method calls an inner one, why can the inner exception wipe out data the outer method already wrote? How do you save just one of those rows?
- My annotation is clearly there yet nothing happens — which of the five silent failure causes did I hit, and what single log setting proves it on the spot?
The word "transaction" sounds heavy, but its goal is plain: treat a group of operations as one indivisible whole — all succeed or all fail. The industry summarises it with four letters, ACID:
| Property | Plain meaning |
|---|---|
| Atomicity | Live and die together: commit all or roll back all |
| Consistency | Move from one valid state to another valid state |
| Isolation | Concurrent transactions do not disturb each other, as if queued |
| Durability | Once committed, a power cut cannot lose it |
Isolation is the hardest, because perfect isolation means serial execution and terrible performance. Hence four isolation levels: higher is safer, lower is more concurrent:
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Impossible | Possible | Possible |
| REPEATABLE READ | Impossible | Impossible | Possible (largely avoided by MySQL InnoDB) |
| SERIALIZABLE | Impossible | Impossible | Impossible |
MySQL's InnoDB defaults to REPEATABLE READ and uses MVCC plus gap locks to eliminate most phantom reads — the source of the claim that "MySQL is more solid at the same level than Oracle". Oracle / PostgreSQL default to READ COMMITTED.
isolation levels are the reading rules of a library. SERIALIZABLE lets one reader into the room at a time — perfectly quiet, endless queue. READ UNCOMMITTED lets you read other people's rough drafts, which is fast but may show you lines later crossed out. REPEATABLE READ hands you a photograph taken the moment you walked in: however the room changes afterwards, you keep seeing that photo. In MySQL the photo is called MVCC (multi-version concurrency control: every row keeps several historical versions and each transaction reads the one matching its own point in time).
Look how much boilerplate a single transfer needs without the annotation (JDBC version):
public void transfer(Long from, Long to, BigDecimal amount) { Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 1. disable auto-commit, begin accountDao.deduct(conn, from, amount); // 2. debit accountDao.add(conn, to, amount); // 3. credit conn.commit(); // 4. commit on success } catch (Exception e) { conn.rollback(); // 5. rollback on failure throw new RuntimeException(e); } finally { conn.setAutoCommit(true); conn.close(); // 6. return the connection }}Six steps, one try-catch-finally, copied for every transactional method — and easy to botch a step (say, forget the rollback). With @Transactional:
@Transactionalpublic void transfer(Long from, Long to, BigDecimal amount) { accountDao.deduct(from, amount); accountDao.add(to, amount); // commit, rollback and connection return are all handled for you}One annotation kills six steps of boilerplate. So here is the question: an annotation is just metadata; the JVM will not commit anything because of it. Who, and when, ran those six steps for us? That is what the next section dissects.
Before dissecting it, confirm the machinery is actually on your classpath — the number-one reason a @Transactional dies silently is that no transaction manager exists at all. Do not paste somebody else's pom; tick the boxes and see which layer each line supports. Check Data JPA or JDBC and watch the matching implementation classes appear in the tree; check MySQL alone and the annotation is pure decoration.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Layer one: the proxy layer — turning the annotation into an aspect. @EnableTransactionManagement switches on transaction management. It imports ProxyTransactionManagementConfiguration, which registers a BeanFactoryTransactionAttributeSourceAdvisor. This advisor scans every bean carrying @Transactional and generates an AOP proxy for it.
@Configuration@EnableTransactionManagement // the transaction switch (already on in Boot auto-config)public class TxConfig { // The key is that it imports a config registering an advisor: // BeanFactoryTransactionAttributeSourceAdvisor // -> the advisor matches @Transactional methods and proxies them}Layer two: the interception layer — TransactionInterceptor runs inside the proxy. When transfer() is called from outside, the proxy is entered first, and TransactionInterceptor.invoke() wraps the target method "around": open a transaction before, commit on normal return, roll back on a thrown exception.
// The core of TransactionInterceptor (heavily simplified)public Object invoke(MethodInvocation invocation) throws Throwable { TransactionAttribute attr = getTransactionAttribute(invocation); // read annotation metadata return invokeWithinTransaction(invocation.getMethod(), targetClass, () -> { // open -> invoke target -> commit/rollback, all orchestrated here return invocation.proceed(); });}Layer three: the execution layer — PlatformTransactionManager actually touches the database. It is the lowest executor, with just three methods: getTransaction (open), commit, rollback. Spring Boot wires up a DataSourceTransactionManager by default.
public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition); // open / join void commit(TransactionStatus status); // commit void rollback(TransactionStatus status); // rollback}Inside it does three things: borrow a connection from the pool -> disable auto-commit (setAutoCommit(false)) -> commit or roll back and return the connection. It also binds that connection to TransactionSynchronizationManager (a ThreadLocal underneath) so later work on the same thread reuses it — the key to the next section.

The animation gives the panorama, but "at which step is a connection borrowed, at which step does my code finally run" is not something reading alone installs — you have to click. The left pane below is those seven lines; the right pane refreshes the live variables and the call stack. Press step repeatedly, watch boxes ④ and ⑤: a connection is not borrowed per call but only when this transaction is newly created, and box ⑥ — your business code runs for the first time only there, with five steps turning around it.
orderService.create(order); // ① the call lands on the proxy, not on your objectTransactionInterceptor.invoke() // ② the around advice: all transaction orchestration lives heretm.getTransaction(def) // ③ first ask propagation: join an existing one or createconn = dataSource.getConnection() // ④ only the create branch borrows a connectionconn.setAutoCommit(false); bind(holder) // ⑤ disable auto-commit and bind it to this threadresult = method.invoke(target) // ⑥ your business code runs for the first time herecommit / rollback -> unbind -> returnConn // ⑦ commit or roll back, then unbind and hand it back| orderService | OrderServiceImpl$$SpringCGLIB$$0 |
| caller thread | http-nio-8080-exec-7 |
OrderController.createproxy.createA common question: if a transaction calls a DAO ten times, does it fetch ten connections? No. When the transaction opens, the manager already bound that connection to the current thread's ThreadLocal:
// On transaction open (simplified DataSourceTransactionManager.doBegin)Connection conn = dataSource.getConnection();conn.setAutoCommit(false);// The key: bind the connection to the current threadTransactionSynchronizationManager.bindResource(dataSource, new ConnectionHolder(conn));// Later, any DAO fetching a connection (simplified DataSourceUtils.getConnection)ConnectionHolder holder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);if (holder != null) { return holder.getConnection(); // reuse the connection bound to this thread}return dataSource.getConnection(); // create one only without a transaction- Same-thread reuse: while inside the transaction, a DAO always gets the bound connection
- Cross-thread break: on a new thread, the ThreadLocal misses and a new connection is created — so work in the new thread is not in the original transaction. This explains the later "multi-threading breaks transactions"
- Return timing: after commit or rollback the ThreadLocal is unbound and the connection goes back to the pool
Put the two columns side by side and both "invisible" phenomena — Section 4's and Section 6's — explain themselves at once: the left is the default case (same thread, same connection, shared fate), while the right covers changing threads and changing transactions. The second connection REQUIRES_NEW borrows and the orphan connection an @Async write runs on both live in the right column:

the right column is not an "exception" — it is another perfectly normal state. It carries one warning: to decide "are these two writes in the same transaction", do not look at whether they are in the same method; look at which thread they run on and which connection they are bound to.
Propagation answers one question: when a transactional method calls another, how should the transaction be handled? There are seven:
| Propagation | No transaction | With a transaction | Typical use |
|---|---|---|---|
REQUIRED (default) | Create one | Join the current one | The vast majority of writes |
REQUIRES_NEW | Create one | Suspend the current, start a new one | Standalone audit logs, message records |
NESTED | Create one | Create a nested (savepoint) child | Roll back only the child on partial failure |
SUPPORTS | No transaction | Join the current one | Query methods |
NOT_SUPPORTED | No transaction | Suspend the current, run non-transactionally | Heavy IO that ignores consistency |
MANDATORY | Throw | Join the current one | Require an existing transaction from above |
NEVER | No transaction | Throw | Forbid running inside a transaction |
Three deserve side-by-side memory:
- REQUIRED (default): inner and outer are one transaction. Once the inner marks rollback, the whole outer rolls back too — "live and die together".
- REQUIRES_NEW: two independent transactions. The outer is suspended, the inner runs in a fresh transaction; their commit/rollback does not affect each other.
- NESTED: the inner is a nested child of the outer, implemented via a database savepoint. An inner failure rolls back to the savepoint; the outer may continue or roll back everything.
propagation is simply the etiquette of meetings. REQUIRED says "you are already in a meeting, so I will join it and we leave together with one shared resolution" — if anyone in that room throws a fit, every minute of the meeting is void. REQUIRES_NEW says "pause yours, I will book the room next door, my outcome gets filed there, then you resume" — which is exactly why two rooms are occupied at once and two connections get borrowed. NESTED is a breakout group inside the main session: if the breakout collapses, only its own statements are retracted (rollback to the savepoint), while the plenary can still be adjourned entirely (outer rollback). NOT_SUPPORTED means "carry on with your meeting, I am stepping out to make a phone call" — nothing I do ends up in the minutes.
Text alone makes "merge" versus "separate room" easy to memorise backwards, so the animation below draws both paths side by side. Watch the middle step: REQUIRES_NEW keeps conn1 and conn2 alive simultaneously, while REQUIRED never leaves one connection:

See the difference with the classic order + audit-log case:
@Servicepublic class OrderService { @Autowired private OrderDao orderDao; @Autowired private AuditService auditService; @Transactional // outer: REQUIRED public void create(Order order) { orderDao.insert(order); try { auditService.log("created order " + order.getId()); // inner: REQUIRES_NEW } catch (Exception e) { // audit failure must not sink the order: catch and swallow System.out.println("audit failed, ignored"); } // if this throws, the whole outer rolls back }}@Servicepublic class AuditService { // Independent transaction: it commits as long as this line runs, regardless of the outer @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(String message) { auditDao.insert(message); }}# If AuditService.log used REQUIRED (default):transaction: BEGIN -> insert order -> insert audit -> COMMIT# if order later throws -> both inserts roll back (the log is gone)# With REQUIRES_NEW:transaction A: BEGIN -> insert order --suspend A--transaction B: BEGIN -> insert audit -> COMMITtransaction A: --resume A-- (continue) -> COMMIT or ROLLBACK# A rolling back does not touch B, so the audit log survivesPoint: REQUIRES_NEW needs a fresh database connection (the original stays suspended). If the outer holds pool resources and concurrency is high, many REQUIRES_NEW calls can exhaust the pool — a hidden cost, so do not overuse it.
How large that hidden cost is depends on one number. Drag it and you will see why "nobody touched the transaction code, yet the endpoint started stalling": one connection per request is normal, two is REQUIRES_NEW, and a pool capped at 2 cannot fit two concurrent requests each suspending an inner transaction.
- 10 is literally HikariCP's default — that number was not picked at random
- With pure REQUIRED one request holds one connection, so 10 covers 10 in flight
- With two or three REQUIRES_NEW levels, estimate worker threads x deepest nesting first
- Before touching this number, ask whether the transaction can be shortened — that is usually the cause
A dead @Transactional is a fixture of interviews and incidents. This table covers most cases:
| Scenario | Cause | Fix |
|---|---|---|
Self-invocation in the same class (this.method()) | Goes through the raw object, never the proxy | Split into another bean; inject your own proxy; or use AopContext |
Method is not public | Proxies enhance public methods by default | Make it public |
| Exception swallowed by catch | The proxy sees no exception, so it cannot roll back | Do not swallow; or TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| A checked exception is thrown | Only RuntimeException / Error roll back by default | @Transactional(rollbackFor = Exception.class) |
| Work inside a new thread | The new thread lacks the original ThreadLocal connection | Open a transaction in that thread, or do everything on the main thread |
| Engine does not support transactions | e.g. MyISAM | Switch to InnoDB |
| return in finally | return masks the exception; the proxy sees success | Never return in finally |
| Not managed by Spring | A newed object that never entered the container | Let the container manage it |
Two of these deserve detail:
First, self-invocation. It uses this, bypassing the proxy:
@Servicepublic class UserService { public void outer() { this.inner(); // ❌ goes through this, annotation is dead! } @Transactional public void inner() { // the transaction should open here, but the proxy is never reached }}@Servicepublic class UserService { @Autowired private UserService self; // inject your own proxy public void outer() { self.inner(); // ✅ through the proxy, transaction works } @Transactional public void inner() { /* ... */ }}Second, exception type mismatch. By default only RuntimeException and subclasses roll back; a checked throws IOException does not roll back:
// ❌ checked exceptions do not roll back by default@Transactionalpublic void create() throws IOException { dao.insert(); throw new IOException("boom"); // the transaction still commits, data is dirty}// ✅ declare the exception types that trigger rollback@Transactional(rollbackFor = Exception.class)public void create() throws IOException { dao.insert(); throw new IOException("boom"); // now it rolls back}Trap: these two account for roughly 80% of "transaction did not work" production problems. Remember two lines: self-invocation skips the proxy; checked exceptions do not roll back by default.
The last two rows of that table (swallowed exception, checked exception) share one symptom — the data committed obediently and nothing was logged — yet they come from different gates. Watch the animation take the adjudication in order, then click through the diagram below to find which gate caught you:

Two rarely used but worthwhile attributes:
// readOnly=true: declare it read-only for optimisation; timeout is in seconds@Transactional(readOnly = true, timeout = 5)public List<Order> listOrders(Long userId) { return orderDao.findByUser(userId);}readOnly = true: hints "this transaction only reads". In Hibernate it disables dirty checking and flush, saving some overhead; but it is not a hard guarantee, so do not expect it to block accidental writes (whether it applies depends on the implementation and DB). A real hard read-only relies on database account permissions.timeout: transaction timeout in seconds; on expiry it throwsTransactionTimedOutExceptionand rolls back, guarding against a slow SQL holding a connection.
Attention: in most implementations readOnly is only a hint, not a guarantee of "really read-only". For mandatory read-only, use a read-only database account rather than relying on the attribute alone.
One line captures the binding: @Transactional only affects the connection managed by the PlatformTransactionManager currently in play. With a single default datasource there is one datasource and one manager, and everything works.
Once you configure multiple datasources (DB A + DB B), @Transactional by default opens a transaction on only one of them; writes on the other are not in the same transaction and will not roll back with it. For cross-database atomic commit you must bring in distributed transactions:
- Seata (AT mode): coordinates branch transactions via a global transaction ID, low intrusion — the mainstream choice
- Local message table: write business data plus a message row (same local transaction), then a scheduler delivers it for eventual consistency
- Best-effort notification / TCC: for scenarios with different consistency needs or that require resource reservation
distributed transactions are a topic of their own; here is just a mental model — single DB and tables rely on local transactions, while cross-DB and cross-service must rely on a global transaction or eventual consistency. Do not expect one @Transactional to span two databases.
Transactions are "invisible", but turning on logs makes them obvious. Spring's AbstractPlatformTransactionManager prints the open / commit / rollback:
logging: level: org.springframework.transaction: TRACE # turn on the transaction internals log org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUGYou will then see a whole "life of a transaction", matching the seven steps above:
Creating new transaction with name [...OrderService.create]: PROPAGATION_REQUIRED, ISOLATION_DEFAULT # 1. decide propagation, create transactionAcquired Connection [HikariProxyConnection@...] for JDBC transactionSwitching JDBC Connection [...] to manual commit # 2. disable auto-commitInitiating transaction commit # 3. prepare to commitCommitting JDBC transaction on Connection [...] # 4. the real COMMITReleasing JDBC Connection [...] back to DataSource # 5. return the connectionOn an exception the path differs:
Initiating transaction rollbackRolling back JDBC transaction on Connection [...]Releasing JDBC Connection [...] back to DataSourceTip: the most direct way to check "did the transaction actually take effect" is to turn on this log. Seeing Creating new transaction / Initiating transaction commit means the proxy and the transaction manager are both working.
Expand those two YAML lines into a configuration that actually runs: tick datasource, JPA, logging and profile, and see where the transaction-internals logger sits next to open-in-view and show-sql — note that the JPA section also emits spring.jpa.hibernate.ddl-auto, and the TransactionRequiredException in Section 14 is born from a bad combination of exactly these switches.
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
Turning the log on is only step one — the real skill is reading what each line proves. These six sentences account for most transaction triage. Play a round: pick a log line on the left, then what it actually tells you on the right; a wrong pick explains itself on the spot, which beats memorising a table.
Theory done; now a reproducible run. The demo below uses an order table (t_order) and a log table (t_log); switch options and watch their final state:
Point: in the demo, focus on the "outer rollback" option — with the default `REQUIRED`, both tables **roll back together**; with `REQUIRES_NEW`, the log table **keeps a row** while the order table rolls back. That is the most tangible production difference between the two propagations.
The demo above has only three settings — enough to see "roll back together" versus "the log survives", but not the full seven-propagation picture, nor how many connections each one eats. The five labs below run in the order "see the whole table → see the most common silent death → see the price → see where the boundary belongs → see the aspects fighting for order".
The first is the console for all seven propagations. Suggested route: required to watch a merge, requires_new for suspend-and-resume, then nested to see a savepoint roll back only the child segment, and finally supports / not_supported to see code that lives fine without any transaction:
The second answers the thing beginners find hardest to derive alone: why this.method() inside the same class turns an annotation into decoration. Pick "Self-invocation" and notice that the advice-chain column goes empty — the transaction manager did not go on strike, the call never reached the proxy:
The third turns that one line at the end of Section 5 — "REQUIRES_NEW has a hidden cost" — into numbers you can see. Pick "Queue at the limit" and "Wait timeout" to watch the outer transaction refuse to release its connection while the inner one waits for a second one:
The fourth settles the question reviewers argue about most: which layer should carry @Transactional. On the controller, one HTTP request holds a connection while it sleeps through every slow call; on the repository, it is too fine-grained and one business action shatters into several unrelated commits. Switch to "Cost of skipping a layer" to watch a transaction torn open by a layer-skipping call:
The fifth is the close relative of Section 8's multi-datasource topic and the standard answer to "does the transaction aspect or my own @Aspect wrap the other". Get the order wrong and you will see COMMIT time inside your latency metric, or an authorisation check running inside the transaction that already wrote half a row when it failed:
Once you are bored of buttons, type the commands yourself. This console talks to the same in-browser Java kernel and every reply is computed there — start with beans to confirm a transaction manager really is in the container, then run the three endings and two propagations one by one:
run lab tx rollback immediately followed by lab tx requires_new — the first empties both tables, the second leaves the audit row sitting there anyway. Same business method; the only difference is one propagation constant.
A wrong propagation never fails at compile time. It surfaces in production with two opposite symptoms: what needed independence stayed merged (the audit row vanished with the business rollback), or what should have merged took a second connection anyway and drained the pool. This sandbox turns "what the inner method wants" into a single switch; each position shows the transaction boundary, the connection footprint and the failure consequence side by side:
@Transactional ← nothing extra to write, REQUIRED is the defaulttransactions: 1 connections held: conn1 x 1inner throws -> outer rolls back too ✔ exactly what you wanted# test: would an inner failure leave dirty business data? yes -> merge them
the four positions map onto four real demands — "share the fate", "must survive", "do not drag me down", "read-only without locking". Decide in that order: first ask "would an inner failure hurt me?", then ask "am I willing to hold a second connection?" — never the other way round, which is how people end up memorising enum names.
A warm-up straight from the number-one row of the failure table in Section 6:
Now a combined question threading Section 5's propagation rules together with the connection cost from Section 11:
The most demoralising moment for a beginner is "I wrote the annotation, the data did not roll back, and there was not even an error". Every fragment below can be copied verbatim into a search engine.
First, the five-cause checklist for a @Transactional that appears not to work — walk it in order and you will usually land on your case:
- Self-invocation:
this.method()inside the same class, bypassing the proxy (Section 6) - Method is not public: proxies enhance public methods by default, so
protected/privateis the same as writing nothing - The bean never entered the container: an object you
new XxxService()yourself has no shell, so the annotation is decoration - The exception is swallowed or re-wrapped: you
catchit without rethrowing, or convert it into a normal return value, so the proxy concludes "success" - The exception type is outside the rollback rules: checked exceptions (
IOException/SQLException) do not roll back by default — you needrollbackFor
| Error text (fragment) | Real cause | 30-second fix | Where to dig deeper |
|---|---|---|---|
TransactionRequiredException: Executing an update/delete query (JPA) | A write ran in a context with no transaction, and the EntityManager refuses it | Annotate that Service method with @Transactional; for a custom @Modifying repository query, make sure the caller is transactional too | Sections 3 and 6 |
Cannot transaction on closed connection / Connection has been closed | The transaction had already ended and the connection was returned, yet code still calls commit() on it or lazily loads through it (classically self-invocation plus an async thread) | Check whether a new thread reused the original transaction; open its own transaction inside the async task; also look for an overly short pool maxLifetime | Sections 4 and 6 |
getConnection was not registered for synchronization because DataSource is not transactional | The DataSource the transaction manager controls is not the DataSource the connection came from (a multi-datasource slip) | Point @Transactional(transactionManager = "...") at the right manager; two DataSources can never be covered by one local transaction | Section 8 |
| No exception at all, yet data stayed in the table after a checked exception was thrown | Only RuntimeException / Error roll back by default, so throws IOException counts as a normal return | Write @Transactional(rollbackFor = Exception.class) and pin that exact line in your team conventions | Section 6 |
NestedTransactionNotSupportedException: Nested transactions are not supported by this JDBC driver | You chose NESTED, but the driver/pool offers no savepoints, or a wrapper hid the savepoint API | Fall back to REQUIRES_NEW (slightly different semantics but it runs), or remove the wrapping layer; MySQL Connector/J with InnoDB does support savepoints | Section 5 |
TransactionTimedOutException: Transaction timed out: deadline was ... | The timeout attribute fired and the transaction was force-rolled-back | Look for a slow SQL or an HTTP call inside the transaction; move external IO out instead of blindly raising the timeout | Section 7 |
Connection is busy: result set is not closed, or the pool saturated and requests queued for ages | A long transaction kept a connection hostage, or a manually fetched connection was never returned | Enable leakDetectionThreshold to find the leak; give long read-only queries their own short transaction; reconcile pool size with concurrency | Article 28 |
to settle "did my transaction open at all", look first for Creating new transaction in the TRACE log from Section 9. No such line means the proxy never caught you — stop debugging anything else. Such a line means the transaction really ran, so go check exception types and propagation.
The first row above, TransactionRequiredException, is the one worth practising, because it puts "proxy present" and "transaction absent" in the same stack — a rare and misleading combination. Do not read the answer; click the frame you think is guilty:
A nightly job zeroes the statistics table. Everything passed locally, and the very first production run threw this with a suspiciously short business stack.
Reproduce "all or nothing" with two in-memory accounts and watch a rollback happen. Fully runnable code (no database — the logs do the talking):
package com.example.tx;import java.util.ArrayList;import java.util.List;/** A pretend DAO: every write lands in a buffer first; commit flushes it, rollback discards it */public class AccountDao { private final List<String> buffer = new ArrayList<>(); private final List<String> disk = new ArrayList<>(); public void deduct(long id, int amount) { buffer.add("UPDATE account SET money=money-" + amount + " WHERE id=" + id); } public void add(long id, int amount) { buffer.add("UPDATE account SET money=money+" + amount + " WHERE id=" + id); } public void commit() { disk.addAll(buffer); buffer.clear(); } public void rollback() { buffer.clear(); } public List<String> disk() { return disk; }}package com.example.tx;public class TransferService { private final AccountDao dao = new AccountDao(); /** The six manual steps, mirroring the JDBC code in Section 2 */ public void transfer(long from, long to, int amount, boolean blowUp) { System.out.println("[tx] BEGIN"); // 1. begin try { dao.deduct(from, amount); // 2. debit if (blowUp) throw new IllegalStateException("balance check failed"); dao.add(to, amount); // 3. credit dao.commit(); // 4. commit System.out.println("[tx] COMMIT"); } catch (RuntimeException e) { dao.rollback(); // 5. rollback System.out.println("[tx] ROLLBACK -> " + e.getMessage()); } System.out.println("[pool] connection returned"); // 6. return the connection System.out.println("[disk] " + dao.disk()); } public static void main(String[] args) { TransferService svc = new TransferService(); svc.transfer(1, 2, 100, false); System.out.println("---"); svc.transfer(1, 2, 50, true); }}Expected output (run it yourself before reading on):
[tx] BEGIN[tx] COMMIT[disk] [UPDATE account SET money=money-100 WHERE id=1, UPDATE account SET money=money+100 WHERE id=2]---[tx] BEGIN[tx] ROLLBACK -> balance check failed[disk] [UPDATE account SET money=money-100 WHERE id=1, UPDATE account SET money=money+100 WHERE id=2]Look at the second run: after BEGIN only the debit was written before it blew up, and the disk gained not even half a row — that is atomicity. Flip blowUp to false and both updates appear together.
Goal: turn the hand-rolled transaction above into one where "the debit succeeded but the credit failed" still leaves the books consistent, while proving propagation at the same time.
Hint: change exactly two things — ① add an AuditDao with its own buffer/disk pair so it behaves as an independent "child transaction"; ② in transfer's catch branch, commit the audit first, then roll back the business, mimicking REQUIRES_NEW.
You should observe three things: after the outer ROLLBACK, the audit disk still gained a row (which is the correct answer to the combined question in Section 13); if you instead make the audit share the business buffer, that row vanishes (equivalent to REQUIRED); and once you split the logic across two classes that call each other, you will feel why "the proxy only acts at the entry point of a call" has nothing to do with self-invocation here — there is no proxy at all, only manual orchestration.
Build a small real Spring Boot app: AccountService.transfer() moves money, writes an audit row and sends one "in-app notification" (stub it with a method that sleeps 300ms). Your job is to place the transaction boundaries so they are both correct and connection-frugal.
Acceptance checklist:
- [ ] Both account writes sit in one
@Transactionalmethod, and a mid-way exception restores both balances - [ ] The audit row uses
REQUIRES_NEW; force the transfer to fail and the log table still contains that row - [ ] The slow "notification" sits outside the transaction (moved to
NOT_SUPPORTEDor to a post-commit listener), and you can explain why it must not hold a connection while sleeping 300ms - [ ] Every
catchthat could swallow an exception either declaresrollbackFor = Exception.classor callssetRollbackOnly()explicitly - [ ] With
logging.level.org.springframework.transaction: TRACEon, paste one success and one rollback snippet and point out whereCreating new transaction,Participating in existing transactionandSuspending current transactioneach appear - [ ] Under 50 concurrent transfers, HikariCP's
activeandawaitingmetrics do not keep climbing (proof the suspension did not drain the pool)
without looking anything up, name the three links in the relay that makes @Transactional work — the AOP proxy, TransactionInterceptor, PlatformTransactionManager. Miss one and reread Section 3.
can you explain REQUIRED / REQUIRES_NEW / NESTED using the meeting analogy? The keywords must be merge, pause and book another room and savepoint, and you should mention the second connection out loud.
can you list all five silent causes of a dead @Transactional within thirty seconds? If not, go back to the checklist in Section 14 and then to the table in Section 6.
does a checked exception trigger a rollback by default, and what is the exact annotation attribute that fixes it? Get this wrong and redo Tier 1 of Section 16.
what single log setting tells you whether your transaction opened at all? The answer should be the line Creating new transaction.
annotations never commit — the proxy does; one connection, one thread; self-invocation always bypasses; checked exceptions never roll back unless rollbackFor says so; suspend-and-restart buys you a durable audit row at the price of a second connection.
Compress this article into five lines — the annotation works because an AOP proxy + TransactionInterceptor + PlatformTransactionManager relay across three layers; one transaction shares one connection because it is bound to a ThreadLocal; of the seven propagations, REQUIRED is one transaction, REQUIRES_NEW is independent, NESTED is a savepoint; 80% of failures come from "self-invocation skips the proxy" and "checked exceptions do not roll back by default"; an invisible transaction is instantly visible with TRACE logs. Master these five and you can spot a wrong transaction at a glance during review.