Transactions Internals: How @Transactional Works and Propagates

bee2026-10-0851 min read0 views
Why does one annotation commit or roll back for you? Proxy interception, connection binding to ThreadLocal, all seven propagation behaviors, dead-annotation scenarios and a peek at distributed transactions.
1 / 148
Section
0. The 30-second version
2 / 148

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.

3 / 148

Five words, one line each (used throughout):

4 / 148
  • 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
5 / 148
类比

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".

6 / 148
Diagram
Figure · Choosing among the seven propagation behaviours
Figure · Choosing among the seven propagation behaviours
7 / 148

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.

8 / 148

After this article you should be able to answer three questions:

9 / 148
  • 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?
10 / 148
Section
1. ACID and an isolation-level cheat sheet
11 / 148

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:

12 / 148
Table
PropertyPlain meaning
AtomicityLive and die together: commit all or roll back all
ConsistencyMove from one valid state to another valid state
IsolationConcurrent transactions do not disturb each other, as if queued
DurabilityOnce committed, a power cut cannot lose it
13 / 148

Isolation is the hardest, because perfect isolation means serial execution and terrible performance. Hence four isolation levels: higher is safer, lower is more concurrent:

14 / 148
Table
Isolation levelDirty readNon-repeatable readPhantom read
READ UNCOMMITTEDPossiblePossiblePossible
READ COMMITTEDImpossiblePossiblePossible
REPEATABLE READImpossibleImpossiblePossible (largely avoided by MySQL InnoDB)
SERIALIZABLEImpossibleImpossibleImpossible
15 / 148
Note

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.

16 / 148
类比

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).

17 / 148
Section
2. From the pain of manual transactions to @Transactional
18 / 148

Look how much boilerplate a single transfer needs without the annotation (JDBC version):

19 / 148
java
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    }}
20 / 148

Six steps, one try-catch-finally, copied for every transactional method — and easy to botch a step (say, forget the rollback). With @Transactional:

21 / 148
java
@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}
22 / 148

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.

23 / 148

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.

24 / 148
Generator
GeneratorWhich dependency lines the transaction machinery needspom.xml3 / 6
Tick JDBC alone first to get the minimal working set, then add JPA / MySQL / H2; note the scope on the last Test line — it is exactly what article 34's integration tests rely on
Output
<?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>
Why each choice matters
parentInheriting 3.3.4 starter-parent means no spring-boot-starter-* needs a version; the moment someone adds an explicit version to one starter, that one wins — the most common source of dependency drift.
JDBCJust the template class and HikariCP — the minimum when you refuse an ORM.
H2 内存库Runtime scope so local runs and tests need no real server; exclude it from the prod profile.
Testscope=test: @SpringBootTest, MockMvc and AssertJ live here; without it @Test is unresolved.
25 / 148
Section
3. How it works: three layers of machinery
26 / 148
Diagram
Figure 1 · Four layers behind one annotation
Figure 1 · Four layers behind one annotation
27 / 148

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.

28 / 148
java
@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}
29 / 148

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.

30 / 148
java
// 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();    });}
31 / 148

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.

32 / 148
java
public interface PlatformTransactionManager {    TransactionStatus getTransaction(TransactionDefinition definition);  // open / join    void commit(TransactionStatus status);                               // commit    void rollback(TransactionStatus status);                             // rollback}
33 / 148

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.

34 / 148
Animation
Animation · Life of a transaction
Animation · Life of a transaction
35 / 148

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.

36 / 148
Stepper
StepperStep through TransactionInterceptor: where the COMMIT key is actually pressed1 / 7
Press ①→⑦. Box ③ decides join-or-create, box ⑥ is finally your code, box ⑦ is that COMMIT
Code under debug
1orderService.create(order); // ① the call lands on the proxy, not on your object
2TransactionInterceptor.invoke() // ② the around advice: all transaction orchestration lives here
3tm.getTransaction(def) // ③ first ask propagation: join an existing one or create
4conn = dataSource.getConnection() // ④ only the create branch borrows a connection
5conn.setAutoCommit(false); bind(holder) // ⑤ disable auto-commit and bind it to this thread
6result = method.invoke(target) // ⑥ your business code runs for the first time here
7commit / rollback -> unbind -> returnConn // ⑦ commit or roll back, then unbind and hand it back
Variables now
orderServiceOrderServiceImpl$$SpringCGLIB$$0
caller threadhttp-nio-8080-exec-7
Call stack
1OrderController.create
2proxy.create
1Establish the premise first: what was injected is not an object you newed but the stand-in the container built. If the class name contains SpringCGLIB, the other six steps get a chance to run; if it prints a clean com.example.OrderServiceImpl, no proxy was ever created and the annotation equals a comment.
37 / 148
Section
4. Connection lifecycle: why it is never re-fetched
38 / 148

A 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:

39 / 148
Code
Codejava
// 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
Notes
  • 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
40 / 148

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:

41 / 148
Diagram
Figure · How a thread remembers one connection
Figure · How a thread remembers one connection
42 / 148
Note

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.

43 / 148
Section
5. The seven propagation behaviours
44 / 148

Propagation answers one question: when a transactional method calls another, how should the transaction be handled? There are seven:

45 / 148
Table
PropagationNo transactionWith a transactionTypical use
REQUIRED (default)Create oneJoin the current oneThe vast majority of writes
REQUIRES_NEWCreate oneSuspend the current, start a new oneStandalone audit logs, message records
NESTEDCreate oneCreate a nested (savepoint) childRoll back only the child on partial failure
SUPPORTSNo transactionJoin the current oneQuery methods
NOT_SUPPORTEDNo transactionSuspend the current, run non-transactionallyHeavy IO that ignores consistency
MANDATORYThrowJoin the current oneRequire an existing transaction from above
NEVERNo transactionThrowForbid running inside a transaction
46 / 148

Three deserve side-by-side memory:

47 / 148
  • 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.
48 / 148
类比

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.

49 / 148

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:

50 / 148
Animation
Animation · REQUIRED joins versus REQUIRES_NEW suspending and resuming
Animation · REQUIRED joins versus REQUIRES_NEW suspending and resuming
51 / 148

See the difference with the classic order + audit-log case:

52 / 148
java
@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);    }}
53 / 148
Code
Codetext
# 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 survives
Notes

Point: 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.

54 / 148

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.

55 / 148
Tuner
TunerPool size: one number that governs transactions and concurrency together
spring.datasource.hikari.maximum-pool-size
10connectionsNow 1 – 200
The default: a sane start for modest traffic
  • 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
Waiting for a connection12%
Pool utilisation74%
Ask first 'how many connections can one request hold at once', then set the ceiling — reverse the order and you are using pool size to hide a long transaction.
56 / 148
Section
6. The full list of ways @Transactional silently dies
57 / 148

A dead @Transactional is a fixture of interviews and incidents. This table covers most cases:

58 / 148
Table
ScenarioCauseFix
Self-invocation in the same class (this.method())Goes through the raw object, never the proxySplit into another bean; inject your own proxy; or use AopContext
Method is not publicProxies enhance public methods by defaultMake it public
Exception swallowed by catchThe proxy sees no exception, so it cannot roll backDo not swallow; or TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
A checked exception is thrownOnly RuntimeException / Error roll back by default@Transactional(rollbackFor = Exception.class)
Work inside a new threadThe new thread lacks the original ThreadLocal connectionOpen a transaction in that thread, or do everything on the main thread
Engine does not support transactionse.g. MyISAMSwitch to InnoDB
return in finallyreturn masks the exception; the proxy sees successNever return in finally
Not managed by SpringA newed object that never entered the containerLet the container manage it
59 / 148

Two of these deserve detail:

60 / 148

First, self-invocation. It uses this, bypassing the proxy:

61 / 148
java
@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    }}
62 / 148
java
@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() { /* ... */ }}
63 / 148

Second, exception type mismatch. By default only RuntimeException and subclasses roll back; a checked throws IOException does not roll back:

64 / 148
Code
Codejava
// ❌ 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}
Notes

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.

65 / 148

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:

66 / 148
Animation
Animation · Commit or roll back: the interceptor's chain of judgement
Animation · Commit or roll back: the interceptor's chain of judgement
67 / 148
Diagram
FlowHow the proxy decides whether to commit once an exception is thrown1 / 6
Go ①→⑥. Boxes ③④ are the default rules, ⑤ is your rollbackFor, and ⑥ is the sneakiest road
→
→
→
→
→
① The method returns normally
No adjudication is needed at all: the proxy sees a return value and takes the commit branch. This box states the counter-intuitive rule — **committing is the default outcome; rollback is the exception** — so 'the row made it into the table' proves nothing about whether your exception was handled.
All clearThe order in one line: can it escape the method body -> is the escaped type on the list -> on the list means rollback, off it means commit.
68 / 148
Section
7. Read-only transactions and timeouts
69 / 148

Two rarely used but worthwhile attributes:

70 / 148
Code
Codejava
// 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);}
Notes
  • 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 throws TransactionTimedOutException and 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.

71 / 148
Section
8. Multi-datasource transactions and a peek at distributed transactions
72 / 148

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.

73 / 148

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:

74 / 148
  • 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
75 / 148
Note

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.

76 / 148
Section
9. Observability: turn on TRACE logs to watch the lifecycle
77 / 148

Transactions are "invisible", but turning on logs makes them obvious. Spring's AbstractPlatformTransactionManager prints the open / commit / rollback:

78 / 148
yaml
logging:  level:    org.springframework.transaction: TRACE     # turn on the transaction internals log    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
79 / 148

You will then see a whole "life of a transaction", matching the seven steps above:

80 / 148
text
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 connection
81 / 148

On an exception the path differs:

82 / 148
Code
Codetext
Initiating transaction rollbackRolling back JDBC transaction on Connection [...]Releasing JDBC Connection [...] back to DataSource
Notes

Tip: 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.

83 / 148

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.

84 / 148
Generator
GeneratorA complete troubleshooting config: datasource + JPA + loggingapplication.yml2 / 5
Tick Logging alone first to get the full version of the two lines above; then add DataSource and see that HikariCP's ceilings are the very slider in Section 5; ticking Profile shows how far apart dev and prod transaction log levels should be
Output
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 }
Why each choice matters
datasourcePool settings only apply here; constructing HikariDataSource in code ignores every one of them.
loggingLevels work per package; root=DEBUG floods you with third-party output — never in production.
85 / 148

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.

86 / 148
Match
MatchTransaction TRACE logs: what each line actually provesMatched 0/6 · Missed 0
All six come from real AbstractPlatformTransactionManager and DataSourceTransactionManager output. Do not use positions — both columns are shuffled
Pick a card on the left first
87 / 148
Section
10. Run propagation behaviour yourself
88 / 148

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:

89 / 148
Kernel lab
TeaVMRun the propagation behaviours yourselfidle
Switch through 'normal commit', 'outer rollback' and 'REQUIRES_NEW' and compare the two tables' final state
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
90 / 148

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.

91 / 148
Section
11. Hands on: click through all seven propagations
92 / 148

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".

93 / 148

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:

94 / 148
Kernel lab
TeaVMClick through the seven propagationsidle
Switch to 'Rollback rules' and watch a transaction stay completely unmoved when a checked exception is thrown
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
95 / 148

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:

96 / 148
Kernel lab
TeaVMWhat self-invocation bypassesidle
Compare the JDK and CGLIB tabs first to confirm the proxy exists, then switch to 'Self-invocation' and watch the interceptor chain disappear
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
97 / 148

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:

98 / 148
Kernel lab
TeaVMNested transactions borrow two connectionsidle
Start with 'Idle hit' to see the smooth case, then 'Wait timeout' to see the app freeze once the suspended outer connection fills the pool
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
99 / 148

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:

100 / 148
Kernel lab
TeaVMWhere the transaction boundary landsidle
Watch 'An order, top-down' first to fix the call direction, then this one for the consequences of annotating controller / service / repository, and finish against Section 7's readOnly
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
101 / 148

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:

102 / 148
Kernel lab
TeaVMTransaction aspect versus custom aspectidle
This position shows how TransactionInterceptor nests with your own @Aspect; switch to 'Reversed order' to see which log lines swap places when it goes backwards
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
103 / 148

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:

104 / 148
Console
105 / 148
Note

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.

106 / 148
Section
12. Sandbox: which propagation does this method need
107 / 148

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:

108 / 148
Sandbox
SandboxPropagation picker: state the need, then pick the enum
Result
@Transactional ← nothing extra to write, REQUIRED is the default
transactions: 1 connections held: conn1 x 1
inner throws -> outer rolls back too ✔ exactly what you wanted
# test: would an inner failure leave dirty business data? yes -> merge them
Most create/update/delete code belongs in this cell. Do not swap the enum just to look sophisticated.
109 / 148
Note

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.

110 / 148
Section
13. Checkpoints
111 / 148

A warm-up straight from the number-one row of the failure table in Section 6:

112 / 148
Quiz
Check yourselfInside one Service class, `outer()` calls the `@Transactional` `inner()` via `this.inner()`, and the transaction never starts. What is the most accurate cause?
Pick one — you get feedback right away
113 / 148

Now a combined question threading Section 5's propagation rules together with the connection cost from Section 11:

114 / 148
Quiz
Check yourselfOuter `create()` is REQUIRED and calls `log()`, which is REQUIRES_NEW. `log()` inserts and commits successfully, then the outer method throws a RuntimeException. What is the final state of the database?
Pick one — you get feedback right away
115 / 148
Section
14. Error quick-reference
116 / 148

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.

117 / 148

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:

118 / 148
  1. Self-invocation: this.method() inside the same class, bypassing the proxy (Section 6)
  2. Method is not public: proxies enhance public methods by default, so protected / private is the same as writing nothing
  3. The bean never entered the container: an object you new XxxService() yourself has no shell, so the annotation is decoration
  4. The exception is swallowed or re-wrapped: you catch it without rethrowing, or convert it into a normal return value, so the proxy concludes "success"
  5. The exception type is outside the rollback rules: checked exceptions (IOException / SQLException) do not roll back by default — you need rollbackFor
119 / 148
Table
Error text (fragment)Real cause30-second fixWhere to dig deeper
TransactionRequiredException: Executing an update/delete query (JPA)A write ran in a context with no transaction, and the EntityManager refuses itAnnotate that Service method with @Transactional; for a custom @Modifying repository query, make sure the caller is transactional tooSections 3 and 6
Cannot transaction on closed connection / Connection has been closedThe 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 maxLifetimeSections 4 and 6
getConnection was not registered for synchronization because DataSource is not transactionalThe 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 transactionSection 8
No exception at all, yet data stayed in the table after a checked exception was thrownOnly RuntimeException / Error roll back by default, so throws IOException counts as a normal returnWrite @Transactional(rollbackFor = Exception.class) and pin that exact line in your team conventionsSection 6
NestedTransactionNotSupportedException: Nested transactions are not supported by this JDBC driverYou chose NESTED, but the driver/pool offers no savepoints, or a wrapper hid the savepoint APIFall back to REQUIRES_NEW (slightly different semantics but it runs), or remove the wrapping layer; MySQL Connector/J with InnoDB does support savepointsSection 5
TransactionTimedOutException: Transaction timed out: deadline was ...The timeout attribute fired and the transaction was force-rolled-backLook for a slow SQL or an HTTP call inside the transaction; move external IO out instead of blindly raising the timeoutSection 7
Connection is busy: result set is not closed, or the pool saturated and requests queued for agesA long transaction kept a connection hostage, or a manually fetched connection was never returnedEnable leakDetectionThreshold to find the leak; give long read-only queries their own short transaction; reconcile pool size with concurrencyArticle 28
120 / 148
Tip

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.

121 / 148

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:

122 / 148
Triage
Error triageTransactionRequiredException: Executing an update/delete query
A JPA write on a thread with no transaction: a proxy in the stack, but no transaction

A nightly job zeroes the statistics table. Everything passed locally, and the very first production run threw this with a suspiciously short business stack.

jakarta.transaction.TransactionRequiredException: Executing an update/delete query
at org.hibernate.internal.AbstractSharedSessionContract.checkTransactionNeededForUpdateOperation(AbstractSharedSessionContract.java:51)
at org.hibernate.query.spi.AbstractQuery.executeUpdate(AbstractQuery.java:64)
at com.bee.order.dao.OrderStatDao.resetDaily(OrderStatDao.java:47)
at com.bee.order.service.ReportService.resetWithinTx(ReportService.java:71)
at com.bee.order.service.ReportService.refresh(ReportService.java:63)
at com.bee.order.service.ReportService$$SpringCGLIB$$0.refresh(<generated>)
at com.bee.order.job.DailyJob.run(DailyJob.java:29)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
123 / 148
Section
15. Decision card and summary
124 / 148
Decision
DecisionThe system requires "whether the business succeeds or fails, always leave an audit log that must not be lost". The audit row is written to the database inside the same method as the business operation. What should you do?
125 / 148
Section
16. Exercises
126 / 148
Section
Tier 1 · Follow along
127 / 148

Reproduce "all or nothing" with two in-memory accounts and watch a rollback happen. Fully runnable code (no database — the logs do the talking):

128 / 148
java
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; }}
129 / 148
java
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);    }}
130 / 148

Expected output (run it yourself before reading on):

131 / 148
text
[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]
132 / 148

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.

133 / 148
Section
Tier 2 · Variant
134 / 148

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.

135 / 148

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.

136 / 148

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.

137 / 148
Section
Tier 3 · Build one
138 / 148

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.

139 / 148

Acceptance checklist:

140 / 148
  • [ ] Both account writes sit in one @Transactional method, 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_SUPPORTED or to a post-commit listener), and you can explain why it must not hold a connection while sleeping 300ms
  • [ ] Every catch that could swallow an exception either declares rollbackFor = Exception.class or calls setRollbackOnly() explicitly
  • [ ] With logging.level.org.springframework.transaction: TRACE on, paste one success and one rollback snippet and point out where Creating new transaction, Participating in existing transaction and Suspending current transaction each appear
  • [ ] Under 50 concurrent transfers, HikariCP's active and awaiting metrics do not keep climbing (proof the suspension did not drain the pool)
141 / 148
Section
17. Self-check
142 / 148
自检

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.

143 / 148
自检

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.

144 / 148
自检

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.

145 / 148
自检

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.

146 / 148
自检

what single log setting tells you whether your transaction opened at all? The answer should be the line Creating new transaction.

147 / 148
口诀

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.

148 / 148
Summary

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.